diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39ea920da..227d810d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: if: steps.rust.outputs.required != 'false' run: | cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack + # The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the + # prepared vanilla data, so without it they pass at once. + cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack # The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that # changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass @@ -144,6 +147,7 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} + full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }} # Horizon never downloads the game. These jobs need a self-hosted Linux runner # (label horizon-game) holding the maintainer's own copy: the official launcher's # game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in diff --git a/.github/workflows/parity-main.yml b/.github/workflows/parity-main.yml index 70fb33796..4840b98be 100644 --- a/.github/workflows/parity-main.yml +++ b/.github/workflows/parity-main.yml @@ -25,8 +25,8 @@ jobs: parity-main: # A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs # this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited - # behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down - # because the maintainer came back could time out, and a failure here bisects and comments on merges. + # behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs + # make a core-crate run several times longer, and a failure here bisects and comments on merges. runs-on: [self-hosted, macOS, horizon-parity-main] timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures steps: diff --git a/.github/workflows/parity.yml b/.github/workflows/parity.yml index 1fc31d8cb..c850c17aa 100644 --- a/.github/workflows/parity.yml +++ b/.github/workflows/parity.yml @@ -1,6 +1,6 @@ name: parity -# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's -# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository +# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the +# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository # and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md. on: workflow_call: @@ -13,6 +13,13 @@ on: description: The commit to check out and test type: string required: true + full: + description: >- + The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it + runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is + busy the job waits for it. + type: boolean + default: false permissions: contents: read env: @@ -21,40 +28,53 @@ env: jobs: parity: - runs-on: [self-hosted, horizon-game] - timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini + runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }} + # A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4 + # harnesses at a time) up to a few hours, and any pull request's job may land there. + timeout-minutes: ${{ inputs.full && 360 || 180 }} steps: - uses: actions/checkout@v4 with: - # The commit the event names: a fork's later pushes need another run-parity label. + # The commit the event names: a fork's later pushes need another run-parity label. The next step merges + # main into it. ref: ${{ inputs.head }} fetch-depth: 0 persist-credentials: false # Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs. clean: false + - name: The head merged into main (what merging it would land) + id: merge + env: + BASE_SHA: ${{ inputs.base }} + HEAD_SHA: ${{ inputs.head }} + run: | + # Harness fixes and game fixes that reached main since the branch was made then apply here too: a head + # tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before + # #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull + # request's own changes. A head that conflicts with main is tested alone. + set -euo pipefail + git merge --abort 2>/dev/null || true + git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main + main=$(git rev-parse origin/main) + if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then + echo "base=$main" >> "$GITHUB_OUTPUT" + echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY" + else + git merge --abort + echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT" + echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes" + echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY" + fi - uses: dtolnay/rust-toolchain@stable - name: Tools from the runner run: bash tools/ci/runner-env.sh - name: Vanilla data, and resources prepared by this preparer - env: - BASE_SHA: ${{ inputs.base }} - run: | - # main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on - # the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own. - set -euo pipefail - if git rev-parse --verify -q origin/main >/dev/null \ - && git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then - git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh" - bash "$RUNNER_TEMP/vanilla-data.sh" - else - bash tools/ci/vanilla-data.sh - fi + run: bash tools/ci/vanilla-data.sh - name: The prepared output is the one the preparer's OUTPUT_VERSION says env: - BASE_SHA: ${{ inputs.base }} + BASE_SHA: ${{ steps.merge.outputs.base }} run: | - # The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither - # the tool nor the number, so there is nothing to check. + # A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number. if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then echo "This head's preparer has no OUTPUT_VERSION: nothing to check" exit 0 @@ -65,47 +85,8 @@ jobs: - name: The harnesses this change can affect env: PARITY_HOSTS: '' - BASE_SHA: ${{ inputs.base }} - run: | - # main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a - # branch made before a better selection landed gets it too; the head's own runner then runs those - # harnesses. A branch that changes the selection itself selects with its own code. - set -euo pipefail - own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner - fork_point=$(git merge-base "$BASE_SHA" HEAD) - if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then - own - exit - fi - had_reach=$(git ls-files tools/parity_reach.py) - git show origin/main:tools/parity.py > tools/parity.py - git show origin/main:tools/parity_reach.py > tools/parity_reach.py - status=0 - python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$? - git checkout -- tools/parity.py - if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi - cat .local/parity-selection.txt - if [ "$status" != 0 ]; then - echo "main's selection failed: the head's own selection runs" - own - exit - fi - names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true) - # The summary line says how many would run: a count that doesn't match the names read (a format change) - # must not let the job pass untested. - expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p') - count=$(printf '%s' "$names" | grep -c . || true) - if [ -z "$expected" ] || [ "$expected" != "$count" ]; then - echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs" - own - exit - fi - if [ "$count" = 0 ]; then - echo "Nothing to run." - exit 0 - fi - # shellcheck disable=SC2086 - python3 tools/parity.py $names --timeout 2400 + BASE_SHA: ${{ steps.merge.outputs.base }} + run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner - name: Logs of the failed harnesses if: failure() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/run-parity.yml b/.github/workflows/run-parity.yml index a2b8e3291..9089a9a09 100644 --- a/.github/workflows/run-parity.yml +++ b/.github/workflows/run-parity.yml @@ -22,3 +22,4 @@ jobs: with: base: ${{ github.event.pull_request.base.sha }} head: ${{ github.event.pull_request.head.sha }} + full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }} diff --git a/AGENTS.md b/AGENTS.md index e1fa9e7fe..3804ebae8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md] `tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit. - **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d` folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and - `XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to + `XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the + launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to `~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`. - **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 @@ -34,7 +35,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se | Crate | Owns | |---|---| -| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) | +| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` | +| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) | | `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage | | `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) | | `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` | @@ -63,6 +66,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR python3 tools/pr_ready.py --quick # same without harnesses cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately +cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job) python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them @@ -118,16 +122,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end | Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` | | Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` | | Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` | -| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` | +| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` | | UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` | | Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) | | LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) | | Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` | | Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` | -| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` | +| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` | | Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` | | Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` | -| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` | +| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `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` | diff --git a/Cargo.lock b/Cargo.lock index 809df3a5a..e65b25dab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,6 +216,13 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "beacons" +version = "0.1.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "bit-set" version = "0.10.0" @@ -923,6 +930,13 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "far-view" +version = "1.0.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -1365,6 +1379,8 @@ dependencies = [ "glam", "horizon-assets", "horizon-bench", + "horizon-client-core", + "horizon-command", "horizon-core", "horizon-dualsense", "horizon-friends", @@ -1389,6 +1405,7 @@ dependencies = [ "sha1", "sha2", "symphonia", + "unicode-bidi", "ureq", "wgpu", "winit", @@ -1396,6 +1413,23 @@ dependencies = [ "zip", ] +[[package]] +name = "horizon-client-core" +version = "0.1.0" +dependencies = [ + "horizon-world", + "serde_json", +] + +[[package]] +name = "horizon-command" +version = "0.1.0" +dependencies = [ + "horizon-client-core", + "horizon-protocol", + "horizon-world", +] + [[package]] name = "horizon-core" version = "0.1.0" @@ -2971,6 +3005,13 @@ dependencies = [ "objc2-quartz-core 0.3.2", ] +[[package]] +name = "recipes" +version = "1.0.0" +dependencies = [ + "horizon-mod-sdk", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -3734,6 +3775,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.26" diff --git a/Cargo.toml b/Cargo.toml index 59bfe5e33..8f3153c62 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,46 @@ [workspace] exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] -members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"] +members = [ + "crates/horizon-core", + "crates/horizon-dualsense", + "crates/horizon-threads", + "crates/horizon-hdr", + "crates/horizon-world", + "crates/horizon-client", + "crates/horizon-client-core", + "crates/horizon-command", + "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/horizon-mods/tests/ui_guest", + "crates/cargo-horizon-mod", + "crates/horizon-port-pack", + "crates/horizon-shaders", + "crates/horizon-shader-api", + "crates/horizon-release-sign", + "crates/horizon-mod-sdk/examples/coords-hud", + "crates/horizon-mod-sdk/examples/predator-missile", + "crates/horizon-mod-sdk/examples/welcome", + "crates/horizon-mod-sdk/examples/beacons", + "mods/official/fps-overlay", + "mods/official/keystrokes", + "mods/official/far-view", + "mods/official/minimap", + "mods/official/zoom", + "xtask", + "mods/official/recipes", +] resolver = "2" [workspace.package] diff --git a/crates/cargo-horizon-mod/src/check.rs b/crates/cargo-horizon-mod/src/check.rs index 33095a115..988ba826d 100644 --- a/crates/cargo-horizon-mod/src/check.rs +++ b/crates/cargo-horizon-mod/src/check.rs @@ -200,7 +200,7 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { return Err(format!("it didn't start: {reason}")); } let client: Vec = mods.commands().map(str::to_owned).collect(); - let server: Vec = mods.server_commands().map(str::to_owned).collect(); + let server: Vec = mods.server_commands().map(|c| c.name.clone()).collect(); report.commands = [client.clone(), server.clone()].concat(); // A server mod plays with one player on, who joins, chats and leaves. let steve = ServerPlayer { @@ -266,8 +266,17 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> { format!("{} quiet command(s)", lines.len()) } Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")), + Output::SetBlocks { + dimension, blocks, .. + } => { + format!("set {} block(s) in {dimension}", blocks.len()) + } Output::OpenScreen { .. } => "opened a screen".to_string(), Output::CloseScreen { .. } => "closed its screen".to_string(), + Output::PlaceRecipe { book_id, all } => { + format!("placed recipe {book_id}{}", if all { " (all)" } else { "" }) + } + Output::Give { item, count, .. } => format!("gave {count} {item}"), Output::Shake { strength, seconds } => { format!("shook the camera: {strength:.2} for {seconds:.1} s") } diff --git a/crates/horizon-client-core/Cargo.toml b/crates/horizon-client-core/Cargo.toml new file mode 100644 index 000000000..8809a5216 --- /dev/null +++ b/crates/horizon-client-core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "horizon-client-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version = "1.90" +description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name" + +[dependencies] +horizon-world = { path = "../horizon-world" } +serde_json = { version = "1.0.151", features = ["raw_value"] } + +[lints] +workspace = true diff --git a/crates/horizon-client/src/block_entity_nbt.rs b/crates/horizon-client-core/src/block_entity_nbt.rs similarity index 98% rename from crates/horizon-client/src/block_entity_nbt.rs rename to crates/horizon-client-core/src/block_entity_nbt.rs index a4c02ba3b..a4387bf48 100644 --- a/crates/horizon-client/src/block_entity_nbt.rs +++ b/crates/horizon-client-core/src/block_entity_nbt.rs @@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag { Tag::Float(v.to_bits()) } +/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds. +pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> { + let Tag::IntArray(v) = tag else { return None }; + let p: [i32; 3] = v.as_slice().try_into().ok()?; + ((-30_000_000..30_000_000).contains(&p[0]) + && (-20_000_000..20_000_000).contains(&p[1]) + && (-30_000_000..30_000_000).contains(&p[2])) + .then_some(p) +} + /// What a block entity reads and writes, in `saveAdditional` order. struct Fields<'a> { input: &'a Tag, @@ -259,7 +269,7 @@ fn is_pos(tag: &Tag) -> bool { matches!(tag, Tag::IntArray(v) if v.len() == 3) } /// The loaded `SignText` of one side, or the empty text. -pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag { +pub fn sign_side(tag: Option<&Tag>) -> Tag { let empty = || string(""); let mut messages: Vec = match tag.and_then(|t| t.get("messages")) { Some(Tag::List(list)) => list.iter().filter_map(component).collect(), @@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option { } "minecraft:end_gateway" => { f.long("Age", 0); - f.keep("exit_portal", |t| { - is_pos(t) - && matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000) - }); + f.keep("exit_portal", |t| gateway_exit(t).is_some()); if flag(tag.get("ExactTeleport")) == Some(true) { f.put("ExactTeleport", boolean(true)); } diff --git a/crates/horizon-client/src/bossbar.rs b/crates/horizon-client-core/src/bossbar.rs similarity index 100% rename from crates/horizon-client/src/bossbar.rs rename to crates/horizon-client-core/src/bossbar.rs diff --git a/crates/horizon-client/src/component.rs b/crates/horizon-client-core/src/component.rs similarity index 93% rename from crates/horizon-client/src/component.rs rename to crates/horizon-client-core/src/component.rs index fced49898..afb68fa98 100644 --- a/crates/horizon-client/src/component.rs +++ b/crates/horizon-client-core/src/component.rs @@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map) -> Style { 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) { + // ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and + // https only, the scheme in any case (Util.parseAndValidateUntrustedUri). + Some("open_url") => text("url") + .filter(|u| { + u.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) + }) + .map(crate::text::ClickEvent::OpenUrl), + // ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT. + Some("change_page") => e + .get("page") + .and_then(Value::as_i64) + .and_then(|p| i32::try_from(p).ok()) + .filter(|p| *p >= 1) + .map(crate::text::ClickEvent::ChangePage), Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand), Some("copy_to_clipboard") => { text("value").map(crate::text::ClickEvent::CopyToClipboard) @@ -252,7 +268,7 @@ pub fn to_text_with( } else if let Some(sel) = o.get("selector").and_then(Value::as_str) { resolve(sel) } else if let Some(key) = o.get("keybind").and_then(Value::as_str) { - crate::keybinds::display_name(key) + crate::key_display::display_name(key) } else { Text::empty() }; diff --git a/crates/horizon-client/src/fastutil_order.rs b/crates/horizon-client-core/src/fastutil_order.rs similarity index 100% rename from crates/horizon-client/src/fastutil_order.rs rename to crates/horizon-client-core/src/fastutil_order.rs diff --git a/crates/horizon-client/src/gamerules.rs b/crates/horizon-client-core/src/gamerules.rs similarity index 90% rename from crates/horizon-client/src/gamerules.rs rename to crates/horizon-client-core/src/gamerules.rs index 7198fd202..6715e01ec 100644 --- a/crates/horizon-client/src/gamerules.rs +++ b/crates/horizon-client-core/src/gamerules.rs @@ -292,27 +292,4 @@ mod tests { assert_eq!(GameRules::load(&dir), rules); let _ = fs::remove_dir_all(dir); } - /// The world's save (Level.write) writes the rules as they are now, and a - /// world opened again reads them back (#74). - #[test] - fn level_save_keeps_changed_rules() { - let dir = - std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - let mut level = crate::save::Level::default(); - level.write(&dir).unwrap(); - // Rules changed while playing, then saved and loaded again. - let mut rules = GameRules::load(&dir); - assert_eq!(rules, GameRules::default()); - assert!(rules.set("advance_time", Value::Bool(false))); - assert!(rules.set("advance_weather", Value::Bool(false))); - assert!(rules.set("random_tick_speed", Value::Int(0))); - level.game_rules = rules.by_name(); - level.write(&dir).unwrap(); - assert_eq!(GameRules::load(&dir), rules); - let read = crate::save::Level::read(&dir).unwrap(); - assert_eq!(read.game_rules, rules.by_name()); - assert_eq!(read.game_rule("advance_time"), 0); - let _ = fs::remove_dir_all(dir); - } } diff --git a/crates/horizon-client-core/src/identifier.rs b/crates/horizon-client-core/src/identifier.rs new file mode 100644 index 000000000..1e76f7731 --- /dev/null +++ b/crates/horizon-client-core/src/identifier.rs @@ -0,0 +1,18 @@ +//! Resource identifiers (`Identifier`). + +/// Identifier.parse: `ns:path` or `path`, validated. +pub fn parse_identifier(raw: &str) -> Option { + let (ns, path) = match raw.split_once(':') { + Some(("", path)) => ("minecraft", path), + Some((ns, path)) => (ns, path), + None => ("minecraft", raw), + }; + let ns_ok = ns != ".." + && ns + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-')); + let path_ok = path.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/') + }); + (ns_ok && path_ok).then(|| format!("{ns}:{path}")) +} diff --git a/crates/horizon-client-core/src/key_display.rs b/crates/horizon-client-core/src/key_display.rs new file mode 100644 index 000000000..fc57c97bd --- /dev/null +++ b/crates/horizon-client-core/src/key_display.rs @@ -0,0 +1,38 @@ +//! Key display names (`InputConstants.Key.getDisplayName`), shared by key +//! mappings and `keybind` text components. +use crate::text::Text; + +/// InputConstants.Key.getDisplayName: one-character keys as the upper-case +/// character, others by translation. +pub fn display_name(key: &str) -> Text { + if let Some(rest) = key.strip_prefix("key.keyboard.") + && rest.chars().count() == 1 + { + let c = rest.to_uppercase(); + return Text::literal(c); + } + let symbol = match key { + "key.keyboard.minus" => Some("-"), + "key.keyboard.equal" => Some("="), + "key.keyboard.left.bracket" => Some("["), + "key.keyboard.right.bracket" => Some("]"), + "key.keyboard.backslash" => Some("\\"), + "key.keyboard.semicolon" => Some(";"), + "key.keyboard.apostrophe" => Some("'"), + "key.keyboard.grave.accent" => Some("`"), + "key.keyboard.comma" => Some(","), + "key.keyboard.period" => Some("."), + "key.keyboard.slash" => Some("/"), + _ => None, + }; + if let Some(s) = symbol { + return Text::literal(s); + } + if let Some(n) = key.strip_prefix("key.mouse.") + && n.parse::().is_ok() + && !crate::lang::has(key) + { + return Text::translate("key.mouse", &[Text::literal(n)]); + } + Text::translate(key, &[]) +} diff --git a/crates/horizon-client/src/lang.rs b/crates/horizon-client-core/src/lang.rs similarity index 90% rename from crates/horizon-client/src/lang.rs rename to crates/horizon-client-core/src/lang.rs index cb96cc373..ad9f1a358 100644 --- a/crates/horizon-client/src/lang.rs +++ b/crates/horizon-client-core/src/lang.rs @@ -4,7 +4,10 @@ //! language used by every screen. use std::{ collections::{BTreeMap, HashMap}, - sync::{Arc, RwLock}, + sync::{ + Arc, RwLock, + atomic::{AtomicBool, Ordering}, + }, }; #[derive(Clone, Debug, PartialEq, Eq)] @@ -23,11 +26,16 @@ impl LanguageInfo { #[derive(Default)] pub struct Language { pub code: String, + /// `LanguageInfo.bidirectional`: text without a strong character reads + /// right to left (`ClientLanguage.defaultRightToLeft`). + bidirectional: bool, map: HashMap, } pub const DEFAULT: &str = "en_us"; static CURRENT: RwLock>> = RwLock::new(None); +/// The current language's `bidirectional`, read for every drawn string. +static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false); /// A source of resource files: the prepared root plus packs, highest priority last. pub trait ResourceSource { @@ -42,8 +50,12 @@ struct Bundled; impl ResourceSource for Bundled { fn read(&self, path: &str) -> Option> { let body = match path { - "assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"), - "assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"), + "assets/horizon/lang/en_us.json" => { + include_str!("../../horizon-client/assets/horizon/lang/en_us.json") + } + "assets/horizon/lang/ru_ru.json" => { + include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json") + } _ => return None, }; Some(body.as_bytes().to_vec()) @@ -130,8 +142,12 @@ impl Language { { apply_deprecated(&mut map, &bytes); } + let bidirectional = available(sources) + .iter() + .any(|info| info.code == code && info.bidirectional); Self { code: code.to_owned(), + bidirectional, map, } } @@ -193,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec { } pub fn set(language: Language) { + BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed); *CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language)); } pub fn current() -> Option> { CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone() } +/// `ClientLanguage.defaultRightToLeft` of the current language. +pub fn bidirectional() -> bool { + BIDIRECTIONAL.load(Ordering::Relaxed) +} pub fn code() -> String { current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone()) } diff --git a/crates/horizon-client-core/src/lib.rs b/crates/horizon-client-core/src/lib.rs new file mode 100644 index 000000000..7721668ca --- /dev/null +++ b/crates/horizon-client-core/src/lib.rs @@ -0,0 +1,26 @@ +//! The client's lowest layer, below commands and gameplay: chat text and +//! translations, SNBT and NBT paths, text components, the scoreboard, boss +//! bars, game rules, block entity NBT tables, and the registry, particle and +//! sound source tables commands name, and the `pack.mcmeta` filter regex. +//! horizon-client re-exports each module under its old path +//! (`horizon_client::text`, ...). +#![forbid(unsafe_code)] +pub mod block_entity_nbt; +pub mod bossbar; +pub mod component; +pub mod fastutil_order; +pub mod gamerules; +pub mod identifier; +pub mod key_display; +pub mod lang; +pub mod nbt_path; +pub mod particle_type; +pub mod pattern; +pub mod potions; +pub mod registries; +pub mod scoreboard; +pub mod snbt; +pub mod sound_source; +pub mod text; +pub mod uuid; +pub type Result = std::result::Result>; diff --git a/crates/horizon-client/src/nbt_path.rs b/crates/horizon-client-core/src/nbt_path.rs similarity index 100% rename from crates/horizon-client/src/nbt_path.rs rename to crates/horizon-client-core/src/nbt_path.rs diff --git a/crates/horizon-client-core/src/particle_type.rs b/crates/horizon-client-core/src/particle_type.rs new file mode 100644 index 000000000..98de2380b --- /dev/null +++ b/crates/horizon-client-core/src/particle_type.rs @@ -0,0 +1,209 @@ +//! Particle types (`ParticleTypes`): the vanilla particle types with a client +//! implementation, by registry name, and their per-type flags. + +macro_rules! particle_types { + ($($variant:ident = $name:literal),* $(,)?) => { + /// Vanilla particle types with a client implementation here. + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] + pub enum ParticleType { $($variant),* } + impl ParticleType { + pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*]; + /// Registry path (`minecraft:`), also the sprite-set file name. + pub fn name(self) -> &'static str { + match self { $(ParticleType::$variant => $name),* } + } + pub fn from_name(name: &str) -> Option { + let name = name.strip_prefix("minecraft:").unwrap_or(name); + match name { $($name => Some(ParticleType::$variant),)* _ => None } + } + } + }; +} +particle_types! { + Block = "block", + BlockCrumble = "block_crumble", + Trail = "trail", + DustPillar = "dust_pillar", + FallingDust = "falling_dust", + Smoke = "smoke", + LargeSmoke = "large_smoke", + WhiteSmoke = "white_smoke", + Flame = "flame", + SoulFireFlame = "soul_fire_flame", + CopperFireFlame = "copper_fire_flame", + SmallFlame = "small_flame", + Lava = "lava", + DrippingWater = "dripping_water", + FallingWater = "falling_water", + DrippingLava = "dripping_lava", + FallingLava = "falling_lava", + LandingLava = "landing_lava", + DrippingHoney = "dripping_honey", + FallingHoney = "falling_honey", + LandingHoney = "landing_honey", + FallingNectar = "falling_nectar", + FallingSporeBlossom = "falling_spore_blossom", + SporeBlossomAir = "spore_blossom_air", + DrippingObsidianTear = "dripping_obsidian_tear", + FallingObsidianTear = "falling_obsidian_tear", + LandingObsidianTear = "landing_obsidian_tear", + DrippingDripstoneWater = "dripping_dripstone_water", + FallingDripstoneWater = "falling_dripstone_water", + DrippingDripstoneLava = "dripping_dripstone_lava", + FallingDripstoneLava = "falling_dripstone_lava", + Splash = "splash", + Rain = "rain", + Bubble = "bubble", + SulfurBubbles = "sulfur_bubbles", + BubblePop = "bubble_pop", + BubbleColumnUp = "bubble_column_up", + CurrentDown = "current_down", + Underwater = "underwater", + Portal = "portal", + ReversePortal = "reverse_portal", + Enchant = "enchant", + Nautilus = "nautilus", + VaultConnection = "vault_connection", + Crit = "crit", + EnchantedHit = "enchanted_hit", + DamageIndicator = "damage_indicator", + Heart = "heart", + AngryVillager = "angry_villager", + HappyVillager = "happy_villager", + Composter = "composter", + Mycelium = "mycelium", + Dolphin = "dolphin", + EggCrack = "egg_crack", + Poof = "poof", + Explosion = "explosion", + ExplosionEmitter = "explosion_emitter", + CampfireCosySmoke = "campfire_cosy_smoke", + CampfireSignalSmoke = "campfire_signal_smoke", + Note = "note", + Dust = "dust", + TotemOfUndying = "totem_of_undying", + Firework = "firework", + EndRod = "end_rod", + Ash = "ash", + WhiteAsh = "white_ash", + CrimsonSpore = "crimson_spore", + WarpedSpore = "warped_spore", + CherryLeaves = "cherry_leaves", + PaleOakLeaves = "pale_oak_leaves", + TintedLeaves = "tinted_leaves", + Effect = "effect", + InstantEffect = "instant_effect", + EntityEffect = "entity_effect", + Witch = "witch", + Infested = "infested", + Cloud = "cloud", + Sneeze = "sneeze", + Snowflake = "snowflake", + Soul = "soul", + SculkSoul = "sculk_soul", + DragonBreath = "dragon_breath", + Gust = "gust", + SmallGust = "small_gust", + GustEmitterLarge = "gust_emitter_large", + GustEmitterSmall = "gust_emitter_small", + DustColorTransition = "dust_color_transition", + SculkCharge = "sculk_charge", + SculkChargePop = "sculk_charge_pop", + Shriek = "shriek", + SonicBoom = "sonic_boom", + Vibration = "vibration", + SweepAttack = "sweep_attack", + Glow = "glow", + WaxOn = "wax_on", + WaxOff = "wax_off", + ElectricSpark = "electric_spark", + Scrape = "scrape", + SquidInk = "squid_ink", + GlowSquidInk = "glow_squid_ink", + Item = "item", + ItemSlime = "item_slime", + ItemSnowball = "item_snowball", + ItemCobweb = "item_cobweb", + SulfurCubeGoo = "sulfur_cube_goo", + Flash = "flash", + Firefly = "firefly", + TrialSpawnerDetection = "trial_spawner_detection", + TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous", + TrialOmen = "trial_omen", + RaidOmen = "raid_omen", + OminousSpawning = "ominous_spawning", + Spit = "spit", + Fishing = "fishing", + DustPlume = "dust_plume", + RedPoplarLeaves = "red_poplar_leaves", + OrangePoplarLeaves = "orange_poplar_leaves", + YellowPoplarLeaves = "yellow_poplar_leaves", + PauseMobGrowth = "pause_mob_growth", + ResetMobGrowth = "reset_mob_growth", + BlockMarker = "block_marker", +} +impl ParticleType { + /// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`): + /// spawned regardless of distance. + pub fn override_limiter(self) -> bool { + matches!( + self, + Self::BlockMarker + | Self::DamageIndicator + | Self::ExplosionEmitter + | Self::Explosion + | Self::Gust + | Self::GustEmitterLarge + | Self::GustEmitterSmall + | Self::SonicBoom + | Self::SculkCharge + | Self::SculkChargePop + | Self::Vibration + | Self::Poof + | Self::Spit + | Self::SquidInk + | Self::SweepAttack + | Self::CampfireCosySmoke + | Self::CampfireSignalSmoke + | Self::GlowSquidInk + | Self::Glow + | Self::WaxOn + | Self::WaxOff + | Self::ElectricSpark + | Self::Scrape + | Self::TrialSpawnerDetection + | Self::TrialSpawnerDetectionOminous + | Self::VaultConnection + | Self::OminousSpawning + ) + } + /// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class + /// hardcodes the translucent layer, drawn after translucent terrain. + /// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their + /// layer from the sprite instead (`Layer.bySprite`); see the `translucent` + /// sprite set on the engine. + pub fn translucent_layer(self) -> bool { + match self { + // CampfireSmokeParticle. + Self::CampfireCosySmoke | Self::CampfireSignalSmoke + // PlayerCloudParticle. + | Self::Cloud | Self::Sneeze + // SpellParticle. + | Self::Effect | Self::InstantEffect | Self::EntityEffect + | Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen + // EmissiveRisingParticle. + | Self::Soul | Self::SculkSoul + // SculkChargeParticle, SculkChargePopParticle, ShriekParticle, + // VibrationSignalParticle, FireflyParticle, OverlayParticle. + | Self::SculkCharge | Self::SculkChargePop | Self::Shriek + | Self::Vibration | Self::Firefly | Self::Flash + // SimpleAnimatedParticle and its subclasses: EndRodParticle, + // SparkParticle (firework), SquidInkParticle, TotemParticle. + | Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk + | Self::TotemOfUndying + // FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque. + | Self::VaultConnection => true, + _ => false, + } + } +} diff --git a/crates/horizon-client/src/packs/pattern.rs b/crates/horizon-client-core/src/pattern.rs similarity index 100% rename from crates/horizon-client/src/packs/pattern.rs rename to crates/horizon-client-core/src/pattern.rs diff --git a/crates/horizon-client-core/src/potions.rs b/crates/horizon-client-core/src/potions.rs new file mode 100644 index 000000000..044b25e3a --- /dev/null +++ b/crates/horizon-client-core/src/potions.rs @@ -0,0 +1,249 @@ +//! The `MobEffects` and `Potions` registries (code in vanilla, ported as +//! tables): each effect's colour and category, each potion's effects, and the +//! potion colour mix. horizon-client re-exports them from `brewing`. +use std::{collections::HashMap, sync::OnceLock}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Category { + Beneficial, + Harmful, + Neutral, +} +/// A `MobEffect`: id (without namespace), colour, category, instantaneous. +#[derive(Clone, Copy, Debug)] +pub struct EffectDef { + pub id: &'static str, + pub color: u32, + pub category: Category, + pub instant: bool, +} +const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef { + EffectDef { + id, + color, + category, + instant: false, + } +} +use Category::{Beneficial as B, Harmful as H, Neutral as N}; +/// `MobEffects` in registration order. +pub const EFFECTS: &[EffectDef] = &[ + fx("speed", 3402751, B), + fx("slowness", 9154528, H), + fx("haste", 14270531, B), + fx("mining_fatigue", 4866583, H), + fx("strength", 16762624, B), + EffectDef { + id: "instant_health", + color: 16262179, + category: B, + instant: true, + }, + EffectDef { + id: "instant_damage", + color: 11101546, + category: H, + instant: true, + }, + fx("jump_boost", 16646020, B), + fx("nausea", 5578058, H), + fx("regeneration", 13458603, B), + fx("resistance", 9520880, B), + fx("fire_resistance", 16750848, B), + fx("water_breathing", 10017472, B), + fx("invisibility", 16185078, B), + fx("blindness", 2039587, H), + fx("night_vision", 12779366, B), + fx("hunger", 5797459, H), + fx("weakness", 4738376, H), + fx("poison", 8889187, H), + fx("wither", 7561558, H), + fx("health_boost", 16284963, B), + fx("absorption", 2445989, B), + EffectDef { + id: "saturation", + color: 16262179, + category: B, + instant: true, + }, + fx("glowing", 9740385, N), + fx("levitation", 13565951, H), + fx("luck", 5882118, B), + fx("unluck", 12624973, H), + fx("slow_falling", 15978425, B), + fx("conduit_power", 1950417, B), + fx("dolphins_grace", 8954814, B), + fx("bad_omen", 745784, N), + fx("hero_of_the_village", 4521796, B), + fx("darkness", 2696993, H), + fx("trial_omen", 1484454, N), + fx("raid_omen", 14565464, N), + fx("wind_charged", 12438015, H), + fx("weaving", 7891290, H), + fx("oozing", 10092451, H), + fx("infested", 9214860, H), + fx("breath_of_the_nautilus", 65518, B), +]; +pub fn effect(id: &str) -> Option<&'static EffectDef> { + let id = id.trim_start_matches("minecraft:"); + EFFECTS.iter().find(|e| e.id == id) +} +/// Namespaced effect ids for mob effect storage. +pub fn namespaced(id: &'static str) -> &'static str { + static IDS: OnceLock> = OnceLock::new(); + IDS.get_or_init(|| { + EFFECTS + .iter() + .map(|e| { + ( + e.id, + &*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()), + ) + }) + .collect() + }) + .get(id) + .copied() + .unwrap_or(id) +} +/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.`) and +/// effects as (effect, duration ticks, amplifier). +#[derive(Clone, Copy, Debug)] +pub struct PotionDef { + pub id: &'static str, + pub name: &'static str, + pub effects: &'static [(&'static str, i32, i32)], +} +const fn p( + id: &'static str, + name: &'static str, + effects: &'static [(&'static str, i32, i32)], +) -> PotionDef { + PotionDef { id, name, effects } +} +/// `Potions` in registration order. +pub const POTIONS: &[PotionDef] = &[ + p("water", "water", &[]), + p("mundane", "mundane", &[]), + p("thick", "thick", &[]), + p("awkward", "awkward", &[]), + p("night_vision", "night_vision", &[("night_vision", 3600, 0)]), + p( + "long_night_vision", + "night_vision", + &[("night_vision", 9600, 0)], + ), + p("invisibility", "invisibility", &[("invisibility", 3600, 0)]), + p( + "long_invisibility", + "invisibility", + &[("invisibility", 9600, 0)], + ), + p("leaping", "leaping", &[("jump_boost", 3600, 0)]), + p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]), + p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]), + p( + "fire_resistance", + "fire_resistance", + &[("fire_resistance", 3600, 0)], + ), + p( + "long_fire_resistance", + "fire_resistance", + &[("fire_resistance", 9600, 0)], + ), + p("swiftness", "swiftness", &[("speed", 3600, 0)]), + p("long_swiftness", "swiftness", &[("speed", 9600, 0)]), + p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]), + p("slowness", "slowness", &[("slowness", 1800, 0)]), + p("long_slowness", "slowness", &[("slowness", 4800, 0)]), + p("strong_slowness", "slowness", &[("slowness", 400, 3)]), + p( + "turtle_master", + "turtle_master", + &[("slowness", 400, 3), ("resistance", 400, 2)], + ), + p( + "long_turtle_master", + "turtle_master", + &[("slowness", 800, 3), ("resistance", 800, 2)], + ), + p( + "strong_turtle_master", + "turtle_master", + &[("slowness", 400, 5), ("resistance", 400, 3)], + ), + p( + "water_breathing", + "water_breathing", + &[("water_breathing", 3600, 0)], + ), + p( + "long_water_breathing", + "water_breathing", + &[("water_breathing", 9600, 0)], + ), + p("healing", "healing", &[("instant_health", 1, 0)]), + p("strong_healing", "healing", &[("instant_health", 1, 1)]), + p("harming", "harming", &[("instant_damage", 1, 0)]), + p("strong_harming", "harming", &[("instant_damage", 1, 1)]), + p("poison", "poison", &[("poison", 900, 0)]), + p("long_poison", "poison", &[("poison", 1800, 0)]), + p("strong_poison", "poison", &[("poison", 432, 1)]), + p("regeneration", "regeneration", &[("regeneration", 900, 0)]), + p( + "long_regeneration", + "regeneration", + &[("regeneration", 1800, 0)], + ), + p( + "strong_regeneration", + "regeneration", + &[("regeneration", 450, 1)], + ), + p("strength", "strength", &[("strength", 3600, 0)]), + p("long_strength", "strength", &[("strength", 9600, 0)]), + p("strong_strength", "strength", &[("strength", 1800, 1)]), + p("weakness", "weakness", &[("weakness", 1800, 0)]), + p("long_weakness", "weakness", &[("weakness", 4800, 0)]), + p("luck", "luck", &[("luck", 6000, 0)]), + p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]), + p( + "long_slow_falling", + "slow_falling", + &[("slow_falling", 4800, 0)], + ), + p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]), + p("weaving", "weaving", &[("weaving", 3600, 0)]), + p("oozing", "oozing", &[("oozing", 3600, 0)]), + p("infested", "infested", &[("infested", 3600, 0)]), +]; +pub fn potion(id: &str) -> Option<&'static PotionDef> { + let id = id.trim_start_matches("minecraft:"); + POTIONS.iter().find(|p| p.id == id) +} +/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB. +pub const BASE_POTION_COLOR: u32 = 0x385DC6; +/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours. +pub fn potion_color(id: Option<&str>) -> u32 { + let Some(def) = id.and_then(potion) else { + return BASE_POTION_COLOR; + }; + let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32); + for (e, _, amp) in def.effects { + let Some(e) = effect(e) else { continue }; + let w = *amp as u32 + 1; + r += w * (e.color >> 16 & 255); + g += w * (e.color >> 8 & 255); + b += w * (e.color & 255); + total += w; + } + match ( + r.checked_div(total), + g.checked_div(total), + b.checked_div(total), + ) { + (Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b, + _ => BASE_POTION_COLOR, + } +} diff --git a/crates/horizon-client-core/src/registries.rs b/crates/horizon-client-core/src/registries.rs new file mode 100644 index 000000000..310115cf8 --- /dev/null +++ b/crates/horizon-client-core/src/registries.rs @@ -0,0 +1,188 @@ +//! Registry contents and source state that commands validate and complete +//! arguments against (what the server's registry access holds). +use std::collections::BTreeMap; + +/// Registry contents and source state used to validate and complete arguments. +#[derive(Clone, Debug, Default)] +pub struct Registries { + /// Permission level of the command source. + pub permission: u8, + pub items: Vec, + pub item_tags: Vec, + /// Block id -> (property, allowed values). + pub blocks: BTreeMap)>>, + pub block_tags: Vec, + /// Entity types that `summon` can create. + pub summonable: Vec, + /// Every entity type (selector `type=`). + pub entity_types: Vec, + pub effects: Vec, + pub enchantments: Vec, + pub structures: Vec, + pub biomes: Vec, + pub dimensions: Vec, + pub clocks: Vec, + pub timelines: Vec, + pub time_markers: Vec, + pub advancements: Vec, + /// Game rule name -> integer-valued. + pub game_rules: Vec<(String, bool)>, + pub players: Vec, + /// Scoreboard objective names, team names and tracked score holders. + pub objectives: Vec, + pub teams: Vec, + pub tracked: Vec, + /// Loaded datapack functions (`ns:path`) and function tags (`ns:path`). + pub functions: Vec, + /// `/data ... storage` ids. + pub storages: Vec, + /// Boss bar ids and datapack predicate ids. + pub bossbars: Vec, + pub predicates: Vec, + pub item_modifiers: Vec, + pub schedules: Vec, + pub stopwatches: Vec, + pub recipes: Vec, + pub sounds: Vec, + pub damage_types: Vec, + pub attributes: Vec, + pub loot_tables: Vec, + pub templates: Vec, + pub random_sequences: Vec, + pub datapacks_enabled: Vec, + pub datapacks_disabled: Vec, + pub function_tags: Vec, + /// The block the source is looking at (relevant coordinates). + pub target_block: Option<[i32; 3]>, + /// Every registry's element and tag ids (`minecraft:item` -> ...), as + /// the server's registry access and reloadable registries hold them. + pub by_registry: BTreeMap, + /// Blocks whose default state has a block entity. + pub block_entities: std::collections::BTreeSet, + /// Block tag -> member blocks. + pub block_tag_members: BTreeMap>, + /// Data component types with a codec (not transient). + pub persistent_components: Vec, + /// Recipes `/recipe` can give (not special). + pub learnable_recipes: Vec, + /// Advancement id -> criterion names. + pub advancement_criteria: BTreeMap>, + /// World clock -> time markers shown in commands. + pub clock_time_markers: BTreeMap>, + /// Trigger objectives the source may use (`/trigger` suggestions). + pub trigger_objectives: Vec, + /// Objectives with the `trigger` criterion -> holder -> whether its score is locked. + pub trigger_scores: BTreeMap>, +} +/// The ids and tag ids of one registry. +#[derive(Clone, Debug, Default)] +pub struct RegistryIds { + pub ids: Vec, + pub tags: Vec, +} +const EMPTY: &[String] = &[]; +impl Registries { + /// Fill what the game did not build itself from vanilla's exported + /// registries (`command-registries.json`, written by + /// `VanillaCommandParseOracle registries`): built-in registries such as + /// mob effects, data component types and dialogs, block entities and tag + /// members. Registries that data packs reload stay with the game. + pub fn fill_from_export(&mut self, r: &serde_json::Value) { + fn strings(v: &serde_json::Value) -> Vec { + v.as_array() + .map(|a| { + a.iter() + .filter_map(|x| x.as_str().map(str::to_owned)) + .collect() + }) + .unwrap_or_default() + } + let fill = |field: &mut Vec, key: &str| { + if field.is_empty() { + *field = strings(&r[key]); + } + }; + fill(&mut self.effects, "effects"); + fill(&mut self.enchantments, "enchantments"); + fill(&mut self.attributes, "attributes"); + fill(&mut self.damage_types, "damage_types"); + fill(&mut self.templates, "templates"); + fill(&mut self.persistent_components, "persistent_components"); + fill(&mut self.learnable_recipes, "learnable_recipes"); + const RELOADED: [&str; 5] = [ + "minecraft:advancement", + "minecraft:recipe", + "minecraft:loot_table", + "minecraft:predicate", + "minecraft:item_modifier", + ]; + for (name, entry) in r["by_registry"].as_object().into_iter().flatten() { + if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) { + continue; + } + self.by_registry.insert( + name.clone(), + RegistryIds { + ids: strings(&entry["ids"]), + tags: strings(&entry["tags"]), + }, + ); + } + if self.block_entities.is_empty() { + self.block_entities = strings(&r["block_entities"]).into_iter().collect(); + } + if self.block_tag_members.is_empty() { + for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() { + self.block_tag_members.insert(tag.clone(), strings(members)); + } + } + if self.clock_time_markers.is_empty() { + for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() { + self.clock_time_markers + .insert(clock.clone(), strings(markers)); + } + } + if self.advancement_criteria.is_empty() { + for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() { + self.advancement_criteria + .insert(id.clone(), strings(criteria)); + } + } + } + /// The element ids of a registry (sorted). + pub fn ids(&self, registry: &str) -> &[String] { + if let Some(r) = self.by_registry.get(registry) { + return &r.ids; + } + match registry { + "minecraft:item" => &self.items, + "minecraft:entity_type" => &self.entity_types, + "minecraft:mob_effect" => &self.effects, + "minecraft:enchantment" => &self.enchantments, + "minecraft:worldgen/structure" => &self.structures, + "minecraft:worldgen/biome" => &self.biomes, + "minecraft:world_clock" => &self.clocks, + "minecraft:timeline" => &self.timelines, + "minecraft:attribute" => &self.attributes, + "minecraft:damage_type" => &self.damage_types, + "minecraft:sound_event" => &self.sounds, + "minecraft:predicate" => &self.predicates, + "minecraft:item_modifier" => &self.item_modifiers, + "minecraft:loot_table" => &self.loot_tables, + "minecraft:recipe" => &self.recipes, + "minecraft:advancement" => &self.advancements, + _ => EMPTY, + } + } + /// The tag ids of a registry (sorted). + pub fn tags(&self, registry: &str) -> &[String] { + if let Some(r) = self.by_registry.get(registry) { + return &r.tags; + } + match registry { + "minecraft:item" => &self.item_tags, + "minecraft:block" => &self.block_tags, + _ => EMPTY, + } + } +} diff --git a/crates/horizon-client/src/scoreboard.rs b/crates/horizon-client-core/src/scoreboard.rs similarity index 99% rename from crates/horizon-client/src/scoreboard.rs rename to crates/horizon-client-core/src/scoreboard.rs index 4796be51a..3e820e4eb 100644 --- a/crates/horizon-client/src/scoreboard.rs +++ b/crates/horizon-client-core/src/scoreboard.rs @@ -2,9 +2,9 @@ //! `ObjectiveCriteria`, `NumberFormat`) and its saved data //! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`). use crate::{ - command::Registries, component, fastutil_order::{HashSetOrder, OpenOrder}, + registries::Registries, text::{self, Style, Text}, }; use horizon_world::nbt::Tag; @@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) { }) .collect::>>(), Tag::String(s) => { - crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from) + crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from) } _ => None, }; diff --git a/crates/horizon-client/src/snbt.rs b/crates/horizon-client-core/src/snbt.rs similarity index 100% rename from crates/horizon-client/src/snbt.rs rename to crates/horizon-client-core/src/snbt.rs diff --git a/crates/horizon-client-core/src/sound_source.rs b/crates/horizon-client-core/src/sound_source.rs new file mode 100644 index 000000000..d181d6ac5 --- /dev/null +++ b/crates/horizon-client-core/src/sound_source.rs @@ -0,0 +1,7 @@ +//! Sound sources (`SoundSource`), by name. + +/// SoundSource names in vanilla order. +pub const SOUND_SOURCES: [&str; 11] = [ + "master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient", + "voice", "ui", +]; diff --git a/crates/horizon-client/src/text.rs b/crates/horizon-client-core/src/text.rs similarity index 98% rename from crates/horizon-client/src/text.rs rename to crates/horizon-client-core/src/text.rs index 4c04c9da5..3a0bf301b 100644 --- a/crates/horizon-client/src/text.rs +++ b/crates/horizon-client-core/src/text.rs @@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option { /// Click actions carried by locally constructed text components. #[derive(Clone, Debug, PartialEq, Eq)] pub enum ClickEvent { + /// `ClickEvent.OpenUrl`: an http or https link. + OpenUrl(String), + /// `ClickEvent.ChangePage`: a book's page, counted from 1. + ChangePage(i32), SuggestCommand(String), CopyToClipboard(String), /// A local file to open with the system handler. @@ -67,7 +71,7 @@ pub struct Style { } /// A style's `font` field: an identifier (`alt` is `minecraft:alt`). pub fn font_id(v: &Value) -> Option { - crate::command::args::parse_identifier(v.as_str()?) + crate::identifier::parse_identifier(v.as_str()?) } impl Style { pub fn color(c: u32) -> Self { @@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> { } if let Some(click) = &style.click { let event = match click { + ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }), + ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }), ClickEvent::SuggestCommand(command) => { json!({ "action": "suggest_command", "command": command }) } diff --git a/crates/horizon-client-core/src/uuid.rs b/crates/horizon-client-core/src/uuid.rs new file mode 100644 index 000000000..4ede9b141 --- /dev/null +++ b/crates/horizon-client-core/src/uuid.rs @@ -0,0 +1,24 @@ +//! UUIDs as vanilla's four-int form, to and from their text form. + +pub fn uuid_text(u: [i32; 4]) -> String { + let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} +pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> { + let hex: String = s.chars().filter(|c| *c != '-').collect(); + if hex.len() != 32 { + return None; + } + let mut out = [0i32; 4]; + for (i, v) in out.iter_mut().enumerate() { + *v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32; + } + Some(out) +} diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index f1011c534..150433918 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -9,6 +9,8 @@ description = "Horizon native development client" [dependencies] horizon-assets = { path = "../horizon-assets" } +horizon-client-core = { path = "../horizon-client-core" } +horizon-command = { path = "../horizon-command" } horizon-core = { path = "../horizon-core" } # The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority). horizon-threads = { path = "../horizon-threads" } @@ -60,6 +62,7 @@ gilrs = { version = "0.11", optional = true } horizon-dualsense = { path = "../horizon-dualsense", optional = true } # HTTPS to the Minecraft services (online-mode session join). ureq = { version = "2", default-features = false, features = ["tls"] } +unicode-bidi = "0.3.18" [target.'cfg(unix)'.dependencies] # The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs). diff --git a/crates/horizon-client/examples/probe/arrow_probe.rs b/crates/horizon-client/examples/probe/arrow_probe.rs index f72e6276e..411561c4e 100644 --- a/crates/horizon-client/examples/probe/arrow_probe.rs +++ b/crates/horizon-client/examples/probe/arrow_probe.rs @@ -163,6 +163,8 @@ pub fn main() -> Result<()> { ArrowSound::HitGround => "hit", ArrowSound::Splash => "splash", ArrowSound::Extinguish => "extinguish", + ArrowSound::TridentHitGround => "trident_hit_ground", + ArrowSound::TridentHit => "trident_hit", }, volume.to_bits(), pitch.to_bits() diff --git a/crates/horizon-client/examples/probe/entity_anim_fixture.rs b/crates/horizon-client/examples/probe/entity_anim_fixture.rs index 4aceb9f6f..4582033fe 100644 --- a/crates/horizon-client/examples/probe/entity_anim_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_anim_fixture.rs @@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> { pose, num("maxCrossbowChageDuration"), num("ticksUsingItem"), + false, ); } Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => { diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs index 86ef275d9..3f47ba787 100644 --- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs @@ -23,6 +23,7 @@ fn stack(spec: &str) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; for e in parts { let (name, level) = e.rsplit_once(':').unwrap_or((e, "1")); diff --git a/crates/horizon-client/examples/probe/finalize_spawn_fixture.rs b/crates/horizon-client/examples/probe/finalize_spawn_fixture.rs index b1b25e723..067d545a8 100644 --- a/crates/horizon-client/examples/probe/finalize_spawn_fixture.rs +++ b/crates/horizon-client/examples/probe/finalize_spawn_fixture.rs @@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [ fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec) { use Species as S; let s = m.species(); - let named = |reg: &finalize::VariantRegistry, i: u8| { - format!( - "minecraft:{}", + let named = |reg: &finalize::VariantRegistry, i: Option| { + let name = i.map_or("classic", |i| { reg.names.get(i as usize).map_or("?", |n| n.as_str()) - ) + }); + format!("minecraft:{name}") }; let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant)); match s { diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs index ecdf02901..07f2bf22a 100644 --- a/crates/horizon-client/examples/probe/mob_ai_probe.rs +++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs @@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool { match e { MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0, // ServerExplosion.hurtEntities: within twice the radius every hit does damage. - MobEvent::Explode(at, radius) => { + MobEvent::Explode(at, radius, _) => { let d = Vec3::new(at[0], at[1], at[2]) .distance_squared(p.position) .sqrt(); @@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> { off_hand: None, head: None, armor_cover: 0.0, + invisible: false, spectator: false, gold_armor: false, hurt_by: None, @@ -1057,9 +1058,11 @@ pub fn main() -> horizon_client::Result<()> { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: PLAYER_UUID, allies: Default::default(), remote: false, + passenger: false, } }); let mut scratch = LegacyRandom::new(0); @@ -1069,6 +1072,8 @@ pub fn main() -> horizon_client::Result<()> { for (i, m) in mobs.iter().enumerate() { print(&mut out, 0, i, m, s.creative, world.game_time); } + // No player has a chest open in the scenarios. + let lid_openers = std::collections::HashMap::new(); // The MobWorld every step sees, over a snapshot of the mobs. macro_rules! mob_world { ($snapshot:expr) => { @@ -1080,6 +1085,7 @@ pub fn main() -> horizon_client::Result<()> { world: &world, registry, facts: &facts, + fluid_tables: &motion.fluids, movement: &motion.blocks, collision: &collision, data: &data, @@ -1097,12 +1103,14 @@ pub fn main() -> horizon_client::Result<()> { block_tags: &items.block_tags, biomes: &biomes, mob_griefing: true, + mob_drops: true, difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0), end: false, world_seed: 0, // FlatLevelSource.getSeaLevel. sea_level: -63, items: Some(&items), + lid_openers: &lid_openers, } }; } diff --git a/crates/horizon-client/examples/probe/recipe_book_fixture.rs b/crates/horizon-client/examples/probe/recipe_book_fixture.rs index ce2afe624..2dda3b109 100644 --- a/crates/horizon-client/examples/probe/recipe_book_fixture.rs +++ b/crates/horizon-client/examples/probe/recipe_book_fixture.rs @@ -5,6 +5,10 @@ //! //! usage: recipe_book_fixture RESOURCES CASES OUTPUT use horizon_client::recipe_book::{BookType, RecipeIndex, Settings}; +use horizon_protocol::{ + buf::{Codec, Reader, Writer}, + packets::recipes::RecipeBookChangeSettings, +}; use horizon_world::nbt::Tag; use serde_json::Value; use std::{collections::BTreeSet, fs, path::Path}; @@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> { _ => BookType::Smoker, } .index(); - book.open[i] = op["open"].as_bool().unwrap_or(false); - book.filtering[i] = op["filtering"].as_bool().unwrap_or(false); + let packet = RecipeBookChangeSettings { + book_type: i as i32, + open: op["open"].as_bool().unwrap_or(false), + filtering: op["filtering"].as_bool().unwrap_or(false), + }; + let mut wire = Writer::new(); + RecipeBookChangeSettings::write(&packet, &mut wire); + extra = format!( + " packet={}", + wire.data + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ); + let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?; + book.open[decoded.book_type as usize] = decoded.open; + book.filtering[decoded.book_type as usize] = decoded.filtering; } "save" => { let tag = book.write(); diff --git a/crates/horizon-client/examples/probe/scene_motion_probe.rs b/crates/horizon-client/examples/probe/scene_motion_probe.rs index 21977eeab..0a7e4b594 100644 --- a/crates/horizon-client/examples/probe/scene_motion_probe.rs +++ b/crates/horizon-client/examples/probe/scene_motion_probe.rs @@ -164,6 +164,8 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> { .iter() .filter_map(|e| match e { MotionEvent::Fall(e) => Some(e.trace()), + // The oracle's player wears no armor; the game applies this one. + MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { .. }) => None, MotionEvent::Health(e) => Some(e.trace()), MotionEvent::Snowflake { position, velocity } => Some(format!( "snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}", @@ -351,6 +353,7 @@ pub fn main() -> Result<()> { horizon_world::food::Difficulty::Hard, ][op["difficulty"].as_u64().unwrap() as usize], natural_regeneration: op["regenerate"].as_bool().unwrap(), + ..Default::default() }; } "food" => { diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index 6706532b7..1ed11da4d 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -437,6 +437,8 @@ pub struct EntityView { /// mainhand, offhand, head, chest, legs, feet, body. pub equipment: [Option; 7], pub vehicle: Option>, + /// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`). + pub looking_at: Option>, pub effects: Vec<(String, i32, i32)>, /// Player: game mode name. pub gamemode: Option, @@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool { _ => false, } } -fn range_i(v: &Value, x: i64) -> bool { +pub fn range_i(v: &Value, x: i64) -> bool { range_f(v, x as f64) } /// A registry id, `#tag`, or list matched against one id. @@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool { _ => false, } } +/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate` +/// over the potion's effects (`contains`, `count`, `size`). +pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool { + if v.is_null() { + return true; + } + if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w)) + { + return false; + } + let c = &v["effects"]; + if c.is_null() { + return true; + } + let effects: Vec<(String, i32, i32)> = potion + .and_then(crate::brewing::potion) + .map(|d| { + d.effects + .iter() + .map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur)) + .collect() + }) + .unwrap_or_default(); + // `MobEffectsPredicate.test`: the one listed effect, amplifier and duration. + let test = |pred: &Value, e: &(String, i32, i32)| { + pred.as_object().is_some_and(|m| { + m.get(&e.0).is_some_and(|want| { + range_i(&want["amplifier"], i64::from(e.1)) + && range_i(&want["duration"], i64::from(e.2)) + && want["ambient"].as_bool().is_none_or(|b| !b) + && want["visible"].as_bool().is_none_or(|b| b) + }) + }) + }; + c["contains"] + .as_array() + .is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e)))) + && c["count"].as_array().is_none_or(|l| { + l.iter().all(|entry| { + let n = effects.iter().filter(|e| test(&entry["test"], e)).count(); + range_i(&entry["count"], n as i64) + }) + }) + && range_i(&c["size"], effects.len() as i64) +} /// `ItemPredicate`. pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool { if v.is_null() { @@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool { }) }) } +/// `DistancePredicate.matches` between two points. +pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool { + let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; + range_f(&v["x"], d[0].abs()) + && range_f(&v["y"], d[1].abs()) + && range_f(&v["z"], d[2].abs()) + && range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt()) + && range_f( + &v["absolute"], + (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(), + ) +} /// `EntityPredicate` (26.x keyed sub-predicates). pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool { if v.is_null() { @@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo "minecraft:stepping_on" => { location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w) } - "minecraft:distance" | "distance" => { - let d = [ - e.position[0] - origin[0], - e.position[1] - origin[1], - e.position[2] - origin[2], - ]; - range_f(&p["x"], d[0].abs()) - && range_f(&p["y"], d[1].abs()) - && range_f(&p["z"], d[2].abs()) - && range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt()) - && range_f( - &p["absolute"], - (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(), - ) - } + "minecraft:distance" | "distance" => distance(p, e.position, origin), "minecraft:flags" | "flags" => { let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have); f("is_on_fire", e.on_fire) @@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo }) }) }), - "minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| { - e.gamemode - .as_deref() - .is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g))) - }), + "minecraft:type_specific/player" => { + p["gamemode"].as_array().is_none_or(|modes| { + e.gamemode + .as_deref() + .is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g))) + }) && (p["looking_at"].is_null() + || e.looking_at + .as_ref() + .is_some_and(|t| entity(&p["looking_at"], t, w, origin))) + } "minecraft:type_specific/lightning" => { range_i(&p["blocks_set_on_fire"], 0) && (p["entity_struck"].is_null() @@ -703,6 +753,52 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo } true } +/// A damage event as `DamagePredicate` sees it. +#[derive(Clone, Debug, Default)] +pub struct DamageView { + /// The damage type id (`minecraft:arrow`). + pub kind: String, + /// `DamageSource.getDirectEntity` and `getEntity`. + pub direct: Option, + pub source: Option, + /// `DamageSource.isDirect`: the direct entity is the causing one. + pub is_direct: bool, + /// The damage before and after the shield took its share. + pub original: f32, + pub actual: f32, + pub blocked: bool, +} +/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`. +pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool { + if v.is_null() { + return true; + } + let by = |key: &str, e: &Option| { + v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin)) + }; + if !range_f(&v["dealt"], f64::from(d.original)) + || !range_f(&v["taken"], f64::from(d.actual)) + || !by("source_entity", &d.source) + || v["blocked"].as_bool().is_some_and(|b| b != d.blocked) + { + return false; + } + let ty = &v["type"]; + let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| { + let tag = t["id"].as_str().unwrap_or(""); + let tag = tag.strip_prefix('#').unwrap_or(tag); + w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true) + }); + let direct_ok = ty["direct_entity"].is_null() + || d.direct + .as_ref() + .is_some_and(|e| entity(&ty["direct_entity"], e, w, origin)); + let source_ok = ty["source_entity"].is_null() + || d.source + .as_ref() + .is_some_and(|e| entity(&ty["source_entity"], e, w, origin)); + tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct) +} /// The entities a loot-style condition list may refer to. pub struct Entities<'a> { pub this: Option<&'a EntityView>, diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index 28b28544b..c2123e693 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -35,8 +35,10 @@ enum Animate { Campfire { spawn_particles: bool, }, - /// Leaves: optional falling-leaf particle and chance, tinted by foliage. - Leaves(Option<(T, f32)>), + /// Leaves: optional falling-leaf particle and chance; a tinted leaf takes + /// the biome foliage colour unless a fixed colour is given (azalea); and + /// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`). + Leaves(Option<(T, f32)>, Option, bool), NetherPortal, EnchantingTable, EndRod([i32; 3]), @@ -47,8 +49,11 @@ enum Animate { CandleCake, BrewingStand, EnderChest, - /// FallingBlock with its dust colour. - Falling(u32), + /// FallingBlock with its dust colour; sand also has the ambient desert sound + /// (`SandBlock.animateTick`). + Falling(u32, bool), + /// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`). + Beehive, RedstoneOre, RedstoneWire { power: u8, @@ -70,6 +75,8 @@ enum Animate { waterlogged: bool, }, EndPortal, + /// `WetSpongeBlock.animateTick`. + WetSponge, } fn step(facing: &str) -> [i32; 3] { match facing { @@ -104,10 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { spawn_particles: false, }, "oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves" - | "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))), - "cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))), - "pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))), - "spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None), + | "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false), + "cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false), + "pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false), + "red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true), + "orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true), + "yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true), + // The azalea blocks drop tinted leaves of the fixed colour -9399763. + "azalea_leaves" | "flowering_azalea_leaves" => { + Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false) + } + "spruce_leaves" => Animate::Leaves(None, None, false), "nether_portal" => Animate::NetherPortal, "enchanting_table" => Animate::EnchantingTable, "end_rod" => Animate::EndRod(facing()), @@ -116,9 +130,18 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { "mycelium" => Animate::Mycelium, "brewing_stand" => Animate::BrewingStand, "ender_chest" => Animate::EnderChest, - "sand" => Animate::Falling(0xDBD3A0), - "red_sand" => Animate::Falling(0xA95821), - "gravel" => Animate::Falling(0x807C7B), + "sand" => Animate::Falling(0xDBD3A0, true), + "red_sand" => Animate::Falling(0xA95821, true), + "gravel" => Animate::Falling(0x807C7B, false), + // AnvilBlock/ConcretePowderBlock.getDustColor is the map colour; + // DragonEggBlock's is black. + "anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false), + "dragon_egg" => Animate::Falling(0x000000, false), + _ if name.ends_with("_concrete_powder") => Animate::Falling( + concrete_powder_color(name.trim_end_matches("_concrete_powder")), + false, + ), + "beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive, "redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre, "redstone_wire" => { let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0); @@ -149,6 +172,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { waterlogged: prop("waterlogged").as_deref() == Some("true"), }, "end_portal" => Animate::EndPortal, + "wet_sponge" => Animate::WetSponge, + // BrushableBlock has no dust colour of its own, so the dust is the map colour. + "suspicious_sand" => Animate::Falling(0xF7E9A3, false), + "suspicious_gravel" => Animate::Falling(0x707070, false), "sculk_sensor" | "calibrated_sculk_sensor" if prop("sculk_sensor_phase").as_deref() == Some("active") => { @@ -161,6 +188,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { _ => Animate::None, } } +/// `DyeColor.getMapColor` of a concrete powder colour. +fn concrete_powder_color(dye: &str) -> u32 { + match dye { + "white" => 0xFFFFFF, + "orange" => 0xD87F33, + "magenta" => 0xB24CD8, + "light_blue" => 0x6699D8, + "yellow" => 0xE5E533, + "lime" => 0x7FCC19, + "pink" => 0xF27FA5, + "gray" => 0x4C4C4C, + "light_gray" => 0x999999, + "cyan" => 0x4C7F99, + "purple" => 0x7F3FB2, + "blue" => 0x334CB2, + "brown" => 0x664C33, + "green" => 0x667F33, + "red" => 0x993333, + _ => 0x191919, + } +} /// `RedstoneWireBlock.COLORS`. fn redstone_color(power: u8) -> [f32; 3] { let f = f32::from(power) / 15.0; @@ -194,6 +242,10 @@ pub struct AnimateTables { power_transmitter: Vec, /// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil). dried_ghast_sounds: Vec, + /// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand). + desert_sand_sounds: Vec, + /// `#required_for_poplar_leaf_ambience` (overworld natural logs). + poplar_leaf_logs: Vec, /// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged). burnable: Vec, /// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`). @@ -269,6 +321,8 @@ impl AnimateTables { power_provider: by_state("minecraft:enchantment_power_provider"), power_transmitter: by_state("minecraft:enchantment_power_transmitter"), dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"), + desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"), + poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"), burnable, ambient, }) @@ -357,6 +411,10 @@ impl AnimateContext<'_> { fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) { engine.add(&ParticleOptions::simple(ty), p, v); } +/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then. +fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) { + engine.add_with(&ParticleOptions::simple(ty), p, v, false, true); +} fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) { engine.sounds.push((name, p, volume, pitch)); } @@ -504,6 +562,53 @@ fn try_spawn_drip( } } } +/// `BeehiveBlock.trySpawnDripParticles`. +fn beehive_drip( + tables: &AnimateTables, + engine: &mut ParticleEngine, + ctx: &AnimateContext, + state: BlockStateId, + pos: [i32; 3], +) { + if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 { + return; + } + let Some((bottom, top, bounds)) = ctx.shape_y(pos) else { + return; + }; + if top < 1.0 + || tables + .impermeable + .get(state.0 as usize) + .copied() + .unwrap_or(false) + { + return; + } + let y = if bottom > 0.0 { + f64::from(pos[1]) + bottom - 0.05 + } else { + let below = [pos[0], pos[1] - 1, pos[2]]; + let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1); + // `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below. + if (below_top < 1.0 || !ctx.sturdy(below)) + && ctx.facts.get(ctx.state(below)).fluid == Fluid::None + { + f64::from(pos[1]) - 0.05 + } else { + return; + } + }; + let r = engine.random(); + let (a, b) = (r.next_double(), r.next_double()); + let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2])); + let p = [ + fx + bounds[0] + (bounds[1] - bounds[0]) * a, + y, + fz + bounds[2] + (bounds[3] - bounds[2]) * b, + ]; + add(engine, T::DrippingHoney, p, [0.0; 3]); +} /// `WaterFluid.animateTick` / `LavaFluid.animateTick`. fn fluid_animate_tick( engine: &mut ParticleEngine, @@ -685,7 +790,7 @@ fn block_animate_tick( } } } - Animate::Leaves(falling) => { + Animate::Leaves(falling, fixed_tint, poplar) => { let below = [pos[0], pos[1] - 1, pos[2]]; // LeavesBlock.makeDrippingWaterParticles. if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1 @@ -697,6 +802,9 @@ fn block_animate_tick( add(engine, T::DrippingWater, p, [0.0; 3]); } } + if poplar { + leaves_ambient_sound(tables, engine, ctx, state, pos); + } // FallingParticlesLeavesBlock.makeFallingLeavesParticles. if let Some((ty, chance)) = falling && engine.random().next_float() < chance @@ -705,7 +813,7 @@ fn block_animate_tick( let r = engine.random(); let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()]; let options = if ty == T::TintedLeaves { - let c = ctx.foliage(pos); + let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos)); let f = |s: u32| ((c >> s) & 255) as f32 / 255.0; ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0]) } else { @@ -869,7 +977,7 @@ fn block_animate_tick( fy + 1.0 + f64::from(r.next_float()), fz + f64::from(r.next_float()), ]; - add(engine, T::SulfurBubbles, p, [0.0; 3]); + add_visible(engine, T::SulfurBubbles, p, [0.0; 3]); } if next_int(engine.random(), 10) == 0 { let name = "minecraft:block.potent_sulfur.noxious_gas"; @@ -877,6 +985,36 @@ fn block_animate_tick( } } } + Animate::WetSponge => { + // Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST. + const DIRECTIONS: [[i32; 3]; 6] = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], + [-1, 0, 0], + [1, 0, 0], + ]; + let d = DIRECTIONS[next_int(engine.random(), 6) as usize]; + // The sponge occludes, so only a sturdy neighbour face hides the drip. + if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) { + let r = engine.random(); + let p = match d { + [0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()], + [dx, _, 0] => { + let y = fy + r.next_double() * 0.8; + let z = fz + r.next_double(); + [fx + if dx > 0 { 1.1 } else { 0.05 }, y, z] + } + [_, _, dz] => { + let y = fy + r.next_double() * 0.8; + let x = fx + r.next_double(); + [x, y, fz + if dz > 0 { 1.1 } else { 0.05 }] + } + }; + add(engine, T::DrippingWater, p, [0.0; 3]); + } + } Animate::Mycelium => { let r = engine.random(); if next_int(r, 10) == 0 { @@ -959,7 +1097,7 @@ fn block_animate_tick( add(engine, T::Portal, p, v); } } - Animate::Falling(color) => { + Animate::Falling(color, sand) => { if next_int(engine.random(), 16) == 0 { let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]])); // FallingBlock.isFree: air, fire, liquids or replaceable. @@ -976,6 +1114,21 @@ fn block_animate_tick( ); } } + // AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block. + if sand + && ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air + && next_int(engine.random(), 2100) == 0 + && sand_sound_nearby(tables, ctx, pos) + { + sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0); + } + } + Animate::Beehive => { + // BeehiveBlock.animateTick: nextInt(1) + 1 attempts. + let attempts = next_int(engine.random(), 1) + 1; + for _ in 0..attempts { + beehive_drip(tables, engine, ctx, state, pos); + } } Animate::RedstoneOre => { for d in [ @@ -1086,7 +1239,7 @@ fn block_animate_tick( } Animate::BubbleColumn { drag } => { if drag { - add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]); + add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]); let r = engine.random(); if next_int(r, 200) == 0 { let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15); @@ -1099,7 +1252,7 @@ fn block_animate_tick( ); } } else { - add( + add_visible( engine, T::BubbleColumnUp, [fx + 0.5, fy, fz + 0.5], @@ -1111,7 +1264,7 @@ fn block_animate_tick( fy + f64::from(r.next_float()), fz + f64::from(r.next_float()), ]; - add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]); + add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]); let r = engine.random(); if next_int(r, 200) == 0 { let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15); @@ -1189,6 +1342,91 @@ fn block_animate_tick( } } } +/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves: +/// one log and three leaves of the same kind around the block. +fn leaves_ambient_sound( + tables: &AnimateTables, + engine: &mut ParticleEngine, + ctx: &AnimateContext, + state: BlockStateId, + pos: [i32; 3], +) { + if next_int(engine.random(), 300) != 0 { + return; + } + let Some(name) = ctx.registry.state(state).map(|s| &s.name) else { + return; + }; + let (mut logs, mut leaves) = (0, 0); + for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] { + let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]); + if tables + .poplar_leaf_logs + .get(neighbor.0 as usize) + .copied() + .unwrap_or(false) + { + logs += 1; + } + if ctx + .registry + .state(neighbor) + .is_some_and(|s| &s.name == name) + { + leaves += 1; + } + if logs == 1 && leaves == 3 { + let p = pos.map(f64::from); + sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0); + return; + } + } +} +/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the +/// surface column eight blocks away on all four sides. +fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool { + let is_sand = |p: [i32; 3]| { + tables + .desert_sand_sounds + .get(ctx.state(p).0 as usize) + .copied() + .unwrap_or(false) + }; + let (mut found, mut checked) = (0, 0); + for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] { + let (x, z) = (pos[0] + d[0], pos[2] + d[1]); + // columnContainsTriggeringBlock. + let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1; + let matches = if (surface - pos[1]).abs() > 5 { + let mut y = pos[1] + 6; + let mut above_air = ctx.is_air([x, y, z]); + y -= 1; + let mut hit = false; + for _ in 0..10 { + if above_air && is_sand([x, y, z]) { + hit = true; + break; + } + above_air = ctx.is_air([x, y, z]); + y -= 1; + } + hit + } else { + ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z]) + }; + if matches { + found += 1; + if found > 3 { + return true; + } + } + checked += 1; + if 4 - checked + found < 3 { + return false; + } + } + false +} /// `AbstractCandleBlock.addParticlesAndSound`. fn candle(engine: &mut ParticleEngine, p: [f64; 3]) { let r = engine.random(); @@ -1209,21 +1447,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) { } add(engine, T::SmallFlame, p, [0.0; 3]); } -/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items). +/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says +/// which of a campfire's four slots hold an item. pub fn campfire_particles<'a>( tables: &AnimateTables, engine: &mut ParticleEngine, ctx: &AnimateContext, blocks: impl Iterator, + cooking: &dyn Fn([i32; 3]) -> [bool; 4], ) { for &(pos, state) in blocks { if !tables.is_lit_campfire(state) { continue; } - let signal = ctx + let properties = ctx .registry .state(BlockStateId(state)) - .is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true")); + .map(|s| &s.properties); + let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str); + let signal = prop("signal_fire") == Some("true"); + // Direction.get2DDataValue: south, west, north, east. + let rotation = match prop("facing") { + Some("west") => 1, + Some("north") => 2, + Some("east") => 3, + _ => 0, + }; let r = engine.random(); if r.next_float() < 0.11 { let count = next_int(r, 2) + 2; @@ -1231,6 +1480,28 @@ pub fn campfire_particles<'a>( make_campfire_smoke(engine, pos, signal, false); } } + // The smoke over each cooking item, at the slot's corner of the fire. + let occupied = cooking(pos); + for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) { + if engine.random().next_float() >= 0.2 { + continue; + } + // Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side. + let (step, clockwise) = match (slot + rotation) % 4 { + 0 => ([0.0, 1.0], [-1.0, 0.0]), + 1 => ([-1.0, 0.0], [0.0, -1.0]), + 2 => ([0.0, -1.0], [1.0, 0.0]), + _ => ([1.0, 0.0], [0.0, 1.0]), + }; + let at = [ + f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125, + ]; + for _ in 0..4 { + add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]); + } + } } } /// `CampfireBlock.makeParticles` (always visible, overriding the limiter); diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 30b501011..6d8dc528b 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -9,7 +9,7 @@ use crate::{ collisions::SceneCollisions, mobs::Who, motion::{FluidResources, LAVA, WATER}, - projectiles::{Hit, Projectile}, + projectiles::{Hit, Projectile, ProjectileKind}, world::World, }; use horizon_core::{ @@ -132,6 +132,10 @@ pub enum ArrowSound { HitGround, /// `entity.generic.extinguish_fire`. Extinguish, + /// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`). + TridentHitGround, + /// `ThrownTrident.onHitEntity`'s `item.trident.hit`. + TridentHit, } /// What a burning arrow does to the block it hits (`onProjectileHit`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -156,7 +160,11 @@ pub struct ArrowState { /// `pickupItemStack`: what picking the arrow up gives. pub pickup_item: crate::items::ItemStack, pub no_physics: bool, + /// `AbstractArrow.getPierceLevel`. pub pierce: u8, + /// `piercingIgnoreEntityIds`: the entities a piercing arrow has gone + /// through (cleared when it sticks in a block). + pub pierced: Vec, /// `Entity.remainingFireTicks`. pub fire_ticks: i32, pub fall_distance: f64, @@ -165,6 +173,9 @@ pub struct ArrowState { pub was_eye_in_water: bool, pub first_tick: bool, pub has_been_shot: bool, + /// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block + /// for over 4 ticks) and hits no more. + pub dealt_damage: bool, pub removed: bool, /// The arrow's own random (`Entity.random`). pub random: LegacyRandom, @@ -199,6 +210,7 @@ impl ArrowState { pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1), no_physics: false, pierce: 0, + pierced: Vec::new(), fire_ticks: 0, fall_distance: 0.0, fluids: EntityFluidInteraction::new([WATER, LAVA]), @@ -206,6 +218,7 @@ impl ArrowState { was_eye_in_water: false, first_tick: true, has_been_shot: false, + dealt_damage: false, removed: false, random, events: Vec::new(), @@ -362,6 +375,29 @@ fn bounds(at: Vec3) -> Aabb { Vec3::new(at.x + w, at.y + f64::from(HEIGHT), at.z + w), ) } +/// The bounding box of an arrow, or a thrown trident, at `at`. +pub fn arrow_bounds(at: Vec3) -> Aabb { + bounds(at) +} +/// The blocks an arrow that moved from `from` to `to` was inside +/// (`Entity.checkInsideBlocks`: the sweep of its box, 16 steps at most), for +/// the blocks that react to it (`entityInside`). +pub fn blocks_inside(from: Vec3, to: Vec3) -> Vec { + let deflated = bounds(to).deflate(Vec3::new( + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + )); + let mut inside = Vec::new(); + let _ = sweep_intersected_blocks(from, to, deflated, 1_000_000, |p, iteration| { + if iteration >= 16 { + return false; + } + inside.push(p); + true + }); + inside +} /// `Projectile.lerpRotation`. fn lerp_rotation(mut old: f32, new: f32) -> f32 { while new - old < -180.0 { @@ -423,18 +459,29 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity let Some(mut st) = p.arrow.take() else { return false; }; + let trident = matches!(p.kind, ProjectileKind::Trident); let mut ticker = Ticker { p, st: &mut st, scene, }; match outcome { + EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(), EntityHit::Hurt => { ticker.hit_sound(); - ticker.st.removed = true; + // A piercing arrow flies on (`getPierceLevel() <= 0` discards). + if ticker.st.pierce == 0 { + ticker.st.removed = true; + } } EntityHit::Refused => ticker.turn_back(), - EntityHit::Deflected => {} + EntityHit::Deflected => { + // The deflection comes before `onHitEntity` adds the entity to + // `piercingIgnoreEntityIds`. + if ticker.st.pierce > 0 { + ticker.st.pierced.pop(); + } + } } ticker.finish(); p.stuck = st.in_ground; @@ -464,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> { p.z, )) } + /// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water + /// inertia, sounds and hit). + fn trident(&self) -> bool { + matches!(self.p.kind, ProjectileKind::Trident) + } fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit { + // ThrownTrident.tick. + if self.trident() && self.st.in_ground_time > 4 { + self.st.dealt_damage = true; + } let physics = !self.st.no_physics; let movement = self.p.velocity; let block_pos = self.block_position(); @@ -512,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> { self.st.in_ground_time = 0; let original = self.position(); if self.st.was_touching_water { - // getWaterInertia, then the bubbles behind it. - self.p.velocity = self.p.velocity.scale(f64::from(0.6f32)); + // getWaterInertia (0.99 for a trident), then the bubbles behind it. + let inertia = if self.trident() { 0.99f32 } else { 0.6f32 }; + self.p.velocity = self.p.velocity.scale(f64::from(inertia)); let v = self.p.velocity; for _ in 0..4 { self.st.events.push(ArrowEvent::Particle { @@ -639,7 +696,8 @@ impl Ticker<'_, '_, '_, '_> { self.st.life = 0; } /// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the - /// block (piercing is not modelled). + /// block. A piercing arrow that hits an entity here goes on from it next + /// tick (`finish_entity_hit`); vanilla's loop goes on within the same tick. fn step_move_and_hit(&mut self, from: Vec3, to: Vec3, targets: &[(Who, Aabb)]) -> Hit { let block = self.scene.clip(from, to); let end = block.map_or(to, |b| b.0); @@ -649,10 +707,16 @@ impl Ticker<'_, '_, '_, '_> { .expand_towards(self.p.velocity) .inflate(Vec3::new(1.0, 1.0, 1.0)); let mut best: Option<(Who, Vec3, f64)> = None; + // ThrownTrident.findHitEntity: none once it has dealt damage. + let targets = if self.st.dealt_damage { &[] } else { targets }; for (who, b) in targets { if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) { continue; } + // canHitEntity: not an entity a piercing arrow has gone through. + if self.st.pierced.contains(who) { + continue; + } if let Some(at) = self.entity_hit(b, from, end, margin) { // Sorted by the distance to the entity's position (the // stable sort keeps the first of equals). @@ -667,6 +731,15 @@ impl Ticker<'_, '_, '_, '_> { self.p.position = next; self.apply_effects_from_blocks(from, next); if let Some((who, at, _)) = best { + // AbstractArrow.onHitEntity: past pierce level + 1 entities the + // arrow is discarded before it hurts the next one. + if self.st.pierce > 0 { + if self.st.pierced.len() > usize::from(self.st.pierce) { + self.st.removed = true; + return Hit::None; + } + self.st.pierced.push(who); + } return Hit::Entity { who, at }; } let Some((at, pos, face)) = block else { @@ -706,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> { /// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`. fn hit_sound(&mut self) { let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9); + let sound = if self.trident() { + ArrowSound::TridentHitGround + } else { + ArrowSound::HitGround + }; self.st.events.push(ArrowEvent::Sound { - sound: ArrowSound::HitGround, + sound, volume: 1.0, pitch, }); } + /// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not: + /// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The + /// trident keeps flying (it has dealt its damage) and falls. + fn trident_bounce(&mut self) { + self.st.dealt_damage = true; + self.st.events.push(ArrowEvent::Sound { + sound: ArrowSound::TridentHit, + volume: 1.0, + pitch: 1.0, + }); + // ProjectileDeflection.REVERSE. + let rotation = 170.0 + self.st.random.next_float() * 20.0; + let v = self.p.velocity; + self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5)); + self.p.yaw += rotation; + } /// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`, /// a half turn give or take ten degrees at a tenth of the speed; one /// left almost still drops its pickup (ALLOWED) and is discarded. @@ -752,6 +846,7 @@ impl Ticker<'_, '_, '_, '_> { self.st.shake = 7; self.p.set_critical(false); self.st.pierce = 0; + self.st.pierced.clear(); } /// `Projectile.mayInteract`: a mob's arrows need `mob_griefing`. fn may_interact(&self) -> bool { diff --git a/crates/horizon-client/src/atlas_sources.rs b/crates/horizon-client/src/atlas_sources.rs index 1eea24666..fbed22fd2 100644 --- a/crates/horizon-client/src/atlas_sources.rs +++ b/crates/horizon-client/src/atlas_sources.rs @@ -3,8 +3,8 @@ //! `filter`, `unstitch` and `paletted_permutations` sources run in order //! against the resource tree; a later source replaces an earlier sprite of //! the same id and a filter removes the matching ones. -use crate::packs::pattern::Pattern; use horizon_assets::palette::apply_palette; +use horizon_client_core::pattern::Pattern; use serde_json::Value; use std::{ collections::BTreeMap, diff --git a/crates/horizon-client/src/audio.rs b/crates/horizon-client/src/audio.rs index be6628ece..e45362e19 100644 --- a/crates/horizon-client/src/audio.rs +++ b/crates/horizon-client/src/audio.rs @@ -719,11 +719,6 @@ impl Audio { self.play(event, at, volume, pitch); self.capture = capture; } - /// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the - /// client (the entity's own random there, never the server's world random). - pub fn hurt_pitch(&mut self) -> f32 { - (self.random.next_float() - self.random.next_float()) * 0.2 + 1.0 - } /// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound. pub fn play_button_click(&mut self) { self.play_instance( diff --git a/crates/horizon-client/src/auth.rs b/crates/horizon-client/src/auth.rs index b899ea5d5..a86992735 100644 --- a/crates/horizon-client/src/auth.rs +++ b/crates/horizon-client/src/auth.rs @@ -179,7 +179,7 @@ fn agent() -> ureq::Agent { } /// The discovery document, fetched once per game (a failure is retried next time). -fn discovery() -> Result, JoinError> { +pub(crate) fn discovery() -> Result, JoinError> { static CACHE: Mutex>> = Mutex::new(None); if let Some(d) = CACHE.lock().unwrap().clone() { return Ok(d); diff --git a/crates/horizon-client/src/benchmark.rs b/crates/horizon-client/src/benchmark.rs index 12e38e78a..bd2e97be0 100644 --- a/crates/horizon-client/src/benchmark.rs +++ b/crates/horizon-client/src/benchmark.rs @@ -450,6 +450,54 @@ mod tests { assert_eq!(view_chunks(32), 2573); assert_eq!(view_chunks(0), 0); } + /// The status a fake control answers with: the player is where it should + /// be, nothing is queued, and the world holds `chunks` resident chunks. + fn status(chunks: u64) -> String { + serde_json::json!({ + "player": {"position": [0.0, 64.0, 0.0]}, + "pending_chunks": 0, + "screen": "playing", + "render_distance": 12, + "chunks": chunks, + "generated_chunks": chunks, + "meshed_sections": 0, + "streaming": {}, + }) + .to_string() + } + + #[test] + fn the_load_waits_for_the_chunks_a_teleport_starts() { + // Right after a teleport the game reports steady counts and nothing + // queued for over a second, before the integrated server has sent the + // first chunk: the quiet rule alone would call that loaded, and the + // scene would be measured over an empty view (#1749). + const LATE: usize = 30; + let (tx, rx) = mpsc::channel::(); + let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counted = std::sync::Arc::clone(&polls); + let server = thread::spawn(move || { + let mut seen = 0; + while let Ok(request) = rx.recv() { + if request.line == "status" { + let chunks = if seen < LATE { 0 } else { 400 }; + seen += 1; + counted.store(seen, std::sync::atomic::Ordering::SeqCst); + let _ = request.reply.send(status(chunks)); + } else { + let _ = request.reply.send("{}".to_owned()); + } + } + }); + let control = Control(tx); + wait_loaded(&control, |_| true).expect("the scene loads"); + assert!( + polls.load(std::sync::atomic::Ordering::SeqCst) > LATE, + "the load finished before the chunks arrived" + ); + drop(control); + let _ = server.join(); + } #[test] fn yaw_matches_vanilla() { diff --git a/crates/horizon-client/src/bidi.rs b/crates/horizon-client/src/bidi.rs new file mode 100644 index 000000000..c9c6caac7 --- /dev/null +++ b/crates/horizon-client/src/bidi.rs @@ -0,0 +1,302 @@ +//! Right-to-left text (`FormattedBidiReorder.reorder`, behind +//! `Language.getVisualOrder`): the logical text a player types becomes the +//! glyphs a font draws left to right. Arabic letters are shaped into their +//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the +//! text is reordered by the Unicode bidirectional algorithm, and characters in +//! right-to-left runs are mirrored. Left-to-right text comes back unchanged. +use crate::text::{Span, Text}; +use std::borrow::Cow; +use unicode_bidi::{ + BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction, +}; + +/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins +/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over +/// (diacritics) and `None` never joins. +#[derive(Clone, Copy, PartialEq)] +enum Join { + None, + Right, + Dual, + Mark, +} +use Join::{Dual, Mark, Right}; + +/// ICU's joining table (`araLink`): code point ranges and their class, with the +/// lam-alef ligatures at the end. Anything else is `Join::None`. +const JOINS: &[(u32, u32, Join)] = &[ + (0x0622, 0x0625, Right), + (0x0626, 0x0626, Dual), + (0x0627, 0x0627, Right), + (0x0628, 0x0628, Dual), + (0x0629, 0x0629, Right), + (0x062A, 0x062E, Dual), + (0x062F, 0x0632, Right), + (0x0633, 0x063A, Dual), + (0x0640, 0x0647, Dual), + (0x0648, 0x0649, Right), + (0x064A, 0x064A, Dual), + (0x064B, 0x0656, Mark), + (0x0670, 0x0670, Mark), + (0x0671, 0x0673, Right), + (0x0675, 0x0677, Right), + (0x0678, 0x0687, Dual), + (0x0688, 0x0699, Right), + (0x069A, 0x06B9, Dual), + (0x06BA, 0x06BA, Right), + (0x06BB, 0x06BF, Dual), + (0x06C0, 0x06C0, Right), + (0x06C1, 0x06C1, Dual), + (0x06C2, 0x06CB, Right), + (0x06CC, 0x06CC, Dual), + (0x06CD, 0x06CD, Right), + (0x06CE, 0x06D1, Dual), + (0x06D2, 0x06D3, Right), + (0x200D, 0x200D, Dual), + (0x206D, 0x206F, Mark), + (0xFEF5, 0xFEFC, Right), +]; + +/// Letters that have joined forms and the code point of the isolated one; the +/// final, initial and medial forms follow it (a right-joining letter has the +/// isolated and final only). The lam-alef ligatures are their own letters. +/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to +/// U+06D3) unchanged, so they are in `JOINS` but not here. +#[rustfmt::skip] +const FORMS: &[(u32, u32)] = &[ + (0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89), + (0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99), + (0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB), + (0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9), + (0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD), + (0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1), + (0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1), + (0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB), +]; + +/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two +/// neighbouring characters that mirror each other. +const MIRRORED: &str = "\ + ()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\ + ≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\ + ⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\ + ⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\ + ⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\ + ⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\ + ⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\ + ⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\ + 〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」"; + +fn join(c: char) -> Join { + let c = c as u32; + JOINS + .iter() + .find(|&&(from, to, _)| (from..=to).contains(&c)) + .map_or(Join::None, |&(_, _, join)| join) +} + +/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`. +fn letter_of(c: char) -> char { + let c32 = c as u32; + if !matches!(c32, 0xFE70..=0xFEFC) { + return c; + } + if (0xFE70..0xFE80).contains(&c32) { + // The diacritics' forms pair up from fathatan on. + return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c); + } + FORMS + .iter() + .find(|&&(letter, iso)| { + let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual { + 4 + } else { + 2 + }; + (iso..iso + forms).contains(&c32) + }) + .and_then(|&(letter, _)| char::from_u32(letter)) + .unwrap_or(c) +} + +/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics +/// may sit between) becomes one ligature, then every letter takes the isolated, +/// final, initial or medial form its neighbours call for. Diacritics take the +/// tatweel form between two joined letters. The text can only get shorter. +fn shape(text: &[char]) -> Vec { + let letters: Vec = text.iter().map(|&c| letter_of(c)).collect(); + let mut chars = Vec::with_capacity(letters.len()); + let mut i = 0; + while i < letters.len() { + if letters[i] == '\u{644}' { + let mut at = i + 1; + while letters.get(at).is_some_and(|&c| join(c) == Mark) { + at += 1; + } + let alef = letters.get(at).and_then(|&c| { + ['\u{622}', '\u{623}', '\u{625}', '\u{627}'] + .iter() + .position(|&a| a == c) + }); + if let Some(alef) = alef { + chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}')); + chars.extend_from_slice(&letters[i + 1..at]); + i = at + 1; + continue; + } + } + chars.push(letters[i]); + i += 1; + } + // Whether the nearest letter before joins the next one, and the joins of + // the nearest letter after. + let mut joins_next = false; + let mut out = Vec::with_capacity(chars.len()); + for (i, &c) in chars.iter().enumerate() { + let next_joins = chars[i + 1..] + .iter() + .map(|&n| join(n)) + .find(|&j| j != Mark) + .is_some_and(|j| j != Join::None); + let kind = join(c); + let shaped = match kind { + Join::None => { + joins_next = false; + c + } + Mark => match c as u32 { + m @ 0x064B..=0x0652 => { + let iso = 0xFE70 + 2 * (m - 0x064B); + let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D); + char::from_u32(iso + u32::from(tatweel)).unwrap_or(c) + } + _ => c, + }, + Right | Dual => { + let to_next = kind == Dual && next_joins; + let form = match (joins_next, to_next) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + joins_next = kind == Dual; + FORMS + .binary_search_by_key(&(c as u32), |&(letter, _)| letter) + .ok() + .and_then(|at| char::from_u32(FORMS[at].1 + form)) + .unwrap_or(c) + } + }; + out.push(shaped); + } + out +} + +/// `UCharacter.getMirror`. +fn mirror(c: char) -> char { + if bidi_class(c) != BidiClass::ON { + return c; + } + let mut pairs = MIRRORED.chars(); + while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) { + if c == a { + return b; + } + if c == b { + return a; + } + } + c +} + +/// Whether a character can change anything: right-to-left letters and numbers, +/// the explicit direction controls and the diacritics that shaping turns into +/// letters. Without any, left-to-right text keeps its order. +fn directional(c: char) -> bool { + c as u32 >= 0x0590 + && (matches!(c as u32, 0x064B..=0x0652) + || matches!( + bidi_class(c), + BidiClass::R + | BidiClass::AL + | BidiClass::AN + | BidiClass::LRE + | BidiClass::RLE + | BidiClass::LRO + | BidiClass::RLO + | BidiClass::PDF + | BidiClass::LRI + | BidiClass::RLI + | BidiClass::FSI + | BidiClass::PDI + )) +} + +/// Whether `chars` keep their order and shapes: left-to-right text without +/// anything directional, in a language that reads left to right by default. +fn unchanged(mut chars: impl Iterator, default_rtl: bool) -> bool { + !default_rtl && !chars.any(directional) +} + +/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each +/// character's position in the shaped text. A character's style is the one at +/// its position in the shaped text, as `SubStringSource` takes it (the text +/// shrinks at a lam-alef ligature). +fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> { + let shaped = shape(chars); + let plain: String = shaped.iter().collect(); + // DEFAULT_RTL only changes text without a strong character. + let level = + (default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl); + let info = ParagraphBidiInfo::new(&plain, level); + let levels = info.reordered_levels_per_char(0..plain.len()); + ParagraphBidiInfo::reorder_visual(&levels) + .into_iter() + .map(|i| { + let c = if levels[i].is_rtl() { + mirror(shaped[i]) + } else { + shaped[i] + }; + (c, i) + }) + .collect() +} + +/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for +/// the current language's direction. +pub fn visual(text: &Text) -> Cow<'_, Text> { + let default_rtl = crate::lang::bidirectional(); + if unchanged(text.chars().map(|(c, _)| c), default_rtl) { + return Cow::Borrowed(text); + } + let chars: Vec = text.chars().map(|(c, _)| c).collect(); + let styles: Vec<_> = text.chars().map(|(_, style)| style).collect(); + let mut out = Text::default(); + for (c, at) in reorder(&chars, default_rtl) { + match out.spans.last_mut() { + Some(span) if span.style == *styles[at] => span.text.push(c), + _ => out.spans.push(Span { + text: c.to_string(), + style: styles[at].clone(), + }), + } + } + Cow::Owned(out) +} + +/// The same for a plain string. +pub fn visual_str(s: &str) -> Cow<'_, str> { + let default_rtl = crate::lang::bidirectional(); + if unchanged(s.chars(), default_rtl) { + return Cow::Borrowed(s); + } + let chars: Vec = s.chars().collect(); + Cow::Owned( + reorder(&chars, default_rtl) + .into_iter() + .map(|(c, _)| c) + .collect(), + ) +} diff --git a/crates/horizon-client/src/biome.rs b/crates/horizon-client/src/biome.rs index c8c15e44e..46c1e16ca 100644 --- a/crates/horizon-client/src/biome.rs +++ b/crates/horizon-client/src/biome.rs @@ -1,7 +1,7 @@ //! Biome colours from the pinned biome definitions and colormaps //! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`). use crate::Result; -use horizon_core::{noise::SimplexNoise, random::LegacyRandom}; +use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom}; use serde_json::Value; use std::{collections::HashMap, fs, path::Path}; @@ -33,6 +33,8 @@ pub struct BiomeInfo { pub increased_fire_burnout: bool, pub name: String, pub temperature: f32, + /// `temperature_modifier: frozen` (the frozen oceans). + pub frozen: bool, pub downfall: f32, pub precipitation: bool, pub grass: u32, @@ -45,6 +47,26 @@ pub struct BiomeInfo { pub fog: Option, pub water_fog: u32, } +impl BiomeInfo { + /// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler + /// with height above `sea_level + 17`. + pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 { + use horizon_world::worldgen::biome_temperature::{ + BiomeTemperatureSettings, TemperatureModifier, + }; + BiomeTemperatureSettings { + base_temperature: self.temperature, + modifier: if self.frozen { + TemperatureModifier::Frozen + } else { + TemperatureModifier::None + }, + has_precipitation: self.precipitation, + downfall: self.downfall, + } + .height_adjusted(pos, sea_level) + } +} pub struct Biomes { pub list: Vec, pub index: HashMap, @@ -135,6 +157,7 @@ impl Biomes { .as_bool() .unwrap_or(false), temperature, + frozen: v["temperature_modifier"].as_str() == Some("frozen"), downfall, precipitation: v["has_precipitation"].as_bool().unwrap_or(true), grass: color(&effects["grass_color"]) diff --git a/crates/horizon-client/src/block_entity_anim.rs b/crates/horizon-client/src/block_entity_anim.rs index d91748e73..fd91051fa 100644 --- a/crates/horizon-client/src/block_entity_anim.rs +++ b/crates/horizon-client/src/block_entity_anim.rs @@ -648,6 +648,61 @@ impl Conduit { } } +/// What `ConduitBlockEntity.serverTick`'s 40-tick check decided. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ConduitCheck { + /// The shape changed: `CONDUIT_ACTIVATE` (true) or `CONDUIT_DEACTIVATE`. + pub toggled: Option, + /// The frame blocks counted, while the conduit is active: `applyEffects` + /// and `updateAndAttackTarget` follow (the attack needs 42 or more). + pub frames: Option, +} + +/// `ConduitBlockEntity` (server side): what `serverTick` keeps between ticks. +#[derive(Clone, Debug, Default)] +pub struct ConduitServer { + shape: Conduit, + /// `destroyTarget`: the mob id of the enemy being struck. + pub target: Option, + next_ambient_sound: i64, +} + +impl ConduitServer { + /// The `gameTime % 40` part of `serverTick`: the shape is checked again. + pub fn check( + &mut self, + pos: [i32; 3], + game_time: i64, + is_water: impl Fn([i32; 3]) -> bool, + is_frame: impl Fn([i32; 3]) -> bool, + ) -> Option { + if game_time % 40 != 0 { + return None; + } + let active = self.shape.update_shape(pos, &is_water, &is_frame); + let toggled = (active != self.shape.is_active).then_some(active); + self.shape.is_active = active; + Some(ConduitCheck { + toggled, + frames: active.then_some(self.shape.effect_blocks.len()), + }) + } + /// The sounds of an active conduit at the end of `serverTick`: whether + /// `CONDUIT_AMBIENT` (every 80 ticks) and `CONDUIT_AMBIENT_SHORT` (every + /// 60 to 99 ticks) play. + pub fn ambient(&mut self, game_time: i64, random: &mut impl RandomSource) -> (bool, bool) { + if !self.shape.is_active { + return (false, false); + } + let short = game_time > self.next_ambient_sound; + if short { + self.next_ambient_sound = + game_time + 60 + i64::from(random.next_int_bound(40).unwrap_or(0)); + } + (game_time % 80 == 0, short) + } +} + /// `SkullBlockEntity` animation (client ticker of dragon and piglin heads). #[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct Skull { @@ -832,6 +887,8 @@ pub struct BlockEntityAnims { pub gateways: FxHashMap<[i32; 3], EndGateway>, /// The integrated server's copies of the gateways (`portalTick`). pub gateway_servers: FxHashMap<[i32; 3], EndGateway>, + /// The integrated server's copies of the conduits (`serverTick`). + pub conduit_servers: FxHashMap<[i32; 3], ConduitServer>, /// The client level's random (conduit particles). pub random: LegacyRandom, /// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]). @@ -854,6 +911,7 @@ impl Default for BlockEntityAnims { pots: FxHashMap::default(), gateways: FxHashMap::default(), gateway_servers: FxHashMap::default(), + conduit_servers: FxHashMap::default(), random: LegacyRandom::new(0x6c6576656c), kinds: Vec::new(), present: Vec::new(), @@ -909,6 +967,7 @@ impl BlockEntityAnims { self.pots.retain(|p, _| keep(*p)); self.gateways.retain(|p, _| keep(*p)); self.gateway_servers.retain(|p, _| keep(*p)); + self.conduit_servers.retain(|p, _| keep(*p)); } /// `getOpenNess(partial)` of the chest or ender chest at `pos`. pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 { diff --git a/crates/horizon-client/src/block_entity_render.rs b/crates/horizon-client/src/block_entity_render.rs index 28a876d80..514326e66 100644 --- a/crates/horizon-client/src/block_entity_render.rs +++ b/crates/horizon-client/src/block_entity_render.rs @@ -149,6 +149,8 @@ pub fn draw_part( layer: 0, color, light, + + glint_uv: [0.0, 0.0], }); } } @@ -610,7 +612,9 @@ fn sign_text_calls( .iter() .enumerate() .map(|(i, line)| { + // The line that fits, in the order it is drawn (getVisualOrder). let shown = first_line(line, data.max_line_width(), &advance); + let shown = crate::bidi::visual_str(&shown).into_owned(); TextCall { matrix: m, x: -((width(&shown) as i32) / 2) as f32, @@ -648,6 +652,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec) { layer: 0, color: c, light: call.light, + + glint_uv: [0.0, 0.0], }); } } @@ -734,7 +740,7 @@ pub struct Scene<'a> { /// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer` /// profile → skin): a downloaded `skins/` slot or the profile's /// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve). - pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>, + pub skull: &'a dyn Fn([i32; 3]) -> Option, /// Font glyph cell (none for blank glyphs such as the space) and advance. pub glyph: &'a dyn Fn(char) -> (Option, f32), } @@ -1286,11 +1292,12 @@ impl BlockEntityRenderer { }; // SkullBlockRenderer.getRenderType: the profile's skin when it // resolves, `SKIN_BY_TYPE` (steve) otherwise. - let texture = if *kind == SkullType::Player { - (scene.skull)(pos).unwrap_or(texture) + let skin = if *kind == SkullType::Player { + (scene.skull)(pos) } else { - texture + None }; + let texture = skin.as_deref().unwrap_or(texture); let (Some(model), Some(paint)) = (self.models.get(model), paint(texture, 0xFFFFFF, packed)) else { @@ -1714,6 +1721,8 @@ impl BeamPart { layer: 6, color, light: self.wrap, + + glint_uv: [0.0, 0.0], }); } } @@ -1784,6 +1793,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec) layer, color: 0, light: 0, + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/blocks.rs b/crates/horizon-client/src/blocks.rs index 27a36396a..8e906e1bc 100644 --- a/crates/horizon-client/src/blocks.rs +++ b/crates/horizon-client/src/blocks.rs @@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [ 14200723, 8365974, ]; /// The 12 edges of the unit cube (outline data predating the export). -fn cube_edges() -> Vec<[f64; 6]> { +pub fn cube_edges() -> Vec<[f64; 6]> { let mut edges = Vec::new(); for a in 0..3 { for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] { diff --git a/crates/horizon-client/src/breaking.rs b/crates/horizon-client/src/breaking.rs index 84eedae75..df6d0f33b 100644 --- a/crates/horizon-client/src/breaking.rs +++ b/crates/horizon-client/src/breaking.rs @@ -49,6 +49,8 @@ pub fn geometry( color: 0xffff_ffff, light: stage_light(stage), normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/brewing.rs b/crates/horizon-client/src/brewing.rs index f29bfc08b..b3cda5f90 100644 --- a/crates/horizon-client/src/brewing.rs +++ b/crates/horizon-client/src/brewing.rs @@ -9,232 +9,10 @@ use crate::{ use serde_json::Value; use std::{collections::HashSet, fs, path::Path, sync::Arc}; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Category { - Beneficial, - Harmful, - Neutral, -} -/// A `MobEffect`: id (without namespace), colour, category, instantaneous. -#[derive(Clone, Copy, Debug)] -pub struct EffectDef { - pub id: &'static str, - pub color: u32, - pub category: Category, - pub instant: bool, -} -const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef { - EffectDef { - id, - color, - category, - instant: false, - } -} -use Category::{Beneficial as B, Harmful as H, Neutral as N}; -/// `MobEffects` in registration order. -pub const EFFECTS: &[EffectDef] = &[ - fx("speed", 3402751, B), - fx("slowness", 9154528, H), - fx("haste", 14270531, B), - fx("mining_fatigue", 4866583, H), - fx("strength", 16762624, B), - EffectDef { - id: "instant_health", - color: 16262179, - category: B, - instant: true, - }, - EffectDef { - id: "instant_damage", - color: 11101546, - category: H, - instant: true, - }, - fx("jump_boost", 16646020, B), - fx("nausea", 5578058, H), - fx("regeneration", 13458603, B), - fx("resistance", 9520880, B), - fx("fire_resistance", 16750848, B), - fx("water_breathing", 10017472, B), - fx("invisibility", 16185078, B), - fx("blindness", 2039587, H), - fx("night_vision", 12779366, B), - fx("hunger", 5797459, H), - fx("weakness", 4738376, H), - fx("poison", 8889187, H), - fx("wither", 7561558, H), - fx("health_boost", 16284963, B), - fx("absorption", 2445989, B), - EffectDef { - id: "saturation", - color: 16262179, - category: B, - instant: true, - }, - fx("glowing", 9740385, N), - fx("levitation", 13565951, H), - fx("luck", 5882118, B), - fx("unluck", 12624973, H), - fx("slow_falling", 15978425, B), - fx("conduit_power", 1950417, B), - fx("dolphins_grace", 8954814, B), - fx("bad_omen", 745784, N), - fx("hero_of_the_village", 4521796, B), - fx("darkness", 2696993, H), - fx("trial_omen", 1484454, N), - fx("raid_omen", 14565464, N), - fx("wind_charged", 12438015, H), - fx("weaving", 7891290, H), - fx("oozing", 10092451, H), - fx("infested", 9214860, H), - fx("breath_of_the_nautilus", 65518, B), -]; -pub fn effect(id: &str) -> Option<&'static EffectDef> { - let id = id.trim_start_matches("minecraft:"); - EFFECTS.iter().find(|e| e.id == id) -} -/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.`) and -/// effects as (effect, duration ticks, amplifier). -#[derive(Clone, Copy, Debug)] -pub struct PotionDef { - pub id: &'static str, - pub name: &'static str, - pub effects: &'static [(&'static str, i32, i32)], -} -const fn p( - id: &'static str, - name: &'static str, - effects: &'static [(&'static str, i32, i32)], -) -> PotionDef { - PotionDef { id, name, effects } -} -/// `Potions` in registration order. -pub const POTIONS: &[PotionDef] = &[ - p("water", "water", &[]), - p("mundane", "mundane", &[]), - p("thick", "thick", &[]), - p("awkward", "awkward", &[]), - p("night_vision", "night_vision", &[("night_vision", 3600, 0)]), - p( - "long_night_vision", - "night_vision", - &[("night_vision", 9600, 0)], - ), - p("invisibility", "invisibility", &[("invisibility", 3600, 0)]), - p( - "long_invisibility", - "invisibility", - &[("invisibility", 9600, 0)], - ), - p("leaping", "leaping", &[("jump_boost", 3600, 0)]), - p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]), - p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]), - p( - "fire_resistance", - "fire_resistance", - &[("fire_resistance", 3600, 0)], - ), - p( - "long_fire_resistance", - "fire_resistance", - &[("fire_resistance", 9600, 0)], - ), - p("swiftness", "swiftness", &[("speed", 3600, 0)]), - p("long_swiftness", "swiftness", &[("speed", 9600, 0)]), - p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]), - p("slowness", "slowness", &[("slowness", 1800, 0)]), - p("long_slowness", "slowness", &[("slowness", 4800, 0)]), - p("strong_slowness", "slowness", &[("slowness", 400, 3)]), - p( - "turtle_master", - "turtle_master", - &[("slowness", 400, 3), ("resistance", 400, 2)], - ), - p( - "long_turtle_master", - "turtle_master", - &[("slowness", 800, 3), ("resistance", 800, 2)], - ), - p( - "strong_turtle_master", - "turtle_master", - &[("slowness", 400, 5), ("resistance", 400, 3)], - ), - p( - "water_breathing", - "water_breathing", - &[("water_breathing", 3600, 0)], - ), - p( - "long_water_breathing", - "water_breathing", - &[("water_breathing", 9600, 0)], - ), - p("healing", "healing", &[("instant_health", 1, 0)]), - p("strong_healing", "healing", &[("instant_health", 1, 1)]), - p("harming", "harming", &[("instant_damage", 1, 0)]), - p("strong_harming", "harming", &[("instant_damage", 1, 1)]), - p("poison", "poison", &[("poison", 900, 0)]), - p("long_poison", "poison", &[("poison", 1800, 0)]), - p("strong_poison", "poison", &[("poison", 432, 1)]), - p("regeneration", "regeneration", &[("regeneration", 900, 0)]), - p( - "long_regeneration", - "regeneration", - &[("regeneration", 1800, 0)], - ), - p( - "strong_regeneration", - "regeneration", - &[("regeneration", 450, 1)], - ), - p("strength", "strength", &[("strength", 3600, 0)]), - p("long_strength", "strength", &[("strength", 9600, 0)]), - p("strong_strength", "strength", &[("strength", 1800, 1)]), - p("weakness", "weakness", &[("weakness", 1800, 0)]), - p("long_weakness", "weakness", &[("weakness", 4800, 0)]), - p("luck", "luck", &[("luck", 6000, 0)]), - p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]), - p( - "long_slow_falling", - "slow_falling", - &[("slow_falling", 4800, 0)], - ), - p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]), - p("weaving", "weaving", &[("weaving", 3600, 0)]), - p("oozing", "oozing", &[("oozing", 3600, 0)]), - p("infested", "infested", &[("infested", 3600, 0)]), -]; -pub fn potion(id: &str) -> Option<&'static PotionDef> { - let id = id.trim_start_matches("minecraft:"); - POTIONS.iter().find(|p| p.id == id) -} -/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB. -pub const BASE_POTION_COLOR: u32 = 0x385DC6; -/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours. -pub fn potion_color(id: Option<&str>) -> u32 { - let Some(def) = id.and_then(potion) else { - return BASE_POTION_COLOR; - }; - let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32); - for (e, _, amp) in def.effects { - let Some(e) = effect(e) else { continue }; - let w = *amp as u32 + 1; - r += w * (e.color >> 16 & 255); - g += w * (e.color >> 8 & 255); - b += w * (e.color & 255); - total += w; - } - match ( - r.checked_div(total), - g.checked_div(total), - b.checked_div(total), - ) { - (Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b, - _ => BASE_POTION_COLOR, - } -} +pub use horizon_client_core::potions::{ + BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced, + potion, potion_color, +}; /// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss. pub fn format_duration(ticks: i32) -> String { let secs = ticks / 20; diff --git a/crates/horizon-client/src/bundle.rs b/crates/horizon-client/src/bundle.rs index 42473729f..948e2f4a9 100644 --- a/crates/horizon-client/src/bundle.rs +++ b/crates/horizon-client/src/bundle.rs @@ -1,6 +1,7 @@ //! `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::item_data::BundleSelected; use crate::items::{ItemStack, Items}; use horizon_world::nbt::Tag; @@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool { pub struct Contents { items: Vec, weight: Weight, + /// `selectedItem`: an index into `items`, -1 for none. + selected: i32, } impl Contents { @@ -104,9 +107,11 @@ impl Contents { pub fn of(items: &Items, stack: &ItemStack) -> Option { let inside = contents_of(items, stack)?.to_vec(); let weight = contents_weight(items, &inside); + let selected = stack.data().map_or(-1, |d| d.bundle_selected.0); Some(Self { items: inside, weight, + selected, }) } @@ -154,18 +159,26 @@ impl Contents { amount } - /// `removeOne`: the first stack (Horizon keeps no selected item). + /// `removeOne`: the selected stack, else the first; nothing is selected after. pub fn remove_one(&mut self, items: &Items) -> Option { if self.items.is_empty() { return None; } - let stack = self.items.remove(0); + let at = + usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 }); + let stack = self.items.remove(at); self.weight = self .weight .sub(item_weight(items, &stack).times(stack.count)); + self.selected = -1; Some(stack) } + /// `getSelectedItemIndex`. + pub fn selected(&self) -> i32 { + self.selected + } + /// The stacks inside, in display order (the first is the next out). pub fn items(&self) -> &[ItemStack] { &self.items @@ -200,11 +213,28 @@ impl Contents { /// `toImmutable`, written back into the bundle's component. pub fn store(self, bundle: &mut ItemStack) { - bundle.data_mut().bundle_contents = self.items; + let data = bundle.data_mut(); + data.bundle_contents = self.items; + data.bundle_selected = BundleSelected(self.selected); bundle.tidy(); } } +/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the +/// selected one already or outside the contents. +pub fn toggle_selected(bundle: &mut ItemStack, index: i32) { + let Some(data) = bundle.extra.as_deref_mut() else { + return; + }; + let inside = (0..data.bundle_contents.len() as i32).contains(&index); + let selected = if data.bundle_selected.0 != index && inside { + index + } else { + -1 + }; + data.bundle_selected = BundleSelected(selected); +} + #[cfg(test)] mod tests { use super::Weight; @@ -232,8 +262,10 @@ pub struct TooltipLayout { /// The component's own width (96) and height, the text's excluded. pub width: f32, pub height: f32, - /// Each slot's top-left and the stack in it, in reading order. + /// Each slot's top-left and the stack in it, in drawing order. pub slots: Vec<([f32; 2], ItemStack)>, + /// The position in `slots` of the selected item, which is drawn highlighted. + pub selected: Option, /// The `+N` of the items past the grid: its top-left and the count. pub overflow: Option<([f32; 2], u32)>, /// The progress bar's top-left, its fill's width out of 94, and whether @@ -266,6 +298,7 @@ pub fn tooltip_layout( width: 96.0, height: description_height + 13.0 + 8.0, slots: Vec::new(), + selected: None, overflow: None, bar: [x + offset, y + description_height + 4.0], fill: 0.0, @@ -282,6 +315,7 @@ pub fn tooltip_layout( let x_start = x + offset + 96.0; let y_start = y + grid_height; let mut slots = Vec::with_capacity(shown.len()); + let mut selected = None; let mut overflow = None; // Rows fill bottom-up and columns right-to-left, as vanilla draws them. let mut slot = 0; @@ -290,8 +324,13 @@ pub fn tooltip_layout( let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0]; if hidden > 0 && row == 1 && column == 1 { overflow = Some((at, hidden)); - } else if let Some(stack) = shown.get(slot) { - slots.push((at, stack.clone())); + } else if slot < shown.len() { + // `extractSlot`: the cell of slot number n holds item `size - n`. + let index = shown.len() - 1 - slot; + if usize::try_from(contents.selected()) == Ok(index) { + selected = Some(slots.len()); + } + slots.push((at, shown[index].clone())); slot += 1; } } @@ -301,6 +340,7 @@ pub fn tooltip_layout( width: 96.0, height: grid_height + 13.0 + 8.0, slots, + selected, overflow, bar: [x + offset, y + grid_height + 4.0], // `Mth.mulAndTruncate(weight, 94)`, kept within the bar. @@ -349,8 +389,8 @@ mod tooltip_tests { assert_eq!(layout.slots.len(), 12); assert_eq!(layout.overflow, None); assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0); - // Rows fill bottom-up and columns right-to-left: the first item is in - // the bottom-right cell, the twelfth in the top-left. + // Rows fill bottom-up and columns right-to-left: the twelfth item is in + // the bottom-right cell, the first in the top-left. assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]); assert_eq!(&*layout.slots[0].1.item, "minecraft:stone"); assert_eq!(layout.slots[11].0, [0.0, 0.0]); diff --git a/crates/horizon-client/src/cauldron.rs b/crates/horizon-client/src/cauldron.rs index 03fb0676d..25a709e26 100644 --- a/crates/horizon-client/src/cauldron.rs +++ b/crates/horizon-client/src/cauldron.rs @@ -40,6 +40,8 @@ pub struct Interaction { /// The cauldron's new state. pub block: Option, pub sound: Option<&'static str>, + /// The custom statistic the player earns (`use_cauldron`, ...). + pub stat: Option<&'static str>, } fn state(name: &str, level: Option) -> BlockState { @@ -57,6 +59,7 @@ fn pass() -> Interaction { outcome: Outcome::Pass, block: None, sound: None, + stat: None, } } @@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState { } } -fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction { +fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction { Interaction { outcome: Outcome::Success, block: Some(block), sound, + stat: Some(stat), } } @@ -179,18 +183,22 @@ pub fn interact( stack.data_mut().dyed_color = None; stack.tidy(); } - return changed(lowered(cauldron), None); + return changed(lowered(cauldron), None, "clean_armor"); } let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| { if !can_fill { return pass(); } filled(player, ItemStack::new(new, 1), true); - changed(state("minecraft:cauldron", None), Some(sound)) + changed( + state("minecraft:cauldron", None), + Some(sound), + "use_cauldron", + ) }; let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| { filled(player, ItemStack::new("minecraft:bucket", 1), true); - changed(block, Some(sound)) + changed(block, Some(sound), "fill_cauldron") }; match item { "minecraft:lava_bucket" => { @@ -199,6 +207,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -221,6 +230,7 @@ pub fn interact( outcome: Outcome::Consume, block: None, sound: None, + stat: None, } } else { empty_bucket( @@ -241,6 +251,7 @@ pub fn interact( changed( state("minecraft:water_cauldron", Some(1)), Some("minecraft:item.bottle.empty"), + "use_cauldron", ) } ("water", "minecraft:bucket") => fill_bucket( @@ -255,7 +266,11 @@ pub fn interact( ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"), true, ); - changed(lowered(cauldron), Some("minecraft:item.bottle.fill")) + changed( + lowered(cauldron), + Some("minecraft:item.bottle.fill"), + "use_cauldron", + ) } ("water", "minecraft:potion") => { if level == 3 || !water_potion { @@ -266,7 +281,7 @@ pub fn interact( // BlockState.cycle on a 1..=3 property next.properties .insert("level".into(), (level % 3 + 1).to_string()); - changed(next, Some("minecraft:item.bottle.empty")) + changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron") } ("water", name) if name @@ -283,7 +298,7 @@ pub fn interact( cleaned.data_mut().banner_patterns.pop(); cleaned.tidy(); filled(player, cleaned, false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_banner") } ("water", name) if name @@ -292,7 +307,7 @@ pub fn interact( .is_some_and(|c| COLORS.contains(&c)) => { filled(player, held.transmute("minecraft:shulker_box", 1), false); - changed(lowered(cauldron), None) + changed(lowered(cauldron), None, "clean_shulker_box") } ("lava", "minecraft:bucket") => fill_bucket( player, diff --git a/crates/horizon-client/src/chunk.rs b/crates/horizon-client/src/chunk.rs index 7cd375343..b0dd377c7 100644 --- a/crates/horizon-client/src/chunk.rs +++ b/crates/horizon-client/src/chunk.rs @@ -2,6 +2,7 @@ //! threads can mesh and light immutable snapshots while the game thread edits //! (copy-on-write through `Arc::make_mut`). use horizon_core::paletted::{PalettedContainer, Strategy}; +use horizon_world::nbt::{Compound, Tag}; use horizon_world::registry::BlockStateId; use std::sync::{Arc, OnceLock}; @@ -155,7 +156,7 @@ pub struct Chunk { /// positions, applied once by LevelChunk.postProcessGeneration. pub postprocess: Vec<[i32; 3]>, /// Saved data Horizon keeps but does not simulate (save.rs). - pub saved: Option>, + pub saved: Option>, /// Entities read from a save (vanilla entity NBT), spawned on arrival. pub entities: Vec, /// Structure starts reaching into this chunk (`structures.References`): @@ -309,6 +310,76 @@ impl Chunk { } } +/// A proto chunk saved before it became final. Decoration writes into a +/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks +/// past the finished ones hold writes that generating them again would lose +/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too. +pub struct SavedProto { + /// Blocks, scheduled ticks (in `saved`) and post-processing marks. + pub column: Chunk, + /// Decorated itself (`features`), not only written to by its neighbours + /// (`terrain`). + pub decorated: bool, + /// Block entity data in placement order, with `x`, `y` and `z`. + pub block_entities: Vec, + /// Entities placed by structures, with absolute `Pos`. + pub entities: Vec, +} + +/// A container's loot table and what Horizon rolled from it. Vanilla keeps +/// `LootTable` and `LootTableSeed` until the container is first opened; +/// Horizon rolls when it generates or loads the chunk, and saves the table +/// again while the container still holds exactly the rolled items. +#[derive(Clone, Debug, PartialEq)] +pub struct RolledLoot { + pub pos: (i32, i32, i32), + pub table: String, + /// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable). + pub seed: Option, + /// The rolled `Items`. + pub items: Tag, +} +impl RolledLoot { + /// Puts the loot table back in place of `Items` while they are still the + /// roll; false once the container holds anything else. + pub fn restore(&self, map: &mut Compound) -> bool { + if map.get(&"Items".into()) != Some(&self.items) { + return false; + } + map.remove(&"Items".into()); + map.insert("LootTable".into(), Tag::String(self.table.as_str().into())); + if let Some(seed) = self.seed.filter(|s| *s != 0) { + map.insert("LootTableSeed".into(), Tag::Long(seed)); + } + true + } +} + +/// Save data loaded with a chunk that Horizon keeps but does not simulate. +#[derive(Clone, Debug, Default)] +pub struct ChunkExtras { + /// Chunk root keys written back as loaded (structures, PostProcessing, + /// InhabitedTime, blending data, ...). + pub root: Compound, + /// Block entities Horizon does not model. + pub block_entities: Vec, + /// The NBT of the modelled block entities as loaded, to restore the fields and + /// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`). + pub block_entity_sources: Vec<((i32, i32, i32), Tag)>, + /// Every saved block entity's position in the order of the save's list, + /// the order `SerializableChunkData` puts them in the chunk's maps. + pub block_entity_order: Vec<(i32, i32, i32)>, + /// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the + /// save time. Registered with the level's tick scheduler on arrival and + /// replaced with the scheduler's pending ticks when saving. + pub block_ticks: Vec, + pub fluid_ticks: Vec, + /// Game time written as `LastUpdate`. + pub last_update: i64, + /// Containers still holding their loot table's roll. + pub rolled_loot: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/chunk_map.rs b/crates/horizon-client/src/chunk_map.rs index ed25491c5..fddded9e1 100644 --- a/crates/horizon-client/src/chunk_map.rs +++ b/crates/horizon-client/src/chunk_map.rs @@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap { } } impl crate::light::LightWorld for ChunkMap { - fn state(&self, x: i32, y: i32, z: i32) -> Option { - self.columns.state(x, y, z) + fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc> { + self.columns.section(cx, s, cz) } - fn light(&self, x: i32, y: i32, z: i32) -> Option { - self.columns.light(x, y, z) - } - fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) { - self.columns.set_light(x, y, z, packed); + fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) { + self.columns.set_section_light(cx, s, cz, light); } } @@ -106,7 +103,13 @@ mod tests { for column in map.values_mut() { Arc::make_mut(column).light_loaded = true; } - crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0); + crate::light::LightWorld::set_section_light( + &mut map, + 0, + 0, + 0, + crate::chunk::Light::Uniform(0), + ); assert_eq!(inserted, map.membership_revision()); map.remove(&(0, 0)); map.insert((1, 0), chunk); diff --git a/crates/horizon-client/src/clouds.rs b/crates/horizon-client/src/clouds.rs index 70f29c132..5e9bb731a 100644 --- a/crates/horizon-client/src/clouds.rs +++ b/crates/horizon-client/src/clouds.rs @@ -179,6 +179,8 @@ impl Mesh { layer: 4, color: u32::MAX, light: shade, + + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index 7c32bd829..d35a1adfa 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape { VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column") } +// Vanilla scopes this direction to Entity.move while a piston pushes it. +thread_local! { + static PISTON_NOCLIP: std::cell::Cell> = const { std::cell::Cell::new(None) }; +} + +/// Suppress the moving part of pistons moving in this direction during a push. +pub fn with_piston_noclip( + direction: horizon_core::geometry::Direction, + f: impl FnOnce() -> T, +) -> T { + struct Reset(Option); + impl Drop for Reset { + fn drop(&mut self) { + PISTON_NOCLIP.with(|value| value.set(self.0)); + } + } + let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction))); + let _reset = Reset(previous); + f() +} + pub struct SceneCollisions<'w, 'r> { pub world: &'w World, pub binding: &'w BlockCollisionRegistry<'r>, @@ -140,8 +161,21 @@ impl> EntityMovementWorld for EntityCo border.collision_shape().map(Some).map_err(io::Error::other) } } -impl EntityCollisions<'_, SceneCollisions<'_, '_>> { - /// `SceneCollisions::resolve` that also stops at the entity boxes. +impl<'a, W> EntityCollisions<'a, W> +where + W: BlockCollisionWorld + EntityMovementWorld, +{ + /// `EntityCollisions` for a body the world border also stops: the border + /// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`). + pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self { + Self { + blocks, + entities: &[], + border: Some((border, at)), + } + } + /// `SceneCollisions::resolve` that also stops at the entity boxes and, + /// with a border, at the world border's walls. pub fn resolve( &mut self, bounds: Aabb, @@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> { max_step: f32, on_ground: bool, ) -> Result { - let context = self.blocks.context(bounds); Ok(resolve_world_movement( self, MovementRequest { entity: EntityIdentity(0), - context, + context: CollisionContext::position(bounds.min_position().y), bounds, movement, max_step, @@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> { return Ok(None); }; pistons - .collision_shape(self, p, piston, None) + .collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get())) .map(Some) .map_err(io::Error::other) } @@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> { } } +/// A collision world without blocks: no chunk is resident, so only the +/// world border stops a body. +pub struct EmptyCollisions; + +impl BlockCollisionWorld for EmptyCollisions { + type Error = io::Error; + type Chunk = ChunkPos; + fn collision_chunk(&mut self, _: ChunkPos) -> io::Result> { + Ok(None) + } + fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result { + Err(io::Error::other("no chunk is resident")) + } + fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result { + Ok(false) + } + fn context_shape( + &mut self, + _: CollisionBlock, + _: BlockPos, + _: &CollisionContext<'_>, + ) -> io::Result { + Ok(VoxelShape::empty()) + } +} + +impl EntityMovementWorld for EmptyCollisions { + type Error = io::Error; + fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(Vec::new()) + } + fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result> { + Ok(None) + } +} + /// Collision queries over any block lookup (test worlds without chunks): /// every chunk is present, nothing is suffocating, no entities collide. pub struct LookupCollisions<'w, 'r> { @@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> { Ok(false) } } + +#[cfg(test)] +mod piston_noclip_tests { + use super::*; + use horizon_core::geometry::Direction; + + #[test] + fn piston_noclip_is_scoped_and_restored_after_unwind() { + assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None); + with_piston_noclip(Direction::East, || { + assert_eq!( + PISTON_NOCLIP.with(|value| value.get()), + Some(Direction::East) + ); + let _ = + std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test"))); + assert_eq!( + PISTON_NOCLIP.with(|value| value.get()), + Some(Direction::East) + ); + }); + assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None); + } +} + +#[cfg(test)] +mod border_movement_tests { + use super::*; + + /// A border 10 wide around the origin: its wall's inner face is at x = 5. + fn border() -> BorderGeometry { + BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border") + } + + /// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`: + /// a body inside close to the border collides with the border's walls. + #[test] + fn a_body_next_to_the_border_stops_at_its_wall() { + // A body 0.6 wide, 0.4 blocks short of the wall, moved east into it. + let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6)); + let border = border(); + let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]); + let moved = collisions + .resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false) + .expect("the movement resolves"); + // The wall stops the body 0.4 blocks short of the whole move. + assert!( + (moved.x - 0.4).abs() < 1.0e-9, + "the body was not stopped by the border: {moved:?}" + ); + } + + /// Away from the border, the same body moves the whole way. + #[test] + fn a_body_away_from_the_border_moves_whole() { + let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6)); + let border = border(); + let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]); + let moved = collisions + .resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false) + .expect("the movement resolves"); + assert!( + (moved.x - 0.51).abs() < 1.0e-9, + "the body did not move the whole way: {moved:?}" + ); + } +} diff --git a/crates/horizon-client/src/command_block.rs b/crates/horizon-client/src/command_block.rs index 942e11ab6..47f808932 100644 --- a/crates/horizon-client/src/command_block.rs +++ b/crates/horizon-client/src/command_block.rs @@ -35,6 +35,23 @@ impl Mode { Self::Redstone => "minecraft:command_block", } } + /// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes. + pub fn ordinal(self) -> i32 { + match self { + Self::Sequence => 0, + Self::Auto => 1, + Self::Redstone => 2, + } + } + /// The mode of a packet's ordinal; `handleSetCommandBlock` treats any + /// other value as a plain command block (its `default` branch). + pub fn from_ordinal(ordinal: i32) -> Self { + match ordinal { + 0 => Self::Sequence, + 1 => Self::Auto, + _ => Self::Redstone, + } + } /// `CycleButton`: the next value, wrapping round. pub fn next(self) -> Self { match self { diff --git a/crates/horizon-client/src/container_screen.rs b/crates/horizon-client/src/container_screen.rs index 77c7ad8aa..a7b1794c5 100644 --- a/crates/horizon-client/src/container_screen.rs +++ b/crates/horizon-client/src/container_screen.rs @@ -124,6 +124,18 @@ impl Clicks { self.skip_next_release = true; } + /// The screen that takes the pointer over starts with its own state: + /// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it + /// belongs to on the screen, so a screen that opens (or closes) drops + /// them. A press whose release never arrived -- the pointer left the + /// window -- would otherwise keep the drag running, and `press` answers + /// `None` for every later press, so the quick move and the clicks go + /// dead on every screen after it. + pub fn cancel_drag(&mut self) { + self.quick_crafting = None; + self.skip_next_release = false; + } + /// `mouseClicked` (no double click): the `slotClicked` input it sends on /// `target`, if any. A press holding a stack starts a quick craft drag /// instead. `cloning` is `keyPickItem` with infinite materials. @@ -301,4 +313,35 @@ mod tests { Some(Input::Pickup) ); } + + /// #1737: a press whose release never arrives (the pointer left the + /// window) leaves `isQuickCrafting` set, and `press` answers `None` for + /// every later press, so the Shift quick move and the clicks on the + /// inventory go dead. The drag belongs to the screen that has it + /// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen + /// that takes the pointer over drops it and the quick move works again. + #[test] + fn a_lost_release_does_not_wedge_the_quick_move() { + let mut clicks = Clicks::default(); + let slot = Target::::Slot(3); + // Pressed with a stack carried: a quick craft drag starts, and its + // release never arrives. + assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None); + assert!(clicks.quick_crafting().is_some()); + // The stuck drag swallows the next press: the Shift quick move stops + // working and the inventory stops answering. + assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None); + // The screen that takes the pointer over starts with its own state. + clicks.cancel_drag(); + assert!(clicks.quick_crafting().is_none()); + // The quick move works again. + assert_eq!( + clicks.press(&slot, Button::Right, true, true, false), + Some(Input::QuickMove) + ); + assert_eq!( + clicks.press(&slot, Button::Left, true, false, false), + Some(Input::Pickup) + ); + } } diff --git a/crates/horizon-client/src/containers.rs b/crates/horizon-client/src/containers.rs index 6aba0970e..ed7d09a60 100644 --- a/crates/horizon-client/src/containers.rs +++ b/crates/horizon-client/src/containers.rs @@ -98,6 +98,18 @@ pub enum BlockEntity { Campfire(Box), /// `SkullBlockEntity` of every skull and head block. Skull(Skull), + /// `TheEndGatewayBlockEntity`: its age and configured destination. + EndGateway(EndGateway), + /// `DaylightDetectorBlockEntity`: no data, only the ticker + /// (`DaylightDetectorBlock.tickEntity`). + DaylightDetector, +} +/// `TheEndGatewayBlockEntity`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct EndGateway { + pub age: i64, + pub exit_portal: Option<[i32; 3]>, + pub exact_teleport: bool, } /// `CampfireBlockEntity`: four cooking slots, each with its /// `cookingProgress` and `cookingTime` (the recipe's). @@ -187,6 +199,9 @@ impl Skull { #[derive(Clone, Debug)] pub struct SkullSkin { pub download: Option<(String, String)>, + /// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or + /// an id only): the key `skins::Resolver` resolves it by. + pub dynamic: Option, pub fallback: &'static str, } impl SkullSkin { @@ -195,10 +210,21 @@ impl SkullSkin { let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?); Some((url, location)) }); + // A `Dynamic` profile (a name or id only) resolves through the + // services when the skull is drawn. + let dynamic = crate::skins::dynamic_nbt(profile); // DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id. let fallback = crate::skins::profile_uuid_nbt(profile) + .or_else(|| { + crate::skins::nbt_text(profile.get("name")) + .map(|name| horizon_protocol::crypto::offline_uuid(&name)) + }) .map_or("entity/player/slim/steve", crate::tab_list::default_skin); - Self { download, fallback } + Self { + download, + dynamic, + fallback, + } } } /// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every @@ -363,6 +389,12 @@ impl BlockEntity { if is_skull(name) { return Some(Self::Skull(Skull::default())); } + if name == "minecraft:end_gateway" { + return Some(Self::EndGateway(EndGateway::default())); + } + if name == "minecraft:daylight_detector" { + return Some(Self::DaylightDetector); + } if name == "minecraft:beacon" { return Some(Self::Beacon(Box::new(Beacon { last_check_y: i32::MIN / 2, @@ -412,6 +444,8 @@ impl BlockEntity { Self::Beehive(_) => "minecraft:beehive", Self::CommandBlock(_) => "minecraft:command_block", Self::Skull(_) => "minecraft:skull", + Self::EndGateway(_) => "minecraft:end_gateway", + Self::DaylightDetector => "minecraft:daylight_detector", Self::Campfire(_) => "minecraft:campfire", } } @@ -429,7 +463,9 @@ impl BlockEntity { | Self::Vault(_) | Self::Beehive(_) | Self::CommandBlock(_) - | Self::Skull(_) => Vec::new(), + | Self::EndGateway(_) + | Self::Skull(_) + | Self::DaylightDetector => Vec::new(), Self::Lectern(l) => l.book.iter().cloned().collect(), Self::Sculk(_) => Vec::new(), Self::Banner(_) => Vec::new(), @@ -448,7 +484,9 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &[], + | Self::EndGateway(_) + | Self::Skull(_) + | Self::DaylightDetector => &[], Self::Crafter(c) => &c.items, Self::CreakingHeart(_) | Self::Spawner(_) @@ -471,7 +509,9 @@ impl BlockEntity { | Self::Lectern(_) | Self::Sculk(_) | Self::Banner(_) - | Self::Skull(_) => &mut [], + | Self::EndGateway(_) + | Self::Skull(_) + | Self::DaylightDetector => &mut [], Self::Crafter(c) => &mut c.items, Self::CreakingHeart(_) | Self::Spawner(_) @@ -718,6 +758,18 @@ impl Furnace { .recipes_used .entry(recipe_used_key(&recipe.id)) .or_insert(0) += 1; + // AbstractFurnaceBlockEntity.burn: drying a wet sponge fills + // the bucket in the fuel slot. + if self.items[0] + .as_ref() + .is_some_and(|i| &*i.item == "minecraft:wet_sponge") + && self.items[1] + .as_ref() + .is_some_and(|f| &*f.item == "minecraft:bucket") + { + self.items[1] = + Some(ItemStack::new("minecraft:water_bucket", 1)); + } if let Some(i) = self.items[0].as_mut() { i.count -= 1; if i.count == 0 { diff --git a/crates/horizon-client/src/effects.rs b/crates/horizon-client/src/effects.rs index 17dce93ab..9511fd112 100644 --- a/crates/horizon-client/src/effects.rs +++ b/crates/horizon-client/src/effects.rs @@ -2,7 +2,7 @@ //! add/update with hidden effects, per-tick application of the periodic //! effects (regeneration, poison, wither, hunger, saturation, instant //! effects), and the derived flags the movement and damage code read. -use crate::brewing; +use horizon_client_core::potions; #[derive(Clone, Debug, PartialEq)] pub struct EffectInstance { @@ -179,13 +179,14 @@ impl PlayerEffects { self.list.clear(); } /// The effects of a saved player (`Player.active_effects`: id, - /// duration, amplifier, ambient, visible). + /// duration, amplifier, ambient, particles visible). pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self { let mut effects = Self::default(); - for (id, duration, amplifier, ambient, _) in saved { - if let Some(def) = brewing::effect(id) { + for (id, duration, amplifier, ambient, particles) in saved { + if let Some(def) = potions::effect(id) { let mut e = EffectInstance::new(def.id, *duration, *amplifier); e.ambient = *ambient; + e.visible = *particles; effects.add(e); } } @@ -213,8 +214,9 @@ impl PlayerEffects { } /// `LivingEntity.tickEffects` (server part) with the omens' effect ticks: /// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect` - /// (on its last tick). Returns the actions to apply, in order, and - /// whether Raid Omen was newly added. + /// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s + /// answer: the effect ends once the absorption is used up. Returns the + /// actions to apply, in order, and whether Raid Omen was newly added. /// /// Bad Omen turning into a new Raid Omen adds an entry to the map being /// iterated: vanilla's iterator then throws a @@ -224,6 +226,7 @@ impl PlayerEffects { pub fn tick_with( &mut self, omen: &mut dyn FnMut(&EffectInstance) -> OmenTick, + absorption_empty: bool, ) -> (Vec, bool) { self.tick_count = self.tick_count.wrapping_add(1); let mut actions = Vec::new(); @@ -246,6 +249,7 @@ impl PlayerEffects { let answer = match e.id { "bad_omen" => omen(e), "raid_omen" if t == 1 => omen(e), + "absorption" if absorption_empty => OmenTick::Remove, _ => OmenTick::Continue, }; match answer { @@ -302,10 +306,10 @@ impl PlayerEffects { ) -> (Vec, Vec) { let mut lasting = Vec::new(); let mut now = Vec::new(); - if let Some(def) = potion.and_then(brewing::potion) { + if let Some(def) = potion.and_then(potions::potion) { for (id, duration, amp) in def.effects { - let id = brewing::effect(id).map_or("speed", |e| e.id); - if brewing::effect(id).is_some_and(|e| e.instant) { + let id = potions::effect(id).map_or("speed", |e| e.id); + if potions::effect(id).is_some_and(|e| e.instant) { now.extend(instant(id, *amp, 1.0)); } else { // withScaledDuration: max(floor(d * scale), 1). @@ -465,13 +469,13 @@ mod tests { }; // The first tick adds Raid Omen and stops there (the iterator's // ConcurrentModificationException): Bad Omen and Speed stay as they were. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(added); assert_eq!(fx.get("bad_omen").unwrap().duration, 100); assert_eq!(fx.get("speed").unwrap().duration, 50); assert_eq!(fx.get("raid_omen").unwrap().duration, 600); // The second refreshes Raid Omen and drops Bad Omen. - let (_, added) = fx.tick_with(&mut convert); + let (_, added) = fx.tick_with(&mut convert, false); assert!(!added); assert!(fx.get("bad_omen").is_none()); assert_eq!(fx.get("speed").unwrap().duration, 49); @@ -486,9 +490,9 @@ mod tests { seen.push(e.duration); OmenTick::Remove }; - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_some()); - fx.tick_with(&mut watch); + fx.tick_with(&mut watch, false); assert!(fx.get("raid_omen").is_none()); assert_eq!(seen, vec![1]); } diff --git a/crates/horizon-client/src/enchant.rs b/crates/horizon-client/src/enchant.rs index 2f418ae82..73b9bf090 100644 --- a/crates/horizon-client/src/enchant.rs +++ b/crates/horizon-client/src/enchant.rs @@ -545,6 +545,32 @@ impl Enchantments { } out } + /// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the + /// `minecraft:attributes` effects of `stack`'s enchantments as rows of + /// (attribute, id, amount, operation, slot group), one per slot group the + /// enchantment lists. + pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> { + let mut rows = Vec::new(); + for (id, level) in stack.enchantments() { + let Some(e) = self.get(id) else { continue }; + for a in e.effects["minecraft:attributes"] + .as_array() + .into_iter() + .flatten() + { + for slot in &e.slots { + rows.push(( + a["attribute"].as_str().unwrap_or("").to_owned(), + a["id"].as_str().unwrap_or("").to_owned(), + f64::from(level_value(&a["amount"], *level)), + a["operation"].as_str().unwrap_or("add_value").to_owned(), + slot.clone(), + )); + } + } + } + rows + } /// Sum of `add_value` amounts of one attribute from enchantments on `stack`. pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 { stack.map_or(0.0, |s| { diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index 69e053411..14cd946c2 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -95,6 +95,15 @@ impl ItemEntity { self.health -= fire_damage as i32; } } + // CactusBlock.entityInside: `entity.hurt(cactus, 1.0)`. An item has no + // hurt cooldown, so it loses 1 health every tick it touches a cactus + // (5 health: gone in 5 ticks). Fire resistance does not help; + // `ItemStack.canBeHurtBy` only skips it for a damage_resistant stack. + if crate::motion::environment::cactus_contact(world, registry, self.bounds()) + && !items.resists_cactus(&self.stack) + { + self.health -= 1; + } ItemEnvironmentTick { alive: self.health > 0, lava_hurt, @@ -385,8 +394,29 @@ pub struct ExperienceOrb { pub value: u32, pub age: u32, pub on_ground: bool, + /// `ExperienceOrb.count`: orbs merged into this one; each pickup gives + /// `value` once and takes one off. + pub count: u32, + /// `Entity.tickCount`, which paces `ExperienceOrb.scanForMerges`. + pub ticks: u32, } impl ExperienceOrb { + pub fn bounds(&self) -> Aabb { + let h = 0.25; + Aabb::new( + Vec3::new(self.position.x - h, self.position.y, self.position.z - h), + Vec3::new( + self.position.x + h, + self.position.y + 0.5, + self.position.z + h, + ), + ) + } + /// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed: + /// the same value and ids a multiple of 40 apart. + pub fn can_merge(&self, id: u32, value: u32) -> bool { + self.value == value && (self.id as i32).wrapping_sub(id as i32) % 40 == 0 + } /// ExperienceOrb.getExperienceValue: the largest orb size not above `value`. pub fn split(value: u32) -> u32 { [2477, 1237, 617, 307, 149, 73, 37, 17, 7, 3, 1] @@ -416,7 +446,9 @@ impl ExperienceOrb { facts: &BlockFacts, collision: &BlockCollisionRegistry<'_>, player: Option<[f64; 3]>, + next_float: &mut dyn FnMut() -> f32, ) -> bool { + self.ticks += 1; self.previous = self.position; let block = world.block( self.position.x.floor() as i32, @@ -432,6 +464,14 @@ impl ExperienceOrb { } else { self.velocity.y -= 0.03; } + if facts.get(block).fluid == Fluid::Lava { + // ExperienceOrb.tick: lava throws the orb back out. + self.velocity = Vec3::new( + f64::from((next_float() - next_float()) * 0.2), + f64::from(0.2f32), + f64::from((next_float() - next_float()) * 0.2), + ); + } if let Some(p) = player { let d = [ (p[0] - self.position.x) / 8.0, @@ -449,15 +489,7 @@ impl ExperienceOrb { ); } } - let h = 0.25; - let bounds = Aabb::new( - Vec3::new(self.position.x - h, self.position.y, self.position.z - h), - Vec3::new( - self.position.x + h, - self.position.y + 0.5, - self.position.z + h, - ), - ); + let bounds = self.bounds(); let mut scene = SceneCollisions { world, binding: collision, @@ -494,6 +526,31 @@ impl ExperienceOrb { self.age < 6000 } } +/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`: +/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box, +/// adding their counts and keeping the smaller age. +pub fn merge_orbs(orbs: &mut Vec) { + let mut removed = vec![false; orbs.len()]; + for i in 0..orbs.len() { + if removed[i] || orbs[i].ticks % 20 != 1 { + continue; + } + let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5)); + for j in 0..orbs.len() { + if j != i + && !removed[j] + && orbs[j].can_merge(orbs[i].id, orbs[i].value) + && orbs[j].bounds().intersects(area) + { + orbs[i].count += orbs[j].count; + orbs[i].age = orbs[i].age.min(orbs[j].age); + removed[j] = true; + } + } + } + let mut removed = removed.into_iter(); + orbs.retain(|_| !removed.next().unwrap_or(false)); +} /// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel). #[derive(Clone, Copy, Debug, Default)] pub struct Experience { @@ -525,17 +582,21 @@ impl Experience { 7 + level * 2 } } - /// Returns true when a level was gained. - pub fn give(&mut self, points: i32) -> bool { - let start = self.level; + /// Returns the first multiple of 5 the player reached, where each level + /// gained would play the level-up sound (`Player.giveExperienceLevels`). + pub fn give(&mut self, points: i32) -> Option { + let mut sound_level = None; self.progress += points as f32 / Self::needed(self.level) as f32; self.total = self.total.saturating_add(points).max(0); while self.progress >= 1.0 { self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32; self.level += 1; + if self.level % 5 == 0 { + sound_level.get_or_insert(self.level); + } self.progress /= Self::needed(self.level) as f32; } - self.level > start + sound_level } } /// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 79e11719f..e90b9dd66 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -14,7 +14,8 @@ struct Globals { // Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: array, 2>, // x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000) - // y: Nether cardinal lighting, z: how many dynamic_lights are set. + // y: Nether cardinal lighting, z: how many dynamic_lights are set, + // w: the glint scroll clock (`Glint::scroll_ticks`). time: vec4, crumbling_sprite: vec4, // Selection outline parameters (read by terrain.wgsl). @@ -27,6 +28,12 @@ struct Globals { @group(0) @binding(2) var atlas_sampler: sampler; @group(0) @binding(3) var lightmap: texture_2d; @group(0) @binding(4) var lightmap_sampler: sampler; +// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler). +@group(0) @binding(5) var glint_tex: texture_2d; +@group(0) @binding(6) var glint_sampler: sampler; +// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with +// the same repeating, linearly filtered sampler as the item glint. +@group(0) @binding(7) var glint_armor_tex: texture_2d; struct Out { @builtin(position) clip: vec4, @location(0) uv: vec2, @@ -44,6 +51,9 @@ struct Out { // entity.vsh's lightMapColor, multiplied in after the overlay. @location(9) light_color: vec4, @location(10) @interpolate(flat) weather_rect: vec4, + // The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV + // `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour). + @location(11) glint_uv: vec2, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -103,18 +113,23 @@ fn entity_vertex(position: vec3, uv: vec2, out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u); out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); out.overlay = overlay; + out.glint_uv = vec2(0.0); if overlay == 4u { out.spherical = length(position); } return out; } @vertex fn vs_entity(@location(0) position: vec3, @location(1) uv: vec2, - @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, @location(5) normal: vec4) -> Out { - return entity_vertex(position, uv, overlay, color, light, normal); + @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, + @location(5) normal: vec4, @location(6) glint_uv: vec2) -> Out { + var out = entity_vertex(position, uv, overlay, color, light, normal); + out.glint_uv = glint_uv; + return out; } // Retained bolt-local geometry, translated using the same f64 camera // subtraction as the former CPU path before adding each local f32 position. +// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV. @vertex fn vs_lightning(@location(0) position: vec3, @location(1) uv: vec2, @location(2) overlay: u32, @location(3) color: vec4, @location(4) light: vec4, @location(5) normal: vec4, - @location(6) offset: vec3) -> Out { + @location(7) offset: vec3) -> Out { return entity_vertex(offset + position, uv, overlay, color, light, normal); } struct CloudInfo { @@ -166,6 +181,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { if d >= end { return 1.0; } return (d - start) / (end - start); } +// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING. +// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the +// common period of both offsets. +fn armor_glint_uv(model: vec2) -> vec2 { + let ticks = globals.time.w; + let offset0 = (ticks % 110000.0) / 110000.0; + let offset1 = (ticks % 30000.0) / 30000.0; + let angle = 0.17453292; + let c = cos(angle); + let s = sin(angle); + let scaled = model * 0.16; + return vec2(scaled.x * c - scaled.y * s - offset0, + scaled.x * s + scaled.y * c + offset1); +} +// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha` +// is squared along with the sample. +fn glint_over(color: vec4, glint: vec4) -> vec4 { + let alpha = globals.fog_ends.w; + let g = alpha * glint; + return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha)); +} // CRUMBLING_UV_BEGIN fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, dimensions: vec2) -> vec2 { let size = vec2(dimensions); @@ -203,25 +239,60 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, // lit texture, then the lightmap, then the glint and fog. color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a); color *= in.light_color; - if in.overlay == 7u || in.overlay == 8u { - // Enchantment glint (overlay 7, 8 with the hurt tint): the purple - // enchanted_glint sheen drifting diagonally over the item's shape. - let px = in.uv * vec2(textureDimensions(atlas)); - let t = globals.misc.y * 0.05; - let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5; - let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5; - let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0); - let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer); - color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a); + if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u { + // 33: a translucent item with the glint. + // Enchantment glint: item.fsh's GLINT path, sampled from + // enchanted_glint_item at the `GLINT_TEXTURING` UV. + color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv)); + } else if in.overlay == 13u { + // Enchanted armour: enchanted_glint_armor at the scrolling model UV. + color = glint_over( + color, + textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)), + ); } let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)); return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a); } +// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no +// normal, so no cardinal light) wherever the texture is not fully clear. +@fragment fn fs_outline(in: Out) -> @location(0) vec4 { + if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; } + return vec4(in.color.rgb, 1.0); +} + +fn celestial_color(in: Out) -> vec4 { + return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color; +} @fragment fn fs_celestial(in: Out) -> @location(0) vec4 { - let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color; - return color; + return celestial_color(in); +} + +// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light, +// premultiplied for an additive (one, one) blend, and as alpha how much of it +// the texel adds, which the blend takes off the frame's alpha. HDR output +// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not +// the dark rest of their quads or the dim End sky. +@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4 { + let color = celestial_color(in); + let added = color.rgb * color.a; + return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b)))); +} + +// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by +// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds +// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both +// as breaking::stage_light packs them. +@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4 { + let bytes = round(in.color * 255.0); + let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0; + let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas))); + if color.a < 0.1 { discard; } + let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), + linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)); + return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a); } // RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off). diff --git a/crates/horizon-client/src/entity_culling.rs b/crates/horizon-client/src/entity_culling.rs index 16de17465..0c309041a 100644 --- a/crates/horizon-client/src/entity_culling.rs +++ b/crates/horizon-client/src/entity_culling.rs @@ -186,6 +186,8 @@ mod tests { color: u32::MAX, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }; let prior = vertex([0.0; 3]); let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])]; diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 7f3565067..5c3d41ddc 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { ("Silent", m.silent), ("NoGravity", m.no_gravity), ("Glowing", m.glowing), - ("Invulnerable", m.invulnerable_flag), ] { if on { map.insert(key.into(), Tag::Byte(1)); @@ -188,6 +187,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { map.remove(&horizon_world::nbt::JavaString::from(key)); } } + // LivingEntity.addAdditionalSaveData: `active_effects` only when there are + // any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one + // given with `hideParticles` shows neither particles nor its icon). + if m.extra.effects.is_empty() { + map.remove(&horizon_world::nbt::JavaString::from("active_effects")); + } else { + let effects = m + .extra + .effects + .iter() + .map(|&(effect, duration, amplifier)| { + let shown = !m.extra.hidden_effects.contains(&effect); + let mut e = vec![ + ("id", string(crate::brewing::namespaced(effect))), + ("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)), + ("duration", Tag::Int(duration)), + ]; + if !shown { + e.push(("show_particles", Tag::Byte(0))); + } + e.push(("show_icon", Tag::Byte(i8::from(shown)))); + Tag::compound(e) + }); + map.insert("active_effects".into(), Tag::List(effects.collect())); + } + map.insert( + "Invulnerable".into(), + Tag::Byte(i8::from(m.invulnerable_flag)), + ); + map.insert( + "LeftHanded".into(), + Tag::Byte(i8::from(m.extra.left_handed)), + ); + // Keys of older formats that structure templates still carry. + for key in [ + "HandItems", + "ArmorItems", + "HandDropChances", + "ArmorDropChances", + "Dimension", + "HurtByTimestamp", + "Leashed", + ] { + map.remove(&horizon_world::nbt::JavaString::from(key)); + } if m.ticks_frozen > 0 { map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen)); } else { @@ -197,7 +241,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { ("AbsorptionAmount", Tag::Float(0)), ("FallFlying", Tag::Byte(0)), ("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))), - ("LeftHanded", Tag::Byte(0)), + ("current_impulse_context_reset_grace_time", Tag::Int(0)), ( "Brain", Tag::compound([("memories", Tag::Compound(Compound::new()))]), @@ -209,7 +253,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { // Only breedable animals have an age; a loaded creature that was saved without // one (a frog) stays without. let aged = raw.is_none_or(|r| r.get("Age").is_some()); - if m.kind.category == "creature" && aged { + if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged { map.insert("Age".into(), Tag::Int(m.extra.age)); map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked))); map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age)); @@ -225,7 +269,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { if is_animal(id) { map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0))); } + let species = m.species(); + if let Some((_, variant)) = + crate::mobs::finalize::species_variant_component(species, m.extra.variant) + { + map.insert("variant".into(), string(&variant)); + } + if species == crate::mobs::Species::Chicken { + map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time)); + } + let sounds = crate::mobs::finalize::sound_variants(species); + if !sounds.is_empty() { + let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize)); + let sound = sound.copied().unwrap_or("classic"); + map.insert( + "sound_variant".into(), + string(&format!("minecraft:{sound}")), + ); + } match id { + // Fox.addAdditionalSaveData; Horizon models only the type. + "minecraft:fox" => { + let kind = if m.extra.variant == 1 { "snow" } else { "red" }; + map.insert("Type".into(), string(kind)); + for (key, value) in [ + ("Trusted", Tag::List(Vec::new())), + ("Sleeping", Tag::Byte(0)), + ("Sitting", Tag::Byte(0)), + ("Crouching", Tag::Byte(0)), + ] { + map.entry(key.into()).or_insert(value); + } + } + "minecraft:rabbit" => { + map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant))); + map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0)); + } + "minecraft:goat" => { + map.insert( + "IsScreamingGoat".into(), + Tag::Byte(i8::from(m.extra.screaming)), + ); + let [left, right] = m.extra.lost_horns; + map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left))); + map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right))); + } "minecraft:creeper" => { for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] { map.entry(key.into()).or_insert(value); @@ -241,10 +329,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting))); map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8)); - let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)]; - map.insert("variant".into(), string(&format!("minecraft:{variant}"))); - map.entry("sound_variant".into()) - .or_insert_with(|| string("minecraft:classic")); } "minecraft:wolf" => { if let Some(owner) = m.extra.owner { @@ -254,17 +338,30 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting))); map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8)); - let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0; - map.insert("variant".into(), string(&format!("minecraft:{variant}"))); - map.entry("sound_variant".into()) - .or_insert_with(|| string("minecraft:classic")); } "minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => { + // Only Piglin.addAdditionalSaveData writes CannotHunt. + if id == "minecraft:piglin_brute" { + map.remove(&"CannotHunt".into()); + } map.insert( "IsImmuneToZombification".into(), Tag::Byte(i8::from(m.extra.flag2)), ); map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2)); + if id == "minecraft:piglin" { + // InventoryCarrier.writeInventoryToTag. + map.insert( + "Inventory".into(), + Tag::List( + m.extra + .inventory + .iter() + .map(crate::save::item_to_nbt) + .collect(), + ), + ); + } } "minecraft:horse" | "minecraft:donkey" @@ -275,6 +372,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { | "minecraft:trader_llama" => { map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame))); map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3))); + // AbstractHorse.addAdditionalSaveData; Horizon models neither. + for key in ["EatingHaystack", "Bred"] { + map.entry(key.into()).or_insert(Tag::Byte(0)); + } if let Some(owner) = m.extra.owner { map.insert("Owner".into(), Tag::IntArray(owner.to_vec())); } @@ -357,6 +458,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { "minecraft:bat" => { map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag))); } + "minecraft:turtle" => { + map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag))); + if let Some(home) = m.extra.home { + map.insert("home_pos".into(), Tag::IntArray(home.to_vec())); + } + } "minecraft:creaking" => { // home_pos: the heart it is bound to. if let Some(home) = m.extra.home { @@ -384,7 +491,32 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { crate::mobs::villager::write_nbt(m, map); } "minecraft:wandering_trader" => put_offers(map, m), - "minecraft:axolotl" => write_axolotl_memories(m, map), + "minecraft:axolotl" => { + write_axolotl_memories(m, map); + map.insert( + "Variant".into(), + Tag::Int(i32::from(m.extra.variant.min(4))), + ); + } + "minecraft:parrot" => { + map.insert( + "Variant".into(), + Tag::Int(i32::from(m.extra.variant.min(4))), + ); + } + "minecraft:salmon" => { + let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)]; + map.insert("type".into(), string(size)); + } + // TropicalFish.addAdditionalSaveData: the packed variant. + "minecraft:tropical_fish" => { + let pattern = m.extra.variant.min(11); + let packed = i32::from(pattern >= 6) + | i32::from(pattern % 6) << 8 + | i32::from(m.extra.variant2 & 15) << 16 + | i32::from(m.extra.variant3 & 15) << 24; + map.insert("Variant".into(), Tag::Int(packed)); + } "minecraft:allay" => { // InventoryCarrier: the fetched stack; the liked player memory. let mut inv = Vec::new(); @@ -445,6 +577,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new())); } } + // Armadillo.addAdditionalSaveData: the time left to the next scute. + "minecraft:armadillo" => { + map.insert("scute_time".into(), Tag::Int(m.extra.egg_time)); + } "minecraft:ravager" => { map.insert("AttackTick".into(), Tag::Int(m.extra.counter)); map.insert("StunTick".into(), Tag::Int(m.extra.counter2)); @@ -479,10 +615,8 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { )]) }); } - "minecraft:zombie" => { - map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby()))); + "minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => { for (key, value) in [ - ("CanBreakDoors", Tag::Byte(0)), ("DrownedConversionTime", Tag::Int(-1)), ("InWaterTime", Tag::Int(-1)), ] { @@ -507,6 +641,28 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } _ => {} } + // Zombie.addAdditionalSaveData: CanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + map.insert( + "CanBreakDoors".into(), + Tag::Byte(i8::from(m.extra.can_break_doors)), + ); + } + if matches!( + id, + "minecraft:zombie" + | "minecraft:husk" + | "minecraft:drowned" + | "minecraft:zombie_villager" + | "minecraft:zombified_piglin" + | "minecraft:piglin" + | "minecraft:zoglin" + ) { + map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby()))); + } + if let Some(a) = m.extra.attributes.as_deref() { + put_attributes(map, m, a); + } crate::mobs::raider::write_nbt(m, map); crate::mobs::riders::write_nbt(m, map); // LivingEntity equipment: what the mob holds now. @@ -589,6 +745,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag { } tag } +/// `AttributeMap.pack` for the attributes with a base or permanent modifier +/// finalizeSpawn set, over the saved list. +fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) { + use crate::mobs::finalize::{self, Attr, Op}; + let mut list = match map.get(&"attributes".into()) { + Some(Tag::List(l)) => l.clone(), + _ => Vec::new(), + }; + for attr in Attr::ALL { + let modifiers: Vec = a + .modifiers + .iter() + .filter(|x| x.attr == attr) + .map(|x| { + let op = match x.op { + Op::AddValue => "add_value", + Op::AddMultipliedBase => "add_multiplied_base", + Op::AddMultipliedTotal => "add_multiplied_total", + }; + Tag::compound([ + ("id", string(x.id)), + ("amount", Tag::Double(x.amount.to_bits())), + ("operation", string(op)), + ]) + }) + .collect(); + if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) { + continue; + } + let id = format!("minecraft:{}", attr.name()); + let mut entry = vec![ + ("id", string(&id)), + ("base", Tag::Double(finalize::base_value(m, attr).to_bits())), + ]; + if !modifiers.is_empty() { + entry.push(("modifiers", Tag::List(modifiers))); + } + let entry = Tag::compound(entry); + match list + .iter_mut() + .find(|t| text(t.get("id")).as_deref() == Some(id.as_str())) + { + Some(t) => *t = entry, + None => list.push(entry), + } + } + map.insert("attributes".into(), Tag::List(list)); +} +/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones. +pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) { + if m.extra.memories.is_empty() { + return; + } + let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) { + Some(Tag::Compound(c)) => c.clone(), + _ => Compound::new(), + }; + for &(name, value, ttl) in &m.extra.memories { + let value = match name { + "hunted_recently" => Tag::Byte(value as i8), + "home" => match m.extra.home { + Some(p) => Tag::compound([ + ("dimension", string(dimension)), + ("pos", Tag::IntArray(p.to_vec())), + ]), + None => continue, + }, + _ => Tag::Int(value as i32), + }; + let mut memory = vec![("value", value)]; + if ttl != i64::MAX { + memory.push(("ttl", Tag::Long(ttl))); + } + memories.insert( + format!("minecraft:{name}").as_str().into(), + Tag::compound(memory), + ); + } + map.insert( + "Brain".into(), + Tag::compound([("memories", Tag::Compound(memories))]), + ); +} /// `CustomName`: a text component saved as a string or a `{text: ..}` compound. fn custom_name(tag: &Tag) -> Option { match tag.get("CustomName")? { @@ -608,6 +847,22 @@ pub fn name_text(tag: &Tag) -> Text { t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)), } } +/// Mob.finalizeSpawn's follow range bonus in the saved attributes. +pub(crate) fn spawn_bonus(tag: &Tag) -> Option { + let Some(Tag::List(attrs)) = tag.get("attributes") else { + return None; + }; + let range = attrs + .iter() + .find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?; + let Some(Tag::List(modifiers)) = range.get("modifiers") else { + return None; + }; + let bonus = modifiers + .iter() + .find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?; + float(bonus.get("amount")) +} pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) -> Option { let kind = types.get(&self::kind(tag)?)?.clone(); let position = position(tag)?; @@ -633,7 +888,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - } } } + if let Some(bonus) = spawn_bonus(tag) { + use crate::mobs::finalize::{Attr, Op, add_modifier}; + let id = "minecraft:random_spawn_bonus"; + add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase); + } m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32); + // LivingEntity.setHealth clamps to the max health: a horse or llama is made + // with the type's and saved before it drops to the one it rolled. (Only + // the base is read here, so the clamp would cut a leader zombie's bonus.) + if crate::mobs::equine::is_equine(m.species()) { + m.health = m.health.min(m.attrs.max_health); + } m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32; // What it holds (equipment.mainhand / offhand), when saved. if let Some(eq) = tag.get("equipment") { @@ -674,7 +940,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - m.extra.pick_up_loot = flag(tag, "CanPickUpLoot"); m.custom_name = custom_name(tag); m.custom_name_text = tag.get("CustomName").map(name_text); - if m.kind.category == "creature" { + m.extra.left_handed = flag(tag, "LeftHanded"); + if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin { m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32; m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32; m.extra.age_locked = flag(tag, "AgeLocked"); @@ -687,6 +954,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - m.no_gravity = flag(tag, "NoGravity"); m.glowing = flag(tag, "Glowing"); m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32; + // LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last + // entry of an effect winning. + if let Some(Tag::List(effects)) = tag.get("active_effects") { + for e in effects { + let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else { + continue; + }; + let effect = crate::brewing::namespaced(def.id); + let duration = number(e.get("duration")).unwrap_or(0) as i32; + let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32; + m.extra.effects.retain(|x| x.0 != effect); + m.extra.effects.push((effect, duration, amplifier)); + // Details.CODEC: `show_particles` defaults to true. + m.extra.hidden_effects.retain(|h| *h != effect); + if number(e.get("show_particles")) == Some(0) { + m.extra.hidden_effects.push(effect); + } + } + } m.silent = flag(tag, "Silent"); m.invulnerable_flag = flag(tag, "Invulnerable"); if m.species() == crate::mobs::Species::Bat { @@ -706,6 +992,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - ) { m.extra.flag2 = flag(tag, "IsImmuneToZombification"); m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32; + if m.species() == crate::mobs::Species::Piglin + && let Some(Tag::List(items)) = tag.get("Inventory") + { + // InventoryCarrier.readInventoryFromTag (eight slots). + m.extra.inventory = items + .iter() + .filter_map(crate::save::item_from_nbt) + .take(8) + .collect(); + } } if matches!( m.species(), @@ -758,20 +1054,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - m.extra.vector[0] = 0.7; } } + if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some() + && let Some(v) = text(tag.get("variant")) + { + m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v); + } + if m.species() == crate::mobs::Species::Chicken + && let Some(t) = number(tag.get("EggLayTime")) + { + m.extra.egg_time = t as i32; + } + if let Some(v) = text(tag.get("sound_variant")) { + let v = v.trim_start_matches("minecraft:"); + let sounds = crate::mobs::finalize::sound_variants(m.species()); + m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8); + } + match m.species() { + crate::mobs::Species::Fox => { + m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow")); + } + crate::mobs::Species::Rabbit => { + // Rabbit.Variant.byId: an unknown id is brown. + m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) { + v @ (0..=5 | 99) => v as u8, + _ => 0, + }; + } + crate::mobs::Species::Goat => { + m.extra.screaming = flag(tag, "IsScreamingGoat"); + let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0); + m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")]; + } + _ => {} + } if m.species() == crate::mobs::Species::Cat { m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner")); m.extra.tame = m.extra.owner.is_some(); m.extra.sitting = flag(tag, "Sitting"); m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15); - if let Some(Tag::String(v)) = tag.get("variant") { - let v = v.to_string().unwrap_or_default(); - let v = v.trim_start_matches("minecraft:"); - m.extra.variant = crate::mobs::tame::CAT_VARIANTS - .iter() - .position(|n| *n == v) - .unwrap_or(0) as u8; - } } if m.species() == crate::mobs::Species::Wolf { let health = m.health; @@ -782,14 +1103,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - m.extra.sitting = flag(tag, "Sitting"); m.extra.color = number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15); - if let Some(Tag::String(v)) = tag.get("variant") { - let v = v.to_string().unwrap_or_default(); - let v = v.trim_start_matches("minecraft:"); - m.extra.variant = crate::mobs::tame::WOLF_VARIANTS - .iter() - .position(|(n, _)| *n == v) - .unwrap_or(0) as u8; - } } if matches!( m.species(), @@ -808,6 +1121,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - if m.species() == crate::mobs::Species::Axolotl { restore_axolotl_memories(&mut m, tag); } + read_variant(&mut m, tag); m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32; m.air = number(tag.get("Air")).unwrap_or(300) as i32; if m.species() == crate::mobs::Species::Allay { @@ -847,6 +1161,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - { crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]); } + if m.species() == crate::mobs::Species::Turtle { + // Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`. + m.extra.flag = flag(tag, "has_egg"); + m.extra.home = Some(match tag.get("home_pos") { + Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]], + _ => [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ], + }); + } if m.species() == crate::mobs::Species::Wither { m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32; } @@ -876,6 +1202,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - st.vibration = crate::sculk::Data::from_nbt(listener); } } + // Armadillo.readAdditionalSaveData: keeps the constructor's time when absent. + if m.species() == crate::mobs::Species::Armadillo + && let Some(t) = number(tag.get("scute_time")) + { + m.extra.egg_time = t as i32; + } if m.species() == crate::mobs::Species::Ravager { m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32; m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32; @@ -925,6 +1257,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap>, id: u32) - if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") { crate::mobs::finalize::set_baby(&mut m, true); } + // Zombie.readAdditionalSaveData: setCanBreakDoors. + if crate::mobs::doors::is_zombie(m.species()) { + crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors")); + } crate::mobs::raider::read_nbt(&mut m, tag); crate::mobs::riders::read_nbt(&mut m, tag); // A mob saved mid-death finishes dying; one saved dead is dropped. @@ -1043,18 +1379,18 @@ pub fn orb_to_nbt(o: &ExperienceOrb, uuid: [i32; 4]) -> Tag { ("Health", Tag::Short(5)), ("Age", Tag::Short(o.age.min(i16::MAX as u32) as i16)), ("Value", Tag::Short(o.value.min(i16::MAX as u32) as i16)), - ("Count", Tag::Int(1)), + ("Count", Tag::Int(o.count.min(i32::MAX as u32) as i32)), ] { put(&mut tag, key, value); } tag } -/// One Horizon orb per counted orb (vanilla merges orbs of equal value). +/// One orb with its merged `Count`. pub fn orbs_from_nbt(tag: &Tag) -> Vec { let Some(position) = position(tag) else { return Vec::new(); }; - let count = number(tag.get("Count")).unwrap_or(1).clamp(1, 64) as usize; + let count = number(tag.get("Count")).unwrap_or(1).max(1) as u32; let orb = ExperienceOrb { id: 0, position, @@ -1063,8 +1399,10 @@ pub fn orbs_from_nbt(tag: &Tag) -> Vec { value: number(tag.get("Value")).unwrap_or(1).max(1) as u32, age: number(tag.get("Age")).unwrap_or(0).max(0) as u32, on_ground: flag(tag, "OnGround"), + count, + ticks: 0, }; - vec![orb; count] + vec![orb] } /// `Projectile` + `AbstractArrow.addAdditionalSaveData` of a live arrow; @@ -1913,7 +2251,7 @@ mod tests { assert_eq!(number(tag.get("Health")), Some(5)); } #[test] - fn orbs_expand_their_count() { + fn orbs_keep_their_count() { let o = ExperienceOrb { id: 0, position: Vec3::new(1.0, 2.0, 3.0), @@ -1922,12 +2260,14 @@ mod tests { value: 7, age: 3, on_ground: false, + count: 3, + ticks: 0, }; - let mut tag = orb_to_nbt(&o, [0; 4]); - put(&mut tag, "Count", Tag::Int(3)); + let tag = orb_to_nbt(&o, [0; 4]); let back = orbs_from_nbt(&tag); - assert_eq!(back.len(), 3); + assert_eq!(back.len(), 1); assert_eq!(back[0].value, 7); + assert_eq!(back[0].count, 3); } } @@ -1969,6 +2309,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) { } } } +/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and +/// `TropicalFish` read; a missing tag is the default variant (a medium +/// salmon, otherwise index 0). +fn read_variant(m: &mut Mob, tag: &Tag) { + use crate::mobs::Species as S; + let int = |key| number(tag.get(key)).unwrap_or(0) as i32; + match m.species() { + S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, + // Rabbit.Variant.LEGACY_CODEC: an unknown id is brown. + S::Rabbit => { + m.extra.variant = match int("RabbitType") { + v @ (0..=5 | 99) => v as u8, + _ => 0, + } + } + S::Salmon => { + m.extra.variant = text(tag.get("type")) + .and_then(|t| SALMON_SIZES.iter().position(|v| *v == t)) + .unwrap_or(1) as u8; + } + S::TropicalFish => { + let packed = int("Variant"); + let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF); + // TropicalFish.Pattern.byId: an unknown id is a kob. + m.extra.variant = if size <= 1 && index < 6 { + (size * 6 + index) as u8 + } else { + 0 + }; + m.extra.variant2 = ((packed >> 16) & 15) as u8; + m.extra.variant3 = ((packed >> 24) & 15) as u8; + } + _ => {} + } +} fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) { let memories = tag.get("Brain").and_then(|b| b.get("memories")); let ttl = memories @@ -1992,12 +2367,39 @@ fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) { } } const AXOLOTL_VARIANTS: [&str; 5] = ["lucy", "wild", "gold", "cyan", "blue"]; +/// `Salmon.Variant` names, by id. +const SALMON_SIZES: [&str; 3] = ["small", "medium", "large"]; +/// `TropicalFish.Pattern` names in enum order: the six small patterns, then +/// the six large ones (the order of `extra.variant`). +const TROPICAL_PATTERNS: [&str; 12] = [ + "kob", + "sunstreak", + "snooper", + "dasher", + "brinely", + "spotty", + "flopper", + "stripey", + "glitter", + "blockfish", + "betty", + "clayfish", +]; +/// `Bucketable.saveDefaultDataToBucketTag`, with the age an axolotl and a +/// tadpole add (`saveToBucketTag`). pub fn bucket_data(m: &Mob) -> Tag { - let mut data = Tag::compound([ - ("Age", Tag::Int(m.extra.age)), - ("AgeLocked", Tag::Byte(i8::from(m.extra.age_locked))), - ("Health", Tag::Float(m.health.to_bits())), - ]); + let mut data = Tag::compound([("Health", Tag::Float(m.health.to_bits()))]); + if matches!( + m.species(), + crate::mobs::Species::Axolotl | crate::mobs::Species::Tadpole + ) { + put(&mut data, "Age", Tag::Int(m.extra.age)); + put( + &mut data, + "AgeLocked", + Tag::Byte(i8::from(m.extra.age_locked)), + ); + } for (key, on) in [ ("NoAI", m.no_ai), ("Silent", m.silent), @@ -2027,6 +2429,29 @@ pub fn bucket_components(m: &Mob) -> crate::item_data::StackData { .unwrap_or(&"lucy"); data.set_other("minecraft:axolotl/variant", Some(string(variant))); } + // Salmon.saveToBucketTag: the size. + if m.species() == crate::mobs::Species::Salmon { + let size = SALMON_SIZES + .get(usize::from(m.extra.variant)) + .unwrap_or(&"medium"); + data.set_other("minecraft:salmon/size", Some(string(size))); + } + // TropicalFish.saveToBucketTag: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let dye = |i: u8| crate::mobs::render::DYE_NAMES[usize::from(i) & 15]; + let pattern = TROPICAL_PATTERNS + .get(usize::from(m.extra.variant)) + .unwrap_or(&"kob"); + data.set_other("minecraft:tropical_fish/pattern", Some(string(pattern))); + data.set_other( + "minecraft:tropical_fish/base_color", + Some(string(dye(m.extra.variant2))), + ); + data.set_other( + "minecraft:tropical_fish/pattern_color", + Some(string(dye(m.extra.variant3))), + ); + } data } pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { @@ -2077,6 +2502,39 @@ pub fn load_bucket_data(m: &mut Mob, stack: &crate::items::ItemStack) { { m.extra.variant = id as u8; } + // Salmon.applyImplicitComponents: the size. + if m.species() == crate::mobs::Species::Salmon + && let Some(name) = text(components.other("minecraft:salmon/size")) + && let Some(id) = SALMON_SIZES + .iter() + .position(|&v| v == name.trim_start_matches("minecraft:")) + { + m.extra.variant = id as u8; + } + // TropicalFish.applyImplicitComponent: the pattern and both colours. + if m.species() == crate::mobs::Species::TropicalFish { + let name = |key| text(components.other(key)); + let dye = |key| { + name(key).and_then(|n| { + crate::mobs::render::DYE_NAMES + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) + }; + if let Some(id) = name("minecraft:tropical_fish/pattern").and_then(|n| { + TROPICAL_PATTERNS + .iter() + .position(|&v| v == n.trim_start_matches("minecraft:")) + }) { + m.extra.variant = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/base_color") { + m.extra.variant2 = id as u8; + } + if let Some(id) = dye("minecraft:tropical_fish/pattern_color") { + m.extra.variant3 = id as u8; + } + } } #[cfg(test)] @@ -2287,6 +2745,123 @@ mod sheep_nbt_tests { let back = mob_from_nbt(&tag, &types, 2).unwrap(); assert_eq!((back.extra.color, back.extra.sheared), (14, true)); } + + #[test] + fn farm_animals_keep_their_variant_and_sound_variant() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() { + return; + } + let data = crate::mobs::MobTypes::load(&root).unwrap(); + let types: HashMap> = data + .types + .iter() + .map(|(k, v)| (k.clone(), Arc::new(v.clone()))) + .collect(); + let mut m = Mob::new( + 1, + types["minecraft:pig"].clone(), + Vec3::new(0.0, 4.0, 0.0), + 0.0, + ); + let tag = mob_to_nbt(&m, [1, 2, 3, 4], None); + assert_eq!( + text(tag.get("variant")).as_deref(), + Some("minecraft:temperate") + ); + assert_eq!( + text(tag.get("sound_variant")).as_deref(), + Some("minecraft:classic") + ); + // Spawned in a warm biome with the mini sounds. + (m.extra.variant, m.extra.sound_variant) = (1, Some(2)); + let tag = mob_to_nbt(&m, [1, 2, 3, 4], None); + assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm")); + assert_eq!( + text(tag.get("sound_variant")).as_deref(), + Some("minecraft:mini") + ); + let back = mob_from_nbt(&tag, &types, 2).unwrap(); + assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2))); + let mut hen = Mob::new( + 3, + types["minecraft:chicken"].clone(), + Vec3::new(0.0, 4.0, 0.0), + 0.0, + ); + (hen.extra.variant, hen.extra.egg_time) = (2, 7795); + let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None); + assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold")); + let back = mob_from_nbt(&tag, &types, 4).unwrap(); + assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795)); + // The sound variants are indices into the registries finalizeSpawn draws from. + use crate::mobs::{Species, finalize}; + let registries = finalize::SpawnRegistries::load(&root); + for (species, registry) in [ + (Species::Pig, ®istries.pig_sound), + (Species::Cow, ®istries.cow_sound), + (Species::Chicken, ®istries.chicken_sound), + (Species::Cat, ®istries.cat_sound), + (Species::Wolf, ®istries.wolf_sound), + ] { + assert_eq!( + finalize::sound_variants(species), + registry.names, + "{species:?}" + ); + } + } + + #[test] + fn foxes_rabbits_and_goats_keep_their_kind() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() { + return; + } + let data = crate::mobs::MobTypes::load(&root).unwrap(); + let types: HashMap> = data + .types + .iter() + .map(|(k, v)| (k.clone(), Arc::new(v.clone()))) + .collect(); + let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0); + let round_trip = |m: &Mob| { + let tag = mob_to_nbt(m, [1, 2, 3, 4], None); + (mob_from_nbt(&tag, &types, 2).unwrap(), tag) + }; + let mut fox = new("minecraft:fox"); + fox.extra.variant = 1; + let (back, tag) = round_trip(&fox); + assert_eq!(text(tag.get("Type")).as_deref(), Some("snow")); + assert_eq!(number(tag.get("Sleeping")), Some(0)); + assert_eq!(back.extra.variant, 1); + let mut rabbit = new("minecraft:rabbit"); + rabbit.extra.variant = 4; + let (back, tag) = round_trip(&rabbit); + assert_eq!(number(tag.get("RabbitType")), Some(4)); + assert_eq!(back.extra.variant, 4); + let mut goat = new("minecraft:goat"); + (goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]); + let (back, tag) = round_trip(&goat); + assert_eq!(number(tag.get("HasRightHorn")), Some(0)); + assert_eq!( + (back.extra.screaming, back.extra.lost_horns), + (true, [false, true]) + ); + // A goat saved without horn flags has both, as vanilla reads it. + let bare = Tag::compound([ + ("id", string("minecraft:goat")), + ("Pos", doubles(&[0.5, 64.0, 0.5])), + ]); + assert_eq!( + mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns, + [false; 2] + ); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/falling_block.rs b/crates/horizon-client/src/falling_block.rs index 79ca28f7a..4faf105b9 100644 --- a/crates/horizon-client/src/falling_block.rs +++ b/crates/horizon-client/src/falling_block.rs @@ -35,6 +35,18 @@ impl Damage { ..Self::default() } } + /// A block that starts to fall: anvils hurt, and a brushable block + /// breaks where it lands (BrushableBlock.tick: disableDrop). + pub fn falling(block: &str) -> Self { + if block.ends_with("anvil") { + Self::anvil() + } else { + Self { + cancel_drop: block.starts_with("minecraft:suspicious_"), + ..Self::default() + } + } + } pub fn from_nbt(tag: &Tag, anvil: bool) -> Self { Self { hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0), @@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) { #[cfg(test)] mod tests { use super::*; + #[test] + fn only_brushable_blocks_break_where_they_land() { + assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop); + assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop); + assert!(!Damage::falling("minecraft:gravel").cancel_drop); + assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities); + } + #[test] fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() { let damage = Damage::anvil(); diff --git a/crates/horizon-client/src/fire_render.rs b/crates/horizon-client/src/fire_render.rs index c3a9f6a92..98eff6f9d 100644 --- a/crates/horizon-client/src/fire_render.rs +++ b/crates/horizon-client/src/fire_render.rs @@ -48,6 +48,8 @@ pub fn flames( color: 0xffff_ffff, light: 0xf000 | (u32::from(scene_light >> 4) * 16), normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } remaining -= 0.45; diff --git a/crates/horizon-client/src/font.rs b/crates/horizon-client/src/font.rs index d0e8c8499..2bb712612 100644 --- a/crates/horizon-client/src/font.rs +++ b/crates/horizon-client/src/font.rs @@ -889,12 +889,17 @@ impl Font { if s.contains('§') { return self.width_text(&Text::legacy(s)); } - s.chars().map(|c| self.glyph(c).advance).sum::().ceil() + crate::bidi::visual_str(s) + .chars() + .map(|c| self.glyph(c).advance) + .sum::() + .ceil() } /// Font.width of a component: `§` codes in the spans are formatting, - /// not glyphs. + /// not glyphs. Right-to-left text is measured as it is drawn, shaped. pub fn width_text(&self, t: &Text) -> f32 { - t.resolved() + let t = t.resolved(); + crate::bidi::visual(&t) .chars() .map(|(c, s)| self.advance(c, s)) .sum::() diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8448f0837..2c3eeed39 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -18,6 +18,7 @@ mod boats; mod books; mod bossbars; mod breezes; +mod bundles; mod campfire; mod campfire_douse; mod cartography; @@ -62,9 +63,9 @@ mod frames; mod friends; mod functions; mod game_mode; -mod game_rules_screen; mod gamemode_switcher; mod ghasts; +mod glowing; mod golems; mod growth; mod hand; @@ -102,7 +103,12 @@ mod maps; mod minecarts; mod mob_blocks; mod mob_gear; +mod mod_browser; +#[cfg(feature = "mods")] +mod mod_browser_recipes; mod mod_input; +#[cfg(feature = "mods")] +mod mod_recipes; mod mod_render; mod mods; mod mount_menu; @@ -127,6 +133,7 @@ mod persist; mod perspective; mod pickups; mod piglins; +mod piston_push; mod player_equip; mod postprocess; mod potions; @@ -151,6 +158,7 @@ mod scoreboard; mod screens; mod sculk; mod sculk_fixture; +mod server_links; mod server_mods; mod server_packs; mod server_travel; @@ -186,6 +194,7 @@ mod win; mod wind; mod wither; mod world_loading; +mod zombie_reinforcements; mod zombie_villagers; use horizon_client::{ Result, @@ -369,7 +378,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 --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\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 --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\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 --pregen Add Horizon's /pregen command (docs/pregeneration.md)\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); @@ -424,6 +433,16 @@ impl Options { "--width" => o.window_size[0] = value.parse::()?.max(1), "--height" => o.window_size[1] = value.parse::()?.max(1), "--world" => o.world = Some(value), + // Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a + // world that exists opens; else the title screen. --world also creates one. + "--quickPlaySingleplayer" => { + let folder = std::env::current_dir()?.join("saves").join(&value); + if folder.join("level.dat").is_file() { + o.world = Some(value); + } else { + eprintln!("Quick play: there is no world {value} in saves/"); + } + } "--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?), "--benchmark" => { if horizon_client::benchmark::scenes(&value).is_none() { @@ -434,7 +453,7 @@ impl Options { } o.benchmark = Some(value); } - "--server" => o.server = Some(value), + "--server" | "--quickPlayMultiplayer" => o.server = Some(value), "--name" => o.player_name = Some(value), "--session-file" => session_file = Some(value), "--control-socket" => o.control = Some(value.into()), @@ -491,6 +510,12 @@ enum Screen { Friends, /// CommandBlockEditScreen (game/command_blocks.rs). CommandBlock, + /// ServerLinksDialogScreen: a server's links (game/server_links.rs). + ServerLinks, + /// ConfirmLinkScreen for a server link (game/server_links.rs). + ConfirmLink, + /// DialogScreen.WarningScreen: the note about custom screens. + DialogWarning, /// WinScreen: the end poem and the credits (game/win.rs). Win, /// A `ui` mod's own screen, or its settings page from Options > Mods @@ -537,6 +562,8 @@ struct GameLevel<'w> { events: &'w mut Vec, dimension: LevelDimension, darken: i32, + /// The environment of the level's dimension (`SUN_ANGLE`). + environment: &'w Environment, raining: bool, thundering: bool, player_box: Option<([f64; 3], [f64; 3])>, @@ -545,6 +572,10 @@ struct GameLevel<'w> { player_collision: Option, /// Minecart boxes (detector rails). cart_boxes: Vec<([f64; 3], [f64; 3])>, + /// Each minecart's kind and comparator signal, in `cart_boxes` order. + cart_signals: Vec<(minecarts::CartKind, i32)>, + /// Arrow and thrown trident boxes (wooden buttons). + arrow_boxes: Vec<([f64; 3], [f64; 3])>, /// The other entities that keep blocks from being placed into them. blockers: Blockers<'w>, biomes: &'w Biomes, @@ -621,6 +652,37 @@ impl EntityObstructionWorld for BuildingObstruction { } } impl GameLevel<'_> { + /// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the + /// minecart on it, a command block cart first (its success count, which + /// is 0 here), else the first container cart's fullness. + fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 { + if !powered { + return 0; + } + let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)); + let area = Aabb::new( + Vec3::new(x + 0.2, y, z + 0.2), + Vec3::new(x + 0.8, y + 0.8, z + 0.8), + ); + let mut container = None; + for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) { + let cart = Aabb::new( + Vec3::new(lo[0], lo[1], lo[2]), + Vec3::new(hi[0], hi[1], hi[2]), + ); + if !area.intersects(cart) { + continue; + } + match kind { + minecarts::CartKind::CommandBlock => return 0, + minecarts::CartKind::Chest | minecarts::CartKind::Hopper => { + container = container.or(Some(signal)); + } + _ => {} + } + } + container.unwrap_or(0) + } /// Every entity box that blocks building: the players who aren't /// spectators, the minecarts and the `Blockers` (a removed mob or a marker /// armor stand, whose box is empty, doesn't). @@ -681,6 +743,14 @@ impl LevelAccess for GameLevel<'_> { state: BlockStateId, _direction: Direction, ) -> i32 { + if let Some(s) = self.registry.state(state) + && s.name == "minecraft:detector_rail" + { + return self.detector_rail_signal( + pos, + s.properties.get("powered").is_some_and(|v| v == "true"), + ); + } if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) { return signal; } @@ -803,6 +873,11 @@ impl LevelAccess for GameLevel<'_> { fn sky_darken(&self) -> i32 { self.darken } + fn sun_angle(&self, _pos: BlockPos) -> f32 { + self.environment + .sample(self.world.day_time, None, None) + .sun_angle + } fn is_raining(&self) -> bool { self.raining } @@ -871,8 +946,9 @@ impl LevelAccess for GameLevel<'_> { fn is_ticking_chunk(&self, chunk: horizon_core::position::ChunkPos) -> bool { self.tickets.block_ticking(chunk.x, chunk.z) && ticking_chunk(self.world, chunk.x, chunk.z) } - /// Entities that trigger pressure plates, tripwires and detector rails: - /// the players (the host and the LAN guests, mobs) and the minecarts. + /// Entities that trigger pressure plates, tripwires, detector rails and + /// wooden buttons: the players (the host and the LAN guests, mobs) and + /// the minecarts, and for `EntityQuery::Arrows` the arrows. fn count_entities(&self, area: Aabb, query: EntityQuery) -> usize { let hit = |(lo, hi): &([f64; 3], [f64; 3])| { area.intersects(Aabb::new( @@ -881,6 +957,7 @@ impl LevelAccess for GameLevel<'_> { )) }; let carts = || self.cart_boxes.iter().filter(|b| hit(b)).count(); + let arrows = || self.arrow_boxes.iter().filter(|b| hit(b)).count(); let players = || { self.player_box .iter() @@ -890,6 +967,7 @@ impl LevelAccess for GameLevel<'_> { }; match query { EntityQuery::Minecart => carts(), + EntityQuery::Arrows => arrows(), EntityQuery::Mobs => players(), EntityQuery::Everything => players() + carts(), } @@ -1205,6 +1283,8 @@ struct Game<'a> { pending_pack_reload: bool, /// A server's resource packs (game/server_packs.rs). server_packs: server_packs::ServerPackState, + /// The link a chat click asks to open (`ConfirmLinkScreen`). + link_prompt: Option, dialogs: dialogs::DialogState, world_renderer_ready: bool, collision: &'a BlockCollisionRegistry<'a>, @@ -1247,6 +1327,8 @@ struct Game<'a> { inv_clicks: horizon_client::container_screen::Clicks, /// The Mouse Tweaks extra's drag and wheel (`tweaks`). tweaks: tweaks::Tweaks, + /// The wheel and hovered slot of `BundleMouseActions`. + bundle_mouse: bundles::BundleMouse, /// The last inventory click, for the double click that gathers a stack. last_inv_click: Option<(std::time::Instant, usize, bool)>, /// `lastQuickMoved`: what the last Shift click moved, which a Shift @@ -1310,6 +1392,8 @@ struct Game<'a> { /// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only /// while `pack_ids` is set (game/shader_packs.rs). entity_spans: std::cell::RefCell>, + /// The glowing entities' silhouettes this frame (game/glowing.rs). + glowing_vertices: std::cell::RefCell>, /// The shader pack's entity and block entity ids, while one draws entities. pack_ids: Option, started: Instant, @@ -1414,6 +1498,10 @@ struct Game<'a> { titles: titles::Titles, /// `Hud.toolHighlightTimer`: the selected item name. tool_highlight: horizon_client::hud_overlays::ToolHighlight, + /// `Hud.autosaveIndicatorValue`. + saving_indicator: horizon_client::hud_overlays::SavingIndicator, + /// This game's world is autosaving this tick (`MinecraftServer.isSaving`). + autosaving: bool, applied_modifiers: Vec<(String, String)>, experience: Experience, orbs: Vec, @@ -1441,6 +1529,8 @@ struct Game<'a> { /// `ClientAvatarState`: walked distance and view bob. avatar: horizon_client::camera::AvatarBob, flash: u32, + /// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound. + rain_sound_time: i32, /// Bolts that struck here and sky flashes they made, in total (`status`). lightning_seen: [u32; 2], /// The head of the bed while sleeping; the visual timer also survives waking. @@ -1523,10 +1613,19 @@ struct Game<'a> { /// Map textures waiting for the next HUD batch to upload them. map_patches: Vec, text_displays: std::collections::HashMap, + /// The fence knots a server tracks to this client, by entity id, at their + /// block (`LeashFenceKnotEntity`). + remote_knots: HashMap, + /// The entity ids a hosted server gives the knots its mobs hang on, by + /// block, while a lead is tied there. + knot_ids: HashMap<[i32; 3], u32>, text_display_vertices: usize, /// Player skin downloads, and which skin each atlas skin slot holds /// (least recently shown first). skin_downloads: RefCell, + /// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads + /// and mannequins) resolve through the services in the background. + skin_profiles: RefCell, skin_slots: Vec<(String, usize)>, /// Atlas trim slots in use: (sprite name, slot), least recently drawn first. trim_slots: Vec<(String, usize)>, @@ -1640,12 +1739,13 @@ struct Game<'a> { guest_use: Option, /// Level events of this tick for the guests, and the guest acting now. lan_events: Vec<(i32, [i32; 3], i32, Option)>, + lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>, /// Block events (position, b0, b1, state) and entity events (id, event, /// position) for the guests. lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, /// Successful crits (attacking guest, hit entity), queued while the hub is taken. - lan_critical_hits: Vec<(Option, i32)>, + lan_critical_hits: Vec<(Option, i32, bool)>, /// Global level events for every guest (`ServerLevel.globalLevelEvent`). lan_global_events: Vec<(i32, [i32; 3], i32)>, /// `ContainerOpenersCounter`: how many players (the host and the guests) @@ -1663,6 +1763,9 @@ struct Game<'a> { /// The acting guest's cooldowns, as (item, ticks): their packet needs the /// hub's item registry, which the guest's turn does not hold. guest_cooldowns: Vec<(Arc, i32)>, + /// The acting guest's own entity events (`ServerPlayer.connection.send`), + /// sent with their entity id, which the guest's turn does not hold. + guest_entity_events: Vec, /// `broadcastToAdmins` lines a guest's command queued, sent once it /// returns: the line, whether operators get it and whether the console /// logs it. @@ -1697,6 +1800,8 @@ struct Game<'a> { restore_player_state: bool, stats_ui: stats_screen::StatsUi, adv_ui: advancements_screen::AdvUi, + /// The Server Links screens' state (game/server_links.rs). + links_ui: server_links::LinksUi, camera_type: perspective::CameraType, fangs: fangs::FangState, /// The local player as a model (third person, the inventory). @@ -1793,9 +1898,46 @@ impl Game<'_> { fn reach(&self) -> f64 { if self.creative { 5.0 } else { 4.5 } } + /// Outline shapes that depend on more than the block state, as one box + /// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full + /// block while the main hand holds the block's own item + /// (`CollisionContext.isHoldingItem`), and `ShulkerBoxBlock.getShape` + /// grows with the lid (`ShulkerBoxBlockEntity.getBoundingBox`). + fn context_outline(&self, pos: [i32; 3], state: BlockStateId) -> Option<[f64; 6]> { + let name = &self.res.facts().get(state).name; + if matches!(&**name, "minecraft:light" | "minecraft:scaffolding") { + return self + .held() + .is_some_and(|s| *s.item == **name) + .then_some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]); + } + if !name.ends_with("shulker_box") { + return None; + } + let progress = self.be_anims.shulkers.get(&pos).map_or(0.0, |s| s.progress); + let facing = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("facing").cloned()); + let d = facing_offset(facing.as_deref().unwrap_or("up")); + let mut b = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; + for a in 0..3 { + match d[a] { + 1 => b[a + 3] += 0.5 * f64::from(progress), + -1 => b[a] -= 0.5 * f64::from(progress), + _ => {} + } + } + Some(b) + } /// A block's outline boxes in world space (`getShape` plus the random offset). fn outline_boxes(&self, pos: [i32; 3]) -> Vec<[f64; 6]> { let state = self.world.block(pos[0], pos[1], pos[2]); + if let Some(b) = self.context_outline(pos, state) { + return vec![std::array::from_fn(|i| f64::from(pos[i % 3]) + b[i])]; + } let f = self.res.facts().get(state); let o = if f.outline_offset { self.pack @@ -1965,9 +2107,11 @@ impl Game<'_> { /// 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. + /// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping + /// pose. pub(crate) fn sync_player_body(&mut self) { let glider = self.glider_worn(); + let sleeping = self.sleeping.is_some(); let item_use_speed = self .using .and_then(|(slot, _)| { @@ -1995,6 +2139,7 @@ impl Game<'_> { player.item_use_speed = item_use_speed; player.respiration = respiration; player.glider = glider; + let _ = player.set_sleeping(sleeping); player.leather_boots = leather_boots; player.freeze_immune = freeze_immune; player.fluids.fast_lava = self.dim == Dim::Nether; @@ -2090,6 +2235,33 @@ impl Game<'_> { } /// `TerrainParticle` at `pos`: the model's particle sprite and tint source /// 0's `colorAsTerrainParticle` (white when the block has none). + /// `HoneyBlock.showSlideParticles` (entity event 53): five honey block + /// particles at the entity. + fn honey_slide_particles(&mut self, at: [f64; 3]) { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let Some(honey) = self + .pack + .assets + .registry + .default_state("minecraft:honey_block") + else { + return; + }; + let block = [ + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ]; + if let Some((sprite, tint)) = self.particle_sprite(honey, block) { + for _ in 0..5 { + self.particles.add( + &ParticleOptions::block(ParticleType::Block, sprite.clone(), tint), + at, + [0.0; 3], + ); + } + } + } fn particle_sprite(&self, state: BlockStateId, pos: [i32; 3]) -> Option<(String, u32)> { let sprite = Self::block_particle_sprite(&self.pack, state)?; let chunks = &self.world.chunks; @@ -2225,8 +2397,9 @@ impl Game<'_> { ticks: 0, }); } - /// `Block.getDrops`: the block's loot table, drawing from its server random - /// sequence (`LootContext.Builder.create`), else the level random. + /// `Block.getDrops` for a block a player breaks: the block's loot table + /// with the player as `this`, drawing from its server random sequence + /// (`LootContext.Builder.create`), else the level random. fn block_loot_at( &mut self, pos: [i32; 3], @@ -2240,9 +2413,9 @@ impl Game<'_> { let random = self.rand_sequences.get(&sequence, self.level.seed); self.res .items() - .block_drops_with(registry, state, held, &dynamic, random) + .block_drops_by_player(registry, state, held, &dynamic, random) } - None => self.res.items().block_drops_with( + None => self.res.items().block_drops_by_player( registry, state, held, @@ -2286,11 +2459,13 @@ impl Game<'_> { // Player.drop: the drop statistics. self.stats.add("dropped", &thrown.item, thrown.count as i32); self.stats.custom("drop", 1); - self.throw_from_eyes(thrown, eye); + self.throw_from_eyes(thrown, eye, true); } /// `Player.createItemStackToDrop`: an item entity thrown from the eyes - /// along the view direction. - fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3]) { + /// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the + /// drop key) names the player as the thrower; `drop(stack, false)`, for + /// what no longer fits in the inventory, does not. + fn throw_from_eyes(&mut self, thrown: ItemStack, eye: [f64; 3], thrown_from_hand: bool) { let (yaw, pitch) = ( f64::from(self.yaw).to_radians(), f64::from(self.pitch).to_radians(), @@ -2304,11 +2479,18 @@ impl Game<'_> { yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength, ]; self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40); - if self.lan_acting.is_none() { - let uuid = self.level.ensure_player_uuid(); + if thrown_from_hand { + let uuid = self.acting_player_uuid(); self.dropped.last_mut().unwrap().thrower = Some(uuid); } } + /// The UUID of the player acting: the guest whose turn it is, else the host. + pub(super) fn acting_player_uuid(&mut self) -> [i32; 4] { + match self.lan_acting_uuid { + Some(guest) => horizon_client::lan::playerdata::uuid_ints(guest), + None => self.level.ensure_player_uuid(), + } + } fn tick_items(&mut self) { let mut items = std::mem::take(&mut self.dropped); // ServerLevel.tick: only entities in entity-ticking chunks tick. @@ -2345,6 +2527,15 @@ impl Game<'_> { no_physics, &self.res.motion().fluids, ); + // Entity.applyEffectsFromBlocks: bubble columns lift or drag the item. + horizon_client::motion::bubble_column_push( + &self.world, + self.pack.assets.registry, + self.res.facts(), + self.collision, + item.bounds(), + &mut item.velocity, + ); cells.moved(i, item.previous, item.position); // ItemEntity.tick: merge every 2 ticks while moving between // blocks, every 40 otherwise, before the lifetime check. @@ -2453,8 +2644,14 @@ impl Game<'_> { pickups::Taken::Item(Box::new(item.clone())), pickups::Collector::Player, ); + // handleTakeItemEntity: the random pitch of a full pickup. + let pitch = ((self.world.random.next_float() + - self.world.random.next_float()) + * 0.7 + + 1.0) + * 2.0; self.audio - .play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0); + .play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch); } item.stack = rest; } @@ -2511,6 +2708,19 @@ impl Game<'_> { ) }) .collect(); + let cart_signals = self.cart_signals(); + let arrow_boxes = self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + .map(|p| p.position) + .chain(self.tridents.iter().map(|t| t.position)) + .map(|at| { + let b = horizon_client::arrows::arrow_bounds(at); + (b.min_position().components(), b.max_position().components()) + }) + .collect(); let guest_boxes = self.lan_block_trigger_boxes(); let dimension = match self.dim { Dim::Overworld => LevelDimension::Overworld, @@ -2541,6 +2751,8 @@ impl Game<'_> { }; let mut level = GameLevel { cart_boxes, + cart_signals, + arrow_boxes, blockers, world: &mut self.world, facts: self.res.facts(), @@ -2548,6 +2760,11 @@ impl Game<'_> { events: &mut self.level_events, dimension, darken, + environment: match dimension { + LevelDimension::Overworld => self.res.environment(), + LevelDimension::Nether => self.res.nether_environment(), + LevelDimension::End => self.res.end_environment(), + }, raining: rain > 0.2, thundering: thunder > 0.9, player_box, @@ -2589,9 +2806,13 @@ impl Game<'_> { self.process_level_events() } fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> { - self.with_level(|b, level, ticks| { - b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])); + let note = self.with_level(|b, level, ticks| { + b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])) }); + // NoteBlock.attack awards the statistic. + if note { + self.stats.custom("play_noteblock", 1); + } self.process_level_events() } /// Track which chunks are ticking (Ticks needs chunk load/unload). @@ -2674,8 +2895,9 @@ impl Game<'_> { .copied() .filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)) .collect(); - // Entity.checkInsideBlocks for the plates, tripwires and detector rails - // under the players (the host and the LAN guests) and the minecarts. + // Entity.checkInsideBlocks for the plates, tripwires, detector rails and + // buttons under the players (the host and the LAN guests), the minecarts + // and the arrows. let mut inside = Vec::new(); let guests = self.lan_block_trigger_boxes(); let boxes = self @@ -2707,6 +2929,24 @@ impl Game<'_> { } } } + // AbstractArrow.applyEffectsFromBlocks: the blocks every arrow passed + // through this tick (a wooden button is pressed by one inside it), and + // the ones a thrown trident is in. + for p in self + .creatures + .projectiles + .iter() + .filter(|p| p.arrow.is_some()) + { + inside.extend(horizon_client::arrows::blocks_inside( + p.previous, p.position, + )); + } + for t in &self.tridents { + inside.extend(horizon_client::arrows::blocks_inside( + t.position, t.position, + )); + } // BigDripleafBlock.entityInside also asks whether the player stands on // the ground and how high its feet are: the leaves its box's feet // layer is inside (the host and the LAN guests). @@ -2768,20 +3008,29 @@ impl Game<'_> { b.run_block_events(level, ticks); b.tick_block_entities(level, ticks); }); + // `PistonMovingBlockEntity.tick` pushes what its block just ran into, + // with the progress it is about to take. + let pushes = std::mem::take(&mut self.block_ticks.piston_pushes); + self.push_piston_entities(pushes); + self.process_level_events()?; self.sync_moving_pistons(); - self.process_level_events() + Ok(()) } /// Copies the moving-piston block entities into the world, where the /// collision queries and the renderer read them. fn sync_moving_pistons(&mut self) { - let pistons = &mut self.world.moving_pistons; - pistons.clear(); - pistons.extend( - self.block_ticks - .moving_pistons - .iter() - .map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())), - ); + let previous = std::mem::take(&mut self.world.moving_pistons); + let current: Vec<_> = self + .block_ticks + .moving_pistons + .iter() + .map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())) + .collect(); + self.world.moving_piston_fallback = previous + .into_iter() + .filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos)) + .collect(); + self.world.moving_pistons = current; } /// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time: /// the side effects of each (a shrieker summoning a warden, drops, game @@ -2844,6 +3093,16 @@ impl Game<'_> { data, } => self.sculk_charge_event(pos, data), LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos), + // LevelEventHandler 2019 and 2020: the crack on a face of the + // block being mined, with its hit sound for 2020. + LevelEvent::LevelEvent { + id: id @ (2019 | 2020), + pos, + data, + } => { + let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize]; + self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020); + } LevelEvent::LevelEvent { id: 2004, pos, .. } => { self.spawner_burst([pos.x, pos.y, pos.z]) } @@ -2863,6 +3122,11 @@ impl Game<'_> { pos, data, } => self.dragon_egg_particles([pos.x, pos.y, pos.z], data), + LevelEvent::LevelEvent { + id: 2016, + pos, + data, + } => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data), LevelEvent::LevelEvent { id: 2017, pos, @@ -2876,6 +3140,17 @@ impl Game<'_> { pos, data, } => self.smash_dust(pos, data), + // LevelEventHandler 2002 and 2007: a potion's splash. + LevelEvent::LevelEvent { + id: 2002, + pos, + data, + } => self.potion_splash_particles(pos, data, false), + LevelEvent::LevelEvent { + id: 2007, + pos, + data, + } => self.potion_splash_particles(pos, data, true), LevelEvent::LevelEvent { id: id @ 3002..=3005, pos, @@ -2886,6 +3161,34 @@ impl Game<'_> { pos, data, } => self.composter_fill_event(pos, data == 1), + // LevelEventHandler 1504: the stalactite's drip particle. + LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos), + // LevelEventHandler 2006: the dragon fireball's burst. + LevelEvent::LevelEvent { + id: 2006, + pos, + data, + } => self.dragon_fireball_burst(pos, data), + // LevelEventHandler 2009: a wet sponge drying. + LevelEvent::LevelEvent { id: 2009, pos, .. } => { + self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud) + } + // LevelEventHandler 2012: a turtle egg laid. + LevelEvent::LevelEvent { + id: 2012, + pos, + data, + } => self.turtle_egg_particles(pos, data), + // LevelEventHandler 2014: a straw bed destroyed, with no sound. + LevelEvent::LevelEvent { + id: 2014, + pos, + data, + } => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)), + // LevelEventHandler 3009: a sniffer egg cracking. + LevelEvent::LevelEvent { id: 3009, pos, .. } => { + self.sniffer_egg_crack_particles(pos) + } LevelEvent::LevelEvent { id: id @ 3011..=3021, pos, @@ -2901,6 +3204,19 @@ impl Game<'_> { 1501 => Some(("minecraft:block.lava.extinguish", 0.5)), 1009 => Some(("minecraft:block.fire.extinguish", 0.5)), 1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)), + 1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)), + 1034 => Some(("minecraft:block.chorus_flower.death", 1.0)), + 1046 => Some(( + "minecraft:block.pointed_dripstone.drip_lava_into_cauldron", + 2.0, + )), + 1047 => Some(( + "minecraft:block.pointed_dripstone.drip_water_into_cauldron", + 2.0, + )), + // LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break). + 1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)), + 1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)), 1030 => Some(("minecraft:block.anvil.use", 1.0)), 1029 => Some(("minecraft:block.anvil.destroy", 1.0)), 1031 => Some(("minecraft:block.anvil.land", 0.3)), @@ -2908,6 +3224,8 @@ impl Game<'_> { 1044 => Some(("minecraft:block.smithing_table.use", 1.0)), // LevelEvent.SOUND_PAGE_TURN (a lectern's page). 1043 => Some(("minecraft:item.book.page_turn", 1.0)), + // LevelEvent.SOUND_SPLASH_POTION and its instant twin. + 1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)), _ => None, }; if let Some((name, volume)) = sound { @@ -2917,12 +3235,24 @@ impl Game<'_> { - self.world.random.next_float()) * 0.8 } - 1029 | 1030 | 1042 | 1043 | 1044 => { + // LevelEventHandler draws these from the client + // level's random, before 1501's and 1502's smoke. + 1501 | 1502 => { + let r = self.particles.level_random(); + 2.6 + (r.next_float() - r.next_float()) * 0.8 + } + 1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9, + 1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => { self.world.random.next_float() * 0.1 + 0.9 } + 1019 | 1021 => { + (self.world.random.next_float() - self.world.random.next_float()) + * 0.2 + + 1.0 + } _ => 1.0, }; - if matches!(id, 1029 | 1030) { + if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) { // Clients play the anvil sound from its level event; // capturing it also sends a duplicate sound packet. self.audio.play_unshared(name, c, volume, pitch); @@ -2930,6 +3260,15 @@ impl Game<'_> { self.audio.play(name, c, volume, pitch); } } + // LevelEventHandler 1501 and 1502 draw their smoke after the sound. + match id { + 1501 => self.puffs_above_block( + pos, + horizon_client::particles::ParticleType::LargeSmoke, + ), + 1502 => self.torch_burnout_smoke(pos), + _ => {} + } } LevelEvent::Sound { pos, @@ -2971,11 +3310,9 @@ impl Game<'_> { drop_item: true, fall_distance: 0.0, stuck: Vec3::ZERO, - damage: if self.res.facts().get(state).name.ends_with("anvil") { - horizon_client::falling_block::Damage::anvil() - } else { - Default::default() - }, + damage: horizon_client::falling_block::Damage::falling( + &self.res.facts().get(state).name, + ), saved: None, }); } @@ -2998,6 +3335,8 @@ impl Game<'_> { // Popped as the behaviour emits it (GameLevel::emit). PlayerEffect::DropItem(_) => {} PlayerEffect::EatCake => { + // CakeBlock.eat + self.stats.custom("eat_cake_slice", 1); if let Some(p) = self.player.as_mut() { let food = &mut p.health.food; food.food = (food.food + 2).min(20); @@ -3052,6 +3391,11 @@ impl Game<'_> { kind: "hatch_sniffer", .. } => self.hatch_sniffer([pos.x, pos.y, pos.z]), + LevelEvent::BlockEvent { + pos, + state, + kind: "hatch_turtles", + } => self.hatch_turtles([pos.x, pos.y, pos.z], state), LevelEvent::BlockEvent { pos, kind: "portal_spawn", @@ -3073,6 +3417,31 @@ impl Game<'_> { LevelEvent::BlockEntityEvent { pos, a, b, .. } => { self.block_entity_event([pos.x, pos.y, pos.z], a, b) } + // NoteBlock.triggerEvent: a NOTE particle above, coloured by + // the note (`note / 24.0` in the x speed). + LevelEvent::BlockEvent { + pos, + state, + kind: "note_particle", + } => { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let note = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("note")?.parse::().ok()) + .unwrap_or(0.0); + self.particles.add( + &ParticleOptions::simple(ParticleType::Note), + [ + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 1.2, + f64::from(pos.z) + 0.5, + ], + [note / 24.0, 0.0, 0.0], + ); + } LevelEvent::GrowTree { .. } | LevelEvent::PlaceFeature { .. } | LevelEvent::BlockEvent { .. } => {} @@ -3089,6 +3458,10 @@ impl Game<'_> { .name .clone(); let key = (pos[0], pos[1], pos[2]); + if self.chest_menu_blocked(key, &name) { + // The use still succeeds, but no menu, stat or piglin anger. + return; + } if &*name == "minecraft:lectern" { self.open_lectern(pos); return; @@ -3097,20 +3470,21 @@ impl Game<'_> { self.open_command_block(key); return; } + // SignBlock.useWithoutItem: the text editor, not a container. + if horizon_client::signs::sign_kind(&name).is_some() { + self.use_sign_empty_hand(pos); + return; + } if &*name == "minecraft:crafting_table" { self.craft_size = 3; self.craft_table = Some(pos); } else if &*name == "minecraft:ender_chest" { self.open_ender_chest(key); - self.container_opener_event(key, true); - let at = [ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]; - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio - .play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch); + // EnderChestBlock.useWithoutItem + self.stats.custom("open_enderchest", 1); + if self.container_opener_event(key, true) { + self.container_edge_sound(key, true); + } } else if &*name == "minecraft:beacon" && self.open_station(pos, &name) { self.world.block_entities.get_or_insert_with(key, || { BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new())) @@ -3126,7 +3500,7 @@ impl Game<'_> { ]; self.resolve_container_maps(key, centre); self.container = Some(key); - self.container_opener_event(key, true); + let first = self.container_opener_event(key, true); self.post_block_game_event( "minecraft:container_open", [key.0, key.1, key.2], @@ -3150,26 +3524,8 @@ impl Game<'_> { if self.guarded_by_piglins(&name) { self.anger_piglins(true); } - let sound = if storage::is_shulker_box(&name) { - Some("minecraft:block.shulker_box.open") - } else if name.contains("chest") { - Some("minecraft:block.chest.open") - } else if &*name == "minecraft:barrel" { - Some("minecraft:block.barrel.open") - } else { - None - }; - if let Some(sound) = sound { - self.audio.play( - sound, - Some([ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]), - 0.5, - 1.0, - ); + if first { + self.container_edge_sound(key, true); } } else if self.open_station(pos, &name) { // Workstation menus: game/stations.rs. @@ -3444,7 +3800,7 @@ impl Game<'_> { } else if let Some(tag) = spill::saved_entity(&self.world, pos) { self.pending.record_saved(pos, &tag); } - self.creaking_heart_broken(pos); + self.creaking_heart_broken(pos, true); let hive = match self.world.block_entities.get(&key) { Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)), _ => None, @@ -3493,6 +3849,21 @@ impl Game<'_> { { self.hive_broken_by_player(pos, hive, honey, facing); } + // InfestedBlock.spawnAfterBreak: its silverfish comes out unless the + // tool has silk touch (#prevents_infested_spawns) or blocks don't drop. + if facts.name.starts_with("minecraft:infested_") + && !silk + && self.ui.rules.bool("block_drops") + { + let at = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]), + f64::from(pos[2]) + 0.5, + ]; + if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) { + self.mob_poof(id); + } + } // SpawnerBlock.spawnAfterBreak pops its experience whatever the tool. let xp = if silk && &*facts.name != "minecraft:spawner" { 0 @@ -3614,6 +3985,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_sign_applicator(hit.pos, held.as_ref()) { + self.swing_arm(); + return Ok(true); + } if self.use_jukebox(hit.pos, held.as_ref()) { self.swing_arm(); return Ok(true); @@ -3634,6 +4009,10 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.use_beehive(hit.pos, held.as_ref())? { + self.swing_arm(); + return Ok(true); + } if self.use_campfire_douse(hit.pos, held.as_ref())? { self.swing_arm(); return Ok(true); @@ -3673,6 +4052,20 @@ impl Game<'_> { ) }); self.process_level_events()?; + // NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung). + if result == horizon_world::level::InteractionResult::Success { + let name = self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])) + .name + .clone(); + match &*name { + "minecraft:note_block" => self.stats.custom("tune_noteblock", 1), + "minecraft:bell" => self.stats.custom("bell_ring", 1), + _ => {} + } + } if result.consumes_action() { self.swing_arm(); return Ok(true); @@ -3976,6 +4369,8 @@ impl Game<'_> { self.release_used_item(&stack, ticks, hand_changed); return; } + // ServerPlayer.updateUsingItem: USING_ITEM every tick of the use. + self.trigger_using_item(&stack); let Some(info) = self.res.items().get(&stack.item).cloned() else { self.using = None; return; @@ -4003,6 +4398,7 @@ impl Game<'_> { if &*stack.item == "minecraft:spyglass" { // SpyglassItem.USE_DURATION, then finishUsingItem. if ticks + 1 >= 1200 { + self.send_use_item_complete(); self.stop_spyglass(); self.using = None; } else { @@ -4024,6 +4420,7 @@ impl Game<'_> { } if ticks >= duration { self.using = None; + self.send_use_item_complete(); // ItemStack.finishUsingItem's applyAfterUseComponentSideEffects. self.apply_use_cooldown(&stack); self.finish_consuming(&info, consumable, &stack); @@ -4042,6 +4439,17 @@ impl Game<'_> { _ => {} } } + /// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every + /// fourth tick since the START (the hit sound), else 2019 (the crack only), + /// for everyone around, the digger included (`levelEvent(null, ...)`). + fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) { + let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 }; + let direction = horizon_core::geometry::Direction::ALL + .iter() + .position(|d| d.normal() == face) + .unwrap_or(1); + self.level_event_at(id, pos, direction as i32); + } fn tick_mining(&mut self) -> Result<()> { if self.destroy_delay > 0 { self.destroy_delay -= 1; @@ -4085,6 +4493,11 @@ impl Game<'_> { let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); let facts = self.res.facts().get(state); if facts.hardness < 0.0 { + // The server still counts its destroy ticks on an unbreakable block. + let m = self.mining.as_mut().ok_or("mining")?; + m.ticks += 1; + let (pos, ticks) = (m.pos, m.ticks); + self.destroy_progress_event(pos, hit.face, ticks); return Ok(()); } // Player.getDestroySpeed / BlockBehaviour.getDestroyProgress. @@ -4115,26 +4528,15 @@ impl Game<'_> { } else { speed / facts.hardness / if harvest { 30.0 } else { 100.0 } }; - let hit_sound = facts.sounds.clone(); self.swing_arm(); let m = self.mining.as_mut().ok_or("mining")?; m.progress += progress; m.ticks += 1; - let crack = (m.pos, hit.face); - if m.ticks % 4 == 1 { - let c = [ - f64::from(m.pos[0]) + 0.5, - f64::from(m.pos[1]) + 0.5, - f64::from(m.pos[2]) + 0.5, - ]; - self.audio.play( - &hit_sound.hit, - Some(c), - (hit_sound.volume + 1.0) / 8.0, - hit_sound.pitch * 0.5, - ); + // An instant break at START sends no destroy events. + if m.progress < 1.0 || m.ticks > 1 { + let (pos, ticks) = (m.pos, m.ticks); + self.destroy_progress_event(pos, hit.face, ticks); } - self.crack_particle(crack.0, crack.1, state); let m = self.mining.as_mut().ok_or("mining")?; // 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 @@ -4236,6 +4638,12 @@ impl Game<'_> { // The world's spawn from the start, not only once saved: a bed set // before the first save must not become it. self.level.world_spawn.get_or_insert([x, y, z]); + // The "Bonus Chest" create-world option, placed once with the spawn. + if horizon_client::save::WorldGen::read(&self.world_root) + .is_some_and(|g| g.bonus_chest) + { + self.place_bonus_chest([x, y, z])?; + } } let s = self .level @@ -4339,7 +4747,11 @@ impl Game<'_> { self.world.weather.tick_levels(); self.tick_weather_effects(); } - if self.ticks.is_multiple_of(6000) { + self.autosaving = self.ticks.is_multiple_of(6000); + if let Some(serving) = &self.serving { + serving.set_saving(self.autosaving); + } + if self.autosaving { self.save_game_with(false)?; } if running { @@ -4435,12 +4847,6 @@ impl Game<'_> { let events = std::mem::take(&mut player.events); let spin_hit = player.spin_hit.take(); let glider_wear = std::mem::take(&mut player.glider_wear); - if glider_wear > 0 - && !self.creative - && let Some(elytra) = self.armor[1].as_mut() - { - elytra.damage += glider_wear; - } let feet = [player.position.x, player.position.y, player.position.z]; let player_velocity = [player.velocity.x, player.velocity.y, player.velocity.z]; self.previous_eye = self.eye; @@ -4450,19 +4856,47 @@ impl Game<'_> { player.dimensions.eye_height }; self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5; - let crouching = player.pose == horizon_client::pose::Pose::Crouching; + // `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the + // glide's wear): both draw from the level random. + if let Some((at, velocity)) = self.tick_soul_speed() { + self.particles.send_particles( + &horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Soul, + ), + at, + velocity, + 1.0, + 0, + ); + if let Some(host) = self.lan.as_ref() { + lan_particles::send_soul_particle(host, at, velocity); + } + } + // `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear. + if glider_wear > 0 + && !self.creative + && let Some(stack) = self.armor[1].clone() + { + let loss = self.durability_loss(&stack, glider_wear); + if let Some(elytra) = self.armor[1].as_mut() { + elytra.damage += loss; + } + } self.tick_avatar(before, riding); self.end_spin_attack(spin_hit); self.record_combat(&events); + self.wear_armor(&events); for event in events { match event { MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet), MotionEvent::Emission { state, in_water, .. - } if !crouching => { + } => { let sounds = self.res.facts().get(state).sounds.clone(); // Entity.playStepSound: the block's step sound at 0.15 volume, - // muffled under the swim sound in water. + // muffled under the swim sound in water. Whether it plays is + // Player.getMovementEmission's call (shift on the ground), + // not the pose: a forced crouch still steps. let (volume, pitch) = if in_water { (sounds.volume * 0.05, sounds.pitch * 0.8) } else { @@ -4474,26 +4908,21 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.swim", Some(feet), volume, pitch); } + MotionEvent::Chime { volume, pitch } => { + self.audio.play( + "minecraft:block.amethyst_block.chime", + Some(feet), + volume, + pitch, + ); + } MotionEvent::Drown => { - // LivingEntity.makeDrownParticles: eight bubbles moving - // with the player. - use horizon_client::particles::{ParticleOptions, ParticleType}; - for _ in 0..8 { - let mut triangle = || { - let r = self.particles.random(); - r.next_double() - r.next_double() - }; - let p = [ - feet[0] + triangle(), - feet[1] + triangle(), - feet[2] + triangle(), - ]; - self.particles.add( - &ParticleOptions::simple(ParticleType::Bubble), - p, - player_velocity, - ); + // The guests see the entity event 67 too. + if let Some(host) = &self.lan { + host.hub + .entity_event(host.host_entity, combat::DROWN_EVENT, feet); } + self.drown_particles(feet, player_velocity); } MotionEvent::BubbleColumnParticle { bubble, position } => { // ServerLevel.sendParticles: SPLASH (speed 1) or BUBBLE @@ -4529,9 +4958,36 @@ impl Game<'_> { }; self.audio.play(name, Some(feet), volume, pitch); } + // Player.causeFallDamage: a fall of 2 blocks or more counts. + MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => { + self.stats.custom("fall_one_cm", cm) + } + // LivingEntity.checkFallDamage: the block's crumbs where the fall landed. + MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { + state, + position, + count, + speed, + }) => self.landing_dust(None, state, position, count, speed), MotionEvent::Fall(event) => self.landing_block_updates(&event), MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), + MotionEvent::HoneySlide { + sound, particles, .. + } => { + if sound { + self.audio + .play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0); + } + if particles { + self.honey_slide_particles(feet); + // The server's event 53 reaches the guests too. + let id = self.lan.as_ref().map(|h| h.host_entity); + if let Some(id) = id { + self.lan_entity_event(id, 53, feet); + } + } + } MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos), MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos), MotionEvent::InsideWitherRose => self.wither_rose_contact(), @@ -4573,6 +5029,7 @@ impl Game<'_> { } } self.powder_snow_fire()?; + self.rain_extinguish_in_frame(); self.tick_totem(); let Some(player) = self.player.as_mut() else { return Ok(()); @@ -4614,9 +5071,14 @@ impl Game<'_> { if let Some(host) = &self.lan { host.hub.entity_event(host.host_entity, 3, feet); } - self.screen = Screen::Dead; - self.capture(false); - self.ui.death_time = 0; + if self.ui.rules.bool("immediate_respawn") { + // Gui.setScreen: without the death screen the player respawns at once. + self.respawn_player()?; + } else { + self.screen = Screen::Dead; + self.capture(false); + self.ui.death_time = 0; + } } else if dead { // LivingEntity.tickDeath: 20 ticks after dying the player is // removed, and Player.remove's inventoryMenu.removed drops what @@ -4626,7 +5088,7 @@ impl Game<'_> { && let Some(stack) = self.carried.take() && let Some(eye) = self.eye_position() { - self.throw_from_eyes(stack, eye); + self.throw_from_eyes(stack, eye, false); } } self.tick_world(running) @@ -4634,6 +5096,7 @@ impl Game<'_> { /// What the tick runs after the local player's own update: effects, the /// world's entities and blocks, and the network hub. fn tick_world(&mut self, running: bool) -> Result<()> { + self.share_spawn_conditions(); self.update_chunk_tickets(); horizon_client::spans::time("tick_effects", || self.tick_effects(running)); // Player.tick's ItemCooldowns.tick for the window's player (a @@ -4641,6 +5104,7 @@ impl Game<'_> { self.shield_cooldown = self.shield_cooldown.saturating_sub(1); self.cooldowns.tick(); horizon_client::spans::time("tick_using", || self.tick_using()); + self.tick_bundle_hover(); horizon_client::spans::time("tick_hud", || self.tick_hud()); // The player's digging comes in with its packets, before the level // ticks; ServerLevel.tick then runs the scheduled and random block @@ -4843,6 +5307,9 @@ impl Game<'_> { | Screen::Advancements | Screen::WorldOptions | Screen::Friends + | Screen::ServerLinks + | Screen::ConfirmLink + | Screen::DialogWarning | Screen::CommandBlock ) || self.book.is_some() || self.win_screen_pauses()) @@ -4864,10 +5331,13 @@ impl Game<'_> { if capture && !self.focused { return; } - if capture { - // The inventory's screens are behind us: no drag survives them. - self.reset_inventory_clicks(); - } else { + // The container screen's press state belongs to the screen: a screen + // that takes the pointer over starts with its own state, as + // `AbstractContainerScreen` does, so a press whose release never + // arrived (the pointer left the window) cannot wedge the next + // screen's quick move and clicks. + self.reset_inventory_clicks(); + if !capture { self.stop_mining(); } // The real pointer takes over from an agent's. @@ -4946,7 +5416,7 @@ impl Game<'_> { self.capture(true); } } - fn draw_item(&mut self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { + fn draw_item(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) { // `GuiGraphics.renderItem`: the resolved item model (tints, foil and // special renderers included), then the decorations. self.draw_item_model(batch, stack, x, y); @@ -5036,7 +5506,7 @@ impl Game<'_> { /// `GuiGraphicsExtractor.itemDecorations`: the count (or `count_text`) /// and damage bar. fn draw_item_decorations( - &mut self, + &self, batch: &mut HudBatch, stack: &ItemStack, x: f32, @@ -5080,6 +5550,7 @@ impl Game<'_> { }; self.draw_dialog(&mut batch); self.draw_pack_prompt(&mut batch); + self.draw_link_prompt(&mut batch); // Gui.extractRenderState: the debug overlay comes after the screen // and the toasts, so it is over everything, menus included. self.draw_debug_overlay(&mut batch, size); @@ -5141,7 +5612,11 @@ impl Game<'_> { let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed()); let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed()); // The 3D crosshair (F3) takes the 2D one's place. - if self.camera_type.first_person() && !spectator && !self.debug_crosshair() { + // Hud.extractCrosshair: a spectator only sees it over something to open. + if self.camera_type.first_person() + && (!spectator || self.spectator_crosshair()) + && !self.debug_crosshair() + { let attack = (indicator == 1).then_some(strength); let full = attack.is_some_and(|a| { hud_layout::full_attack_indicator(a, delay, self.crosshair_living()) @@ -5163,12 +5638,12 @@ impl Game<'_> { for d in &draws { if let hud_layout::GuiDraw::Item { slot, x, y, .. } = *d { let stack = if slot == 40 { - self.offhand.clone() + self.offhand.as_ref() } else { - self.inventory[slot as usize].clone() + self.inventory[slot as usize].as_ref() }; if let Some(stack) = stack { - self.draw_item(&mut batch, &stack, x as f32, y as f32); + self.draw_item(&mut batch, stack, x as f32, y as f32); } } } @@ -5326,9 +5801,13 @@ impl Game<'_> { Screen::Advancements => self.draw_advancements(&mut batch), Screen::WorldOptions => self.draw_world_options(&mut batch), Screen::Friends => self.draw_friends_screen(&mut batch), + Screen::ServerLinks => self.draw_server_links(&mut batch), + Screen::ConfirmLink => self.draw_confirm_link(&mut batch), + Screen::DialogWarning => self.draw_dialog_warning(&mut batch), Screen::CommandBlock => self.draw_command_block(&mut batch), Screen::Inventory => { self.draw_inventory(&mut batch); + self.draw_mod_browser(&mut batch); } Screen::Creative => { self.draw_creative(&mut batch); @@ -5337,6 +5816,7 @@ impl Game<'_> { Screen::Mod => self.draw_mod_screen(&mut batch), Screen::Playing | Screen::Options | Screen::Win => {} } + self.draw_saving_indicator(&mut batch); self.draw_toasts(&mut batch); self.draw_pack_toasts(&mut batch); self.remote_hud(&mut batch); @@ -5393,7 +5873,9 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) + | BlockEntity::EndGateway(_) | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector | BlockEntity::Campfire(_), ) => {} None => { @@ -5664,7 +6146,9 @@ impl Game<'_> { | BlockEntity::Vault(_) | BlockEntity::Beehive(_) | BlockEntity::CommandBlock(_) + | BlockEntity::EndGateway(_) | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector | BlockEntity::Campfire(_), ) => {} None if self.mount_active() => self.draw_mount_background(batch, ox, oy), @@ -5709,16 +6193,19 @@ impl Game<'_> { } } /// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it - /// belongs to on the screen; Horizon keeps them on the game, so they are - /// dropped when a screen closes. A press whose release never reaches the - /// screen (the pointer left the window, or the screen closed first) would + /// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`, + /// `inv_drag`, `last_inv_click`), so they are dropped when the pointer + /// changes hands: a screen that opens or closes starts with its own + /// state, and a press whose release never reached the screen -- the + /// pointer left the window, or the screen closed first -- would /// otherwise leave every later press on every later screen swallowed. pub(super) fn reset_inventory_clicks(&mut self) { - self.inv_clicks = Default::default(); + self.inv_clicks.cancel_drag(); self.inv_drag = None; self.last_inv_click = None; } pub(super) fn close_container(&mut self) { + self.mod_browser_closed(); self.reset_inventory_clicks(); self.close_station(); self.mount_closed(); @@ -5732,7 +6219,8 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // AbstractContainerMenu.dropOrPlaceInInventory: `drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } self.craft_size = 2; @@ -5740,41 +6228,15 @@ impl Game<'_> { if let Some(key) = self.container.take() { // A cart's or chest boat's items go back to it. self.close_cart_container(key); - self.container_opener_event(key, false); + let last = self.container_opener_event(key, false); self.post_block_game_event( "minecraft:container_close", [key.0, key.1, key.2], Some(horizon_client::mobs::Who::Player), None, ); - let name = self - .res - .facts() - .get(self.world.block(key.0, key.1, key.2)) - .name - .clone(); - let sound = if storage::is_shulker_box(&name) { - Some("minecraft:block.shulker_box.close") - } else if &*name == "minecraft:ender_chest" { - Some("minecraft:block.ender_chest.close") - } else if name.contains("chest") { - Some("minecraft:block.chest.close") - } else if &*name == "minecraft:barrel" { - Some("minecraft:block.barrel.close") - } else { - None - }; - if let Some(sound) = sound { - self.audio.play( - sound, - Some([ - f64::from(key.0) + 0.5, - f64::from(key.1) + 0.5, - f64::from(key.2) + 0.5, - ]), - 0.5, - 1.0, - ); + if last { + self.container_edge_sound(key, false); } self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } @@ -5833,6 +6295,12 @@ impl Game<'_> { /// Several clicks on one menu instance (a quick craft drag is three). fn menu_ops(&mut self, ops: &[(Option, i32, horizon_client::menu::Input)]) -> bool { use horizon_client::menu::Input; + // BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection. + for &(slot, _, input) in ops { + if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) { + self.unselect_bundle(slot); + } + } if self.remote.is_some() && self.screen == Screen::Inventory { // On a server the clicks are the server's: predicted and sent. return self.remote_menu_ops(ops); @@ -6288,6 +6756,7 @@ impl Game<'_> { self.player_menu_sound(sound); } let crafted = std::mem::take(&mut menu.crafted); + let brewed = std::mem::take(&mut menu.brewed); let equipped = std::mem::take(&mut menu.equipped); for (i, stack) in after.into_iter().enumerate() { if let Some(g) = game_of[i] { @@ -6303,6 +6772,9 @@ impl Game<'_> { self.player_drop(stack); } self.award_crafted(&crafted); + for stack in &brewed { + self.trigger_brewed_potion(stack); + } if furnace_before > furnace_after && self.remote.is_none() && let (Some(pos), Some(p)) = (self.container, self.player.as_ref()) @@ -6445,6 +6917,9 @@ impl Game<'_> { fn inventory_press(&mut self, right: bool) { use horizon_client::container_screen::Target; use horizon_client::menu::Input; + if self.mod_browser_press(right) { + return; + } if !right && self.sort_press() { return; } @@ -6628,24 +7103,8 @@ impl Game<'_> { } /// `hasClickedOutside` of the open screen at the cursor. fn clicked_outside(&self, batch: &HudBatch) -> bool { - use horizon_client::container_screen::{Book, Image, clicked_outside}; - let (ox, oy) = self.inventory_origin(batch); - let (shift, width, height) = if self.screen == Screen::Creative { - (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) - } else { - let (shift, width) = match self.station.as_ref().map(|s| s.kind) { - Some(stations::Kind::Merchant) => (-50.0, 276), - Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), - _ => (0.0, 176), - }; - (shift, width, self.screen_height() as i32) - }; - let image = Image { - left: (ox + shift) as i32, - top: oy as i32, - width, - height, - }; + use horizon_client::container_screen::{Book, clicked_outside}; + let image = self.container_image(batch); let book = self .recipe_book_area(batch) .map(|(visible, tab_hovered)| Book { @@ -6659,6 +7118,26 @@ impl Game<'_> { book, ) } + /// The open container screen's window: its place and size. + fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image { + let (ox, oy) = self.inventory_origin(batch); + let (shift, width, height) = if self.screen == Screen::Creative { + (0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32) + } else { + let (shift, width) = match self.station.as_ref().map(|s| s.kind) { + Some(stations::Kind::Merchant) => (-50.0, 276), + Some(stations::Kind::Beacon) => (beacon::SHIFT, 230), + _ => (0.0, 176), + }; + (shift, width, self.screen_height() as i32) + }; + horizon_client::container_screen::Image { + left: (ox + shift) as i32, + top: oy as i32, + width, + height, + } + } /// A click outside the window (slot id -999): `THROW` does nothing; a /// `PICKUP` drops the carried stack (left) or one of it (right), thrown /// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`). @@ -6819,7 +7298,7 @@ impl Game<'_> { } } fn click_inventory(&mut self, right: bool) { - if self.mount_click(right) { + if self.mod_browser_press(right) || self.mount_click(right) { return; } if self.recipe_book_click(right) { @@ -7151,7 +7630,12 @@ impl Game<'_> { self.see_through_vertices = see_through; // Entity shadows (EntityRenderer.extractShadow). self.mark_translucent_entity(out.len(), "minecraft:entity_shadow"); - if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() { + let shadow_sprite = self + .settings + .entity_shadows + .then(|| self.gui.atlas.sprites.get("misc/shadow").copied()) + .flatten(); + if let Some(sprite) = shadow_sprite { let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 }; let darken = self.sky_darken(); let a = alpha; @@ -7329,6 +7813,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } @@ -7550,7 +8036,7 @@ impl Game<'_> { /// strength and whether it is a critical hit): the weapon and effects /// scaled by the attack strength, a mace's smash, the critical hit, then /// enchantments. - pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) { + pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) { let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); // Riptide's spin replaces the attack damage attribute. let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg); @@ -7578,8 +8064,9 @@ impl Game<'_> { amount *= 1.5; } // Enchantment damage (sharpness, smite, bane) scaled by the attack strength. - amount += self.enchant_damage_bonus(target_kind) * scale; - (amount, scale, crit) + let magic_boost = self.enchant_damage_bonus(target_kind) * scale; + amount += magic_boost; + (amount, scale, crit, magic_boost > 0.0) } /// `Player.getAttackStrengthScale(0.5)`. fn attack_strength_scale(&self) -> f32 { @@ -7609,16 +8096,81 @@ impl Game<'_> { } /// The visual and sound part of `Player.doSweepAttack` after a successful hit. pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) { - self.particles.add( - &horizon_client::particles::ParticleOptions::simple( - horizon_client::particles::ParticleType::SweepAttack, - ), - at, - [0.0; 3], + let sweep = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::SweepAttack, ); + self.particles.add(&sweep, at, [0.0; 3]); + // Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`: + // the offset is the one from the player to the particle. + let origin = from.unwrap_or(at); + let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]]; + self.lan_send_particles(&sweep, at, spread, 0.0, 0); self.audio .play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0); } + /// The damage and knockback part of `Player.doSweepAttack` after a sweep + /// hit on the mob at `target`: every other living mob in its box grown by + /// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative + /// too), but the player's allies + /// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the + /// weapon's enchantments, scaled by the attack strength, and is pushed 0.4 + /// along the player's yaw. + pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) { + let Some(p) = self.player.as_ref() else { + return; + }; + let from = [p.position.x, p.position.y, p.position.z]; + // `baseDamage` of `Player.attack` before a critical hit (a sweep has + // none) and the enchantments: the attack damage attribute with the + // strength and weakness effects, scaled by the attack strength. + let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage); + let base_damage = (hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8); + // The enchantment's attribute modifier on the attribute's 0 to 1 range. + let ratio = self + .res + .stations() + .enchantments + .attribute_add( + self.inventory[self.selected].as_ref(), + "minecraft:sweeping_damage_ratio", + ) + .clamp(0.0, 1.0) as f32; + let sweep_damage = 1.0 + ratio * base_damage; + let hit_box = self.mobs[target] + .bounds() + .inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0)); + let (uuid, allies) = ( + self.level.player_uuid.or(Some([0, 0, 0, 1])), + self.player_allies(), + ); + let nearby: Vec = self + .mobs + .iter() + .enumerate() + .filter(|(i, m)| { + *i != target + && m.alive() + && !m.removed + && !allies.contains(&m.id) + && !mace::ally_of_player(m, uuid) + && m.bounds().intersects(hit_box) + && m.position.distance_squared(p.position) < 9.0 + }) + .map(|(i, _)| i) + .collect(); + let (xd, zd) = self.extra_knockback_direction(); + for i in nearby { + let kind = self.mobs[i].kind.id.clone(); + let kind = format!("minecraft:{}", kind.trim_start_matches("minecraft:")); + let damage = (sweep_damage + self.enchant_damage_bonus(&kind)) * scale; + let source = self.attack_source(from); + if self.hurt_by_player(i, damage, source) { + self.mobs[i].knockback(0.4, xd, zd); + self.last_hurt_by_player + .insert(self.mobs[i].id, self.lan_acting); + } + } + } /// `Player.attack`'s sounds when a vehicle takes the hit (it always does, /// and can't be hit critically): knockback when sprinting at full /// strength, then strong or weak. Before `swing_arm` resets the charge. @@ -7703,15 +8255,10 @@ impl Game<'_> { pub(super) fn attack_mob(&mut self, index: usize) { let target_kind = self.mobs[index].kind.id.clone(); let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:")); - let (amount, scale, crit) = self.melee_damage(&target_kind); + let (amount, scale, crit, magic) = self.melee_damage(&target_kind); let sweep = self.sweep_attack_position(scale, crit); let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale); self.attack_ticks = 0; - if let Some(p) = self.player.as_mut() - && !self.creative - { - p.cause_food_exhaustion(0.1); - } let player = self.player.as_ref(); let from = player.map(|p| [p.position.x, p.position.y, p.position.z]); if knockback_attack { @@ -7726,6 +8273,15 @@ impl Game<'_> { None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic), }; let landed = self.hurt_by_player(index, amount, source); + if landed { + let victim = self.mob_entity(&self.mobs[index]); + self.player_hit( + &victim, + creatures::damage_type(source.kind, true), + None, + amount, + ); + } if let Some(p) = self.player.as_mut() { if p.attack_log.len() >= 64 { p.attack_log.remove(0); @@ -7766,6 +8322,18 @@ impl Game<'_> { feet, width, height, + false, + ); + } + // Player.attackVisualEffects: magicCrit when the enchantments added damage. + if magic { + self.critical_hit( + self.lan_acting, + self.mobs[index].id as i32, + feet, + width, + height, + true, ); } if actual > 2.0 { @@ -7779,6 +8347,14 @@ impl Game<'_> { 0.2, (actual * 0.5) as u32, ); + // The players a server serves are sent them too. + self.lan_send_particles( + &indicator, + [feet[0], feet[1] + height * 0.5, feet[2]], + [0.1, 0.0, 0.1], + 0.2, + (actual * 0.5) as u32, + ); } self.last_hurt_by_player .insert(self.mobs[index].id, self.lan_acting); @@ -7792,34 +8368,57 @@ impl Game<'_> { // Player.playServerSideSound: at the player. if let Some(at) = sweep { self.sweep_attack(at, from); + self.sweep_damage(index, scale); } else { self.audio.play(sound, from, 1.0, 1.0); } // The hurt or death sound the mob raised (getHurtSound / getDeathSound, // its voice pitch from its own random). let queued = std::mem::take(&mut self.mobs[index].outbox); + let mut infested = false; for event in queued { - if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event { - self.audio - .play(&format!("minecraft:{s}"), Some(at), volume, pitch); + match event { + horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) => { + self.audio + .play(&format!("minecraft:{s}"), Some(at), volume, pitch); + } + horizon_client::mobs::MobEvent::Infested { .. } => infested = true, + other => { + // Left for the end of the tick. + self.mobs[index].outbox.push(other); + } } } + if infested { + let id = self.mobs[index].id; + self.mob_infested(id); + } self.weapon_hit(index); + // Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt + // (inside `if (wasHurt)`), after damageStatsAndHearts. + if let Some(p) = self.player.as_mut() + && !self.creative + { + p.cause_food_exhaustion(0.1); + } } else { self.audio .play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0); } } /// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step. - /// A mob explosion (`Level.ExplosionInteraction.MOB`: drop decay with the - /// default mobGriefing rule). + /// A mob explosion (`ServerLevel.explode` with `ExplosionInteraction.MOB`: + /// blocks are kept without mobGriefing, else the mob_explosion_drop_decay + /// rule picks decay). fn explode(&mut self, center: [f64; 3], radius: f32) -> Result<()> { - self.server_explode( - center, - radius, - explosion::BlockInteraction::DestroyWithDecay, - false, - ) + let interaction = if !self.ui.rules.bool("mob_griefing") { + explosion::BlockInteraction::Keep + } else if self.ui.rules.bool("mob_explosion_drop_decay") { + explosion::BlockInteraction::DestroyWithDecay + } else { + explosion::BlockInteraction::Destroy + }; + self.server_explode(center, radius, interaction, false) } /// Copies of a save batch carrying its live block entities and save data. fn with_entities<'a>( @@ -7881,6 +8480,8 @@ impl Game<'_> { layer: 2, color, light: 0, + + glint_uv: [0.0, 0.0], }); } }; @@ -7921,6 +8522,7 @@ impl Game<'_> { creative: bool, create: Option<&horizon_client::title::WorldOptions>, ) -> Result<()> { + world_loading::world_data_used(); if self.defer_world_entry(PendingWorldEntry::Local { folder: folder.to_owned(), name: name.map(str::to_owned), @@ -7986,6 +8588,7 @@ impl Game<'_> { ); self.session_lock = Some(session_lock); self.end.set_locator(locator); + self.share_spawn_conditions(); self.score = Default::default(); self.fns = Default::default(); self.bosses = Default::default(); @@ -8124,6 +8727,7 @@ impl Game<'_> { seed, )?; self.end.set_locator(locator); + self.share_spawn_conditions(); Ok((streaming, save)) } /// Change dimension through a portal (Entity.changeDimension with @@ -8362,6 +8966,10 @@ impl Game<'_> { } self.lan_close(false); self.save_game()?; + if let Some(mut remote) = self.remote.take() { + // Minecraft.disconnect closes the connection, so the host sees a clean leave. + remote.session.disconnect(); + } self.saved_for_exit = true; Ok(()) } @@ -8473,15 +9081,54 @@ impl Game<'_> { } self.apply_enchantment_attributes(); } - /// Inventory.hurtArmor: each piece loses max(1, damage / 4) durability. - fn hurt_armor(&mut self, damage: f32) { + /// The armor hits among a tick's motion events (`Player.hurtArmor` from + /// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion, + /// a cactus, lava, a campfire. + fn wear_armor(&mut self, events: &[MotionEvent]) { + for event in events { + if let MotionEvent::Health(horizon_client::health::HealthEvent::ArmorHurt { + source, + amount, + }) = event + { + self.hurt_armor(*source, *amount); + } + } + } + /// `Player.hurtArmor` -> `LivingEntity.doHurtEquipment`: each worn piece that + /// wears when its wearer is hurt loses max(1, damage / 4) durability, except + /// to a fire source when the piece resists fire (`ItemStack.canBeHurtBy`). + fn hurt_armor(&mut self, source: horizon_world::damage_source::DamageTypeId, damage: f32) { + if damage <= 0.0 { + return; + } let amount = ((damage / 4.0) as u32).max(1); + let name = self + .motion + .health + .damage + .definition(source) + .map(|d| d.name.clone()) + .unwrap_or_default(); + let fire = self + .motion + .health + .damage + .in_tag(source, "minecraft:is_fire"); let mut broke = false; let losses: Vec = (0..4) - .map(|i| { - self.armor[i] - .clone() - .map_or(amount, |s| self.durability_loss(&s, amount)) + .map(|i| match self.armor[i].clone() { + Some(s) + if self + .res + .items() + .get(&s.item) + .is_some_and(|info| info.equip_damage_on_hurt) + && !(fire && self.res.items().resists_fire(&s, &name)) => + { + self.durability_loss(&s, amount) + } + _ => 0, }) .collect(); for (slot, amount) in self.armor.iter_mut().zip(losses) { @@ -8578,7 +9225,8 @@ impl Game<'_> { } fn drop_stack(&mut self, stack: ItemStack) { if let Some(eye) = self.0.eye_position() { - self.0.spawn_item(stack, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.0.throw_from_eyes(stack, eye, false); } } } @@ -8603,6 +9251,9 @@ impl Game<'_> { if result.outcome == Outcome::Pass { return Ok(false); } + if let Some(stat) = result.stat { + self.stats.custom(stat, 1); + } if let Some(block) = result.block && let Ok(new) = registry.resolve(&block) { @@ -8712,6 +9363,8 @@ impl Game<'_> { else { return Ok(false); }; + // BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", pos, stack); self.level_set(pos, new)?; // BlockTransformer.TransformParticle.send. self.transform_particle(&particle, pos); @@ -8816,6 +9469,24 @@ impl Game<'_> { } } } + /// `ItemStack.hurtWithoutBreaking`: Unbreaking applies, but the stack stops at + /// one point of durability left instead of breaking. + fn hurt_held_without_breaking(&mut self, amount: u32) { + if self.creative { + return; + } + let Some(held) = self.held().cloned() else { + return; + }; + let amount = self.durability_loss(&held, amount); + let max = self.res.items().get(&held.item).map_or(0, |i| i.max_damage); + if amount == 0 || max == 0 { + return; + } + if let Some(stack) = self.held_mut().as_mut() { + stack.damage = (stack.damage + amount).min(max - 1); + } + } /// Block tool use: distinguish success, failure and fall-through for hand feedback. fn use_tool( &mut self, @@ -8903,7 +9574,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // ItemUtils.createFilledResult: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } return Ok(UseResult::Success { swing: true }); @@ -8965,6 +9637,8 @@ impl Game<'_> { if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old)) && let Ok(state) = registry.resolve(&waxed) { + // HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap. + self.trigger_block("minecraft:item_used_on_block", hit.pos, stack); self.level_set(hit.pos, state)?; self.level_event_at(3003, hit.pos, 0); self.audio.play( @@ -9053,8 +9727,29 @@ impl Game<'_> { while value > 0 { let v = ExperienceOrb::split(value); value -= v; - let _merge_id = self.world.random.next_int_bound(40); - self.add_orb(at, v); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.award_orb(at, v, merge_id); + } + } + /// `ExperienceOrb.tryMergeToExisting`: an orb of the same value within a + /// 1 block box of `at`, whose id is a multiple of 40 from `merge_id`, takes + /// the new one as one more in its count (and restarts its age); otherwise + /// a fresh orb spawns. + fn award_orb(&mut self, at: [f64; 3], value: u32, merge_id: i32) { + let centre = Vec3::new(at[0], at[1], at[2]); + let half = Vec3::new(0.5, 0.5, 0.5); + let area = Aabb::new(centre.subtract(half), centre.add_vector(half)); + // A server's orbs are its own; a LAN or server guest only shows them. + let target = self.remote.is_none().then(|| { + self.orbs + .iter_mut() + .find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area)) + }); + if let Some(Some(orb)) = target { + orb.count += 1; + orb.age = 0; + } else { + self.add_orb(at, value); } } /// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from @@ -9076,6 +9771,8 @@ impl Game<'_> { value, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } fn can_place_used_fire(&mut self, target: [i32; 3]) -> bool { @@ -9152,18 +9849,31 @@ impl Game<'_> { }), ); } + // Player.aiStep: the pickup area is the box inflated by (1, 0.5, 1). let bounds = player.map(|p| { let w = f64::from(p.dimensions.width) / 2.0; ( - [p.position.x - w, p.position.y, p.position.z - w], [ - p.position.x + w, - p.position.y + f64::from(p.dimensions.height), - p.position.z + w, + p.position.x - w - 1.0, + p.position.y - 0.5, + p.position.z - w - 1.0, + ], + [ + p.position.x + w + 1.0, + p.position.y + f64::from(p.dimensions.height) + 0.5, + p.position.z + w + 1.0, ], ) }); let mut orbs = std::mem::take(&mut self.orbs); + // An orb has no random of its own here: lava draws from one made on + // first use, apart from the level stream. + let mut lava_random = None; + let mut next_float = || { + lava_random + .get_or_insert_with(dispenser::entity_random) + .next_float() + }; orbs.retain_mut(|o| { let distance = |feet: &[f64; 3]| -> f64 { (feet[0] - o.position.x).powi(2) @@ -9175,12 +9885,22 @@ impl Game<'_> { .filter(|(feet, _)| distance(feet) < 64.0) .min_by(|a, b| distance(&a.0).total_cmp(&distance(&b.0))) .map(|(_, target)| *target); - o.tick(&self.world, self.res.facts(), self.collision, target) + o.tick( + &self.world, + self.res.facts(), + self.collision, + target, + &mut next_float, + ) }); + // ExperienceOrb.scanForMerges runs on the server only. + if self.remote.is_none() { + horizon_client::entities::merge_orbs(&mut orbs); + } let mut gained = 0; let mut taken = Vec::new(); if let Some((min, max)) = bounds { - orbs.retain(|o| { + orbs.retain_mut(|o| { let touching = o.position.x + 0.25 > min[0] && o.position.x - 0.25 < max[0] && o.position.y + 0.5 > min[1] @@ -9188,11 +9908,13 @@ impl Game<'_> { && o.position.z + 0.25 > min[2] && o.position.z - 0.25 < max[2]; if touching && self.xp_delay == 0 { - // ExperienceOrb.playerTouch: one orb every 2 ticks. + // ExperienceOrb.playerTouch: one orb every 2 ticks; a + // merged orb gives its value once per count. self.xp_delay = 2; gained += o.value; taken.push(o.clone()); - return false; + o.count -= 1; + return o.count > 0; } true }); @@ -9238,16 +9960,13 @@ impl Game<'_> { /// sound every fifth level (at most every 100 ticks). pub(super) fn give_orb_points(&mut self, gained: u32) { let at = self.eye_position(); - if self.experience.give(gained as i32) - && self.experience.level % 5 == 0 + // Player.giveExperienceLevels runs per level, so the first multiple of 5 + // crossed plays the sound (and starts the cooldown for the rest). + if let Some(level) = self.experience.give(gained as i32) && self.ticks > self.last_level_up + 100 { self.last_level_up = self.ticks; - let volume = if self.experience.level > 30 { - 1.0 - } else { - self.experience.level as f32 / 30.0 - }; + let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 }; self.audio .play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0); } @@ -9268,7 +9987,10 @@ impl Game<'_> { }; // BowItem.shootProjectile: shootFromRotation(power * 3, inaccuracy 1). let p = Vec3::new(eye[0], eye[1] - 0.1, eye[2]); - let (power_bonus, infinity) = self.bow_enchantments(); + let ammo = arrow_slot + .and_then(|i| self.stack_in(i).cloned()) + .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); + let (power_bonus, infinity) = self.bow_enchantments(&ammo.item); let kind = horizon_client::projectiles::ProjectileKind::Arrow { base_damage: 2.0 + power_bonus, critical: power >= 1.0, @@ -9282,9 +10004,6 @@ impl Game<'_> { // ProjectileWeaponItem.useAmmo: creative and Infinity draw without // using the ammo, so the arrow is CREATIVE_ONLY; else setOwner(player) // lets it be picked up. - let ammo = arrow_slot - .and_then(|i| self.stack_in(i).cloned()) - .unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1)); if let Some(a) = arrow.arrow.as_mut() { a.set_item(&horizon_client::arrows::drawn_ammo( &ammo, @@ -9312,7 +10031,7 @@ impl Game<'_> { velocity, on_ground, ); - self.creatures.projectiles.push(arrow); + self.add_projectile(arrow); self.player_projectile_shot(); let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5; self.audio @@ -9617,6 +10336,14 @@ impl Game<'_> { }; Some(t < 0.15) } + /// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops + /// motion is above, and the biome's precipitation there is rain. + fn raining_at(&self, p: BlockPos) -> bool { + self.world.weather.levels(1.0).0 > 0.2 + && self.world.light(p.x, p.y, p.z) >> 4 == 15 + && self.rain_height(p.x, p.z) <= p.y + && self.precipitation(p.x, p.y, p.z) == Some(false) + } /// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the /// highest block in `blocks_motion_in_heightmap` or holding a fluid. fn rain_height(&self, x: i32, z: i32) -> i32 { @@ -9630,37 +10357,7 @@ impl Game<'_> { if self.flash > 0 { self.flash -= 1; } - let (rain, _) = self.world.weather.levels(1.0); - let Some(eye) = self.eye_position() else { - return; - }; - // WeatherEffectRenderer.tickRainParticles: occasional rain sounds. - if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 { - let r = &mut self.world.random; - let (dx, dz) = ( - r.next_int_bound(21).unwrap_or(10) - 10, - r.next_int_bound(21).unwrap_or(10) - 10, - ); - let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz); - let top = self.rain_height(x, z); - if self.precipitation(x, top, z) == Some(false) - && (top - eye[1] as i32).abs() < 10 - && self.world.random.next_int_bound(5).unwrap_or(1) == 0 - { - let above = f64::from(top) > eye[1] + 1.0; - let (name, volume, pitch) = if above { - ("minecraft:weather.rain.above", 0.1, 0.5) - } else { - ("minecraft:weather.rain", 0.2, 1.0) - }; - self.audio.play( - name, - Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]), - volume, - pitch, - ); - } - } + // The rain sounds come with the rain splashes (`tick_particles`). } /// WeatherEffectRenderer: rain and snow columns within the weather radius. fn weather_geometry( @@ -10201,6 +10898,9 @@ impl Game<'_> { let level = &mut self.level; level.creative = self.creative; level.game_mode = mode; + // PrimaryLevelData.setDifficulty: /difficulty and the options button change it. + level.difficulty = + horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into(); level.time = self.world.game_time; level.weather = self.world.weather; level.day_time = self.world.day_time; @@ -10356,6 +11056,21 @@ impl Game<'_> { } return Ok(false); } + // A server pack's reload covers the frame with its progress + // (`LoadingOverlay`) until `tick_server_packs` applies it. + if let Some(reload) = &self.server_packs.reload { + if let Some(renderer) = &mut self.renderer { + let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); + horizon_client::level_loading::draw_progress( + &mut hud, + &self.gui.atlas, + reload.progress.line(), + reload.progress.fraction(), + ); + return renderer.render(&loading_frame(&hud)); + } + return Ok(false); + } let Some(size) = self .renderer .as_ref() @@ -10476,7 +11191,11 @@ impl Game<'_> { let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view); // LevelExtractor.extractBlockOutline: model translucency controls draw order. let mut outline_after_terrain = false; - let outline = if self.screen == Screen::Playing && !self.spectator() { + // GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a + // player who may build. Adventure players only get one with a CAN_BREAK or + // CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the + // components nor opens menus for spectators, so those never show it. + let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() { self.pick() .map(|h| { let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]); @@ -10501,6 +11220,17 @@ impl Game<'_> { [0.0; 3] }; let p = h.pos.map(f64::from); + if let Some(b) = self.context_outline(h.pos, state) { + return horizon_client::blocks::cube_edges() + .iter() + .map(|e| { + std::array::from_fn(|i| { + let k = i % 3; + p[k] + b[k] + e[i] * (b[k + 3] - b[k]) + }) + }) + .collect(); + } f.outline_edges .iter() .map(|e| std::array::from_fn(|i| p[i % 3] + o[i % 3] + e[i])) @@ -10550,6 +11280,7 @@ impl Game<'_> { let mut entities = std::mem::take(&mut self.entity_vertices); entities.clear(); self.see_through_vertices.clear(); + self.glowing_vertices.get_mut().clear(); if !title { entities = horizon_client::spans::time("entity_geometry", || { self.entity_geometry(camera, alpha, entities) @@ -10617,6 +11348,7 @@ impl Game<'_> { // map_patches any earlier leaves them a frame behind. hud.patches.append(&mut self.map_patches); let entity_spans = std::mem::take(self.entity_spans.get_mut()); + let glowing = std::mem::take(self.glowing_vertices.get_mut()); let frame = Frame { weather: &weather, weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), @@ -10661,10 +11393,13 @@ impl Game<'_> { dark_disc: self.dark_disc(camera), outline, outline_after_terrain, + outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"), debug_lines: &debug_lines, debug_lines_on_top: &debug_lines_on_top, debug_crosshair: &debug_crosshair, hud: &hud, + glint_alpha: self.settings.file.double("glintStrength") as f32, + glint_ticks: self.glint_ticks(), clouds: (!title && self.dim == Dim::Overworld && horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3])) @@ -10682,6 +11417,7 @@ impl Game<'_> { }), entities: &entities, entity_spans: &entity_spans, + glowing: &glowing, dynamic_lights: &self.dynamic_lights, lightning: horizon_client::gfx::lightning::Input { bolts: &bolts, @@ -10720,7 +11456,7 @@ impl Game<'_> { }), ticks: self.ticks, darkness: self.darkness_lighting(alpha as f32), - night_vision: self.night_vision_intensity(), + night_vision: self.night_vision_intensity(alpha as f32), block_flicker: self.fog.block_light_flicker, boss_darken: self .boss_overlay @@ -10737,6 +11473,7 @@ impl Game<'_> { // Restore storage even when rendering fails (for example, a lost surface). self.entity_vertices = entities; *self.entity_spans.get_mut() = entity_spans; + *self.glowing_vertices.get_mut() = glowing; self.see_through_vertices = see_through; result } @@ -10892,6 +11629,10 @@ impl Game<'_> { Screen::GameModeSwitcher => self.close_gamemode_switcher(false), Screen::Dead => {} Screen::Stats | Screen::Friends => self.screen = Screen::Paused, + Screen::ServerLinks => self.screen = Screen::Paused, + // ConfirmScreen.keyPressed: Escape answers no; both return to + // the Server Links screen they came from. + Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks, Screen::Advancements => self.close_advancements(), Screen::WorldOptions => self.world_options_escape(), Screen::CommandBlock => self.close_command_block(), @@ -10960,8 +11701,10 @@ impl Game<'_> { // key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND // (the server swaps the hands and sends both slots back; spectators can't). KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => { - if !self.spectator() { - self.remote_swap_hands(); + if !self.spectator() + && let Some(r) = self.remote.as_mut() + { + r.pending_keys.push(key); } } KeyCode::KeyF if self.screen == Screen::Playing => { @@ -11058,11 +11801,28 @@ impl Game<'_> { } Ok(()) } - /// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`). + /// LOD Distance and Far Detail Reduced as chosen: the player's LOD + /// Distance when it is on, else a mod's far terrain + /// (`render.set-far-terrain`). + fn far_terrain_choice(&self) -> (u32, bool) { + let g = &self.settings.horizon; + let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced; + if g.lod_distance > 0 { + return (g.lod_distance, reduced); + } + match self.mod_far_terrain() { + Some(f) => (f.distance, reduced || f.reduced), + None => (0, reduced), + } + } + /// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`). fn lod_distance(&self) -> u32 { + let (distance, _) = self.far_terrain_choice(); match &self.renderer { - Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info), - None => self.settings.horizon.lod_distance.min(1024), + Some(renderer) => { + horizon_client::graphics::lod_distance_capped(distance, &renderer.info) + } + None => distance.min(1024), } } /// Only enabled LOD worlds extend the renderer's range, never simulation. @@ -11083,7 +11843,7 @@ impl Game<'_> { } } fn prepare_lod(&mut self) { - let enabled = self.settings.horizon.lod_distance > 0 + let enabled = self.far_terrain_choice().0 > 0 && self.renderer.is_some() && self.player.is_some() && self.title.is_none() @@ -11180,6 +11940,7 @@ impl Game<'_> { { log::warn!("Horizon: failed to save unloaded chunks: {e}"); } + let (far_distance, far_reduced) = self.far_terrain_choice(); if let Some(renderer) = self.renderer.as_mut() { let g = &self.settings.horizon; let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0; @@ -11233,12 +11994,10 @@ impl Game<'_> { } renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z))); if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) { - let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info); + let lod_distance = + horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info); lod.set_near_loading(self.streaming.busy()); - lod.set_reduced_detail( - self.settings.horizon.far_detail - == horizon_client::graphics::FarDetail::Reduced, - ); + lod.set_reduced_detail(far_reduced); // The covered columns change only with the complete ones and // the render area: rebuilt then, not every frame. let at = ( @@ -11405,6 +12164,19 @@ impl ApplicationHandler for Game<'_> { { return; } + // The pack reload's progress overlay takes no input + // (`LoadingOverlay`): `start_pack_reload` released what was held. + if self.server_packs.reload.is_some() + && !matches!( + &event, + WindowEvent::CloseRequested + | WindowEvent::Focused(_) + | WindowEvent::Resized(_) + | WindowEvent::RedrawRequested + ) + { + return; + } if self.pending_save.is_some() { match event { WindowEvent::CloseRequested => { @@ -11561,6 +12333,7 @@ impl ApplicationHandler for Game<'_> { } if self.screen == Screen::Inventory && d != 0.0 + && !self.bundle_scroll(scaled(raw_x), scaled(raw_y)) && !self.tweak_scroll(d, self.inventory_target().0) { self.station_scroll(d); @@ -11571,10 +12344,18 @@ impl ApplicationHandler for Game<'_> { if self.screen == Screen::WorldOptions && d != 0.0 { self.world_options_scroll(d); } + if self.screen == Screen::ServerLinks && d != 0.0 { + self.server_links_scroll(d); + } if self.screen == Screen::Advancements { self.advancements_scroll(scaled(raw_x) as f32, d); } } + WindowEvent::MouseInput { state, button, .. } if self.link_prompt.is_some() => { + if state == ElementState::Pressed && button == MouseButton::Left { + self.link_prompt_click(); + } + } WindowEvent::MouseInput { state, button, .. } if self.server_packs.prompt.is_some() => { @@ -11624,7 +12405,8 @@ impl ApplicationHandler for Game<'_> { let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| { let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height)); t.press_field(atlas, self.cursor, (b.width, b.height), shift); - clicked + let rules = t.press_rules(atlas, self.cursor, (b.width, b.height)); + (clicked.0, clicked.1 || rules) }); self.save_title_options(); if sound { @@ -11762,6 +12544,15 @@ impl ApplicationHandler for Game<'_> { Screen::WorldOptions if pressed && button == MouseButton::Left => { self.world_options_click() } + Screen::ServerLinks if pressed && button == MouseButton::Left => { + self.server_links_click()? + } + Screen::ConfirmLink if pressed && button == MouseButton::Left => { + self.confirm_link_click() + } + Screen::DialogWarning if pressed && button == MouseButton::Left => { + self.dialog_warning_click()? + } Screen::WorldOptions if button == MouseButton::Left => { self.world_options_release() } @@ -11794,6 +12585,13 @@ impl ApplicationHandler for Game<'_> { } } // ConfirmScreen.keyPressed: Escape answers no. + WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => { + if event.state == ElementState::Pressed + && event.physical_key == PhysicalKey::Code(KeyCode::Escape) + { + self.answer_link_prompt(false); + } + } WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => { if event.state == ElementState::Pressed && event.physical_key == PhysicalKey::Code(KeyCode::Escape) @@ -11862,6 +12660,14 @@ impl ApplicationHandler for Game<'_> { // The recipe book's search box takes every key but Escape. self.recipe_key(&event); } + WindowEvent::KeyboardInput { event, .. } + if self.mod_browser_typing() + && event.state == ElementState::Pressed + && !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) => + { + // A mod's item panel's search box, as the recipe book's. + self.mod_browser_key(&event); + } WindowEvent::KeyboardInput { event, .. } if self.station_typing() && event.state == ElementState::Pressed @@ -12185,6 +12991,16 @@ impl ApplicationHandler for Game<'_> { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 + && self.pending_save.as_ref().is_none_or(|save| !save.quit) + { + // SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close. + if let Err(error) = self.begin_closing_save(true) { + self.error = Some(error); + event_loop.exit(); + return; + } + } if self.pending_save.is_some() { match self.poll_closing_save() { Ok(true) => event_loop.exit(), @@ -12309,6 +13125,7 @@ impl<'a> Game<'a> { world_loader: None, pending_pack_reload: false, server_packs: Default::default(), + link_prompt: None, dialogs: Default::default(), world_renderer_ready: false, collision: res.collision(), @@ -12342,6 +13159,7 @@ impl<'a> Game<'a> { craft_table: None, inv_drag: None, tweaks: Default::default(), + bundle_mouse: Default::default(), inv_clicks: Default::default(), last_inv_click: None, last_quick_moved: None, @@ -12384,6 +13202,7 @@ impl<'a> Game<'a> { luminance: None, see_through_vertices: Vec::new(), entity_spans: Default::default(), + glowing_vertices: Default::default(), pack_ids: None, health_hud: hud_layout::HealthHud::default(), started: now, @@ -12403,7 +13222,14 @@ impl<'a> Game<'a> { level, session_lock: None, audio, - music: Default::default(), + music: { + // The manager starts with the saved option (MusicManager's constructor). + let mut music = horizon_client::music::MusicManager::default(); + music.frequency = horizon_client::music::MusicFrequency::parse( + &settings.file.name("musicFrequency"), + ); + music + }, ambience: Default::default(), sound_paused: false, cart_sounds: HashSet::new(), @@ -12447,6 +13273,8 @@ impl<'a> Game<'a> { command_source: None, titles: Default::default(), tool_highlight: Default::default(), + saving_indicator: Default::default(), + autosaving: false, applied_modifiers: Vec::new(), experience: Experience::default(), orbs: Vec::new(), @@ -12465,6 +13293,7 @@ impl<'a> Game<'a> { dragging: false, avatar: Default::default(), flash: 0, + rain_sound_time: 0, lightning_seen: [0; 2], sleeping: None, sleep_timer: 0, @@ -12519,12 +13348,15 @@ impl<'a> Game<'a> { dragon_model: prepared.dragon_model, map_patches: Vec::new(), text_displays: Default::default(), + remote_knots: Default::default(), + knot_ids: Default::default(), text_display_vertices: 0, skin_downloads: RefCell::new(horizon_client::skins::Downloader::new( std::env::current_dir() .unwrap_or_default() .join("assets/skins"), )), + skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()), skin_slots: Vec::new(), trim_slots: Vec::new(), cart_models: prepared.cart_models, @@ -12591,6 +13423,7 @@ impl<'a> Game<'a> { forced_hit: None, guest_use: None, lan_events: Vec::new(), + lan_cushion_breaks: Vec::new(), lan_block_events: Vec::new(), lan_entity_events: Vec::new(), lan_critical_hits: Vec::new(), @@ -12599,6 +13432,7 @@ impl<'a> Game<'a> { lan_acting: None, guest_packets: Vec::new(), guest_cooldowns: Vec::new(), + guest_entity_events: Vec::new(), lan_acting_name: None, lan_acting_uuid: None, chat_capture: None, @@ -12625,6 +13459,7 @@ impl<'a> Game<'a> { restore_player_state: true, stats_ui: Default::default(), adv_ui: Default::default(), + links_ui: Default::default(), camera_type: Default::default(), fangs: Default::default(), player_model: None, @@ -12770,6 +13605,17 @@ impl Game<'_> { if self.rest_loader.is_some() { return Ok(()); } + let on_world_screens = self.title.as_ref().is_some_and(|title| { + !matches!( + title.mode, + horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_) + ) + }); + world_loading::preload_world_data( + on_world_screens, + &self.options.resources, + self.pack.assets.registry, + ); if std::mem::take(&mut self.pending_pack_reload) { self.reload_packs(); } @@ -12856,7 +13702,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [ KeyCode::Numpad9, ]; -const STAGES: u32 = 10; const STAGE_LINES: [&str; 10] = [ "Loading menu", "Loading assets", @@ -12872,19 +13717,28 @@ const STAGE_LINES: [&str; 10] = [ /// The background loader's checkpoint, read by the loading screen each frame. #[derive(Clone)] -struct Progress(Arc); +struct Progress { + value: Arc, + lines: &'static [&'static str], +} impl Progress { fn new() -> Self { - Self(Arc::new(AtomicU32::new(0))) + Self::with(&STAGE_LINES) + } + fn with(lines: &'static [&'static str]) -> Self { + Self { + value: Arc::new(AtomicU32::new(0)), + lines, + } } fn set(&self, stage: u32) { - self.0.store(stage, Ordering::Relaxed); + self.value.store(stage, Ordering::Relaxed); } fn fraction(&self) -> f32 { - self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32 + self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32 } fn line(&self) -> &'static str { - STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)] + self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)] } } @@ -13244,6 +14098,12 @@ fn build_renderer( &assets.sprite_mips, &assets.animations, (&atlas.pixels, atlas.width, atlas.height), + (&atlas.glint.0, atlas.glint.1, atlas.glint.2), + ( + &atlas.glint_armor.0, + atlas.glint_armor.1, + atlas.glint_armor.2, + ), (&pack.forcefield.2, pack.forcefield.0), &gui.panorama, prepared, @@ -13270,6 +14130,7 @@ fn loading_renderer( ) -> Result { static FORCEFIELD: [u8; 4] = [0; 4]; static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255]; + static EMPTY_GLINT: [u8; 4] = [0; 4]; let empty_panorama = horizon_client::gfx::panorama::Faces { size: 1, pixels: [0u8; 4].repeat(6), @@ -13280,6 +14141,10 @@ fn loading_renderer( Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height), None => (EMPTY_HUD.as_slice(), 1, 1), }; + let (glint_pixels, glint_width, glint_height) = match atlas { + Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2), + None => (EMPTY_GLINT.as_slice(), 1, 1), + }; // A glyph rasterized into an earlier texture is not in these pixels; the // new texture gets it again when the character is drawn (see // `build_renderer`). @@ -13296,6 +14161,8 @@ fn loading_renderer( &[], &[], (pixels, width, height), + (glint_pixels, glint_width, glint_height), + (glint_pixels, glint_width, glint_height), (&FORCEFIELD[..], 1), panorama, None, @@ -13338,13 +14205,17 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { dark_disc: false, outline: Vec::new(), outline_after_terrain: false, + outline_high_contrast: false, debug_lines: &[], debug_lines_on_top: &[], debug_crosshair: &[], hud, + glint_alpha: 0.75, + glint_ticks: 0.0, nether_lighting: false, entities: &[], entity_spans: &[], + glowing: &[], dynamic_lights: &[], clouds: None, lightning: Default::default(), @@ -13650,6 +14521,11 @@ impl Loading { } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if horizon_threads::stop_requests() > 0 { + // No world is open yet: nothing to save. + event_loop.exit(); + return; + } if let Err(error) = self.poll_menu_resources() { self.error = Some(error); event_loop.exit(); @@ -13927,6 +14803,9 @@ pub fn run() -> Result<()> { { horizon_client::heartbeat::start(); } + if options.execution_role.is_client() { + horizon_threads::catch_stop_signals(); + } let started = Instant::now(); horizon_client::spans::enable(options.spikes.is_some()); let vanilla_root = options.resources.clone(); diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 0f76d0ca9..912bf7c33 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -9,6 +9,7 @@ use horizon_client::{ text::{self, Style, Text}, toasts::Slide, }; +use horizon_core::geometry::Vec3; use serde_json::Value; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, @@ -82,6 +83,8 @@ pub(super) struct AdvState { pub(super) quiet: bool, /// The inventory last seen by `inventory_changed`. pub(super) last_inventory: u64, + /// `ServerPlayer.levitationStartPos` and `levitationStartTime`. + pub(super) levitation: Option<([f64; 3], u64)>, /// While the game acts for a network player (`as_guest`): the /// advancements whose progress changed (`progressChanged`), the recipes /// unlocked and the announcements, for their client and everyone's. @@ -180,6 +183,14 @@ impl adv::World for View<'_, '_> { "block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)), "entity_type" => self.0.res.mob_data().entity_has(tag, id), "worldgen/biome" => self.0.res.mob_data().biome_has(tag, id), + "damage_type" => self + .0 + .res + .stations() + .enchantments + .damage_tags + .get(tag) + .is_some_and(|t| t.contains(id)), _ => false, } } @@ -647,6 +658,28 @@ impl Game<'_> { }; adv::conditions(&c["player"], &ctx, w) }); + // ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a + // levitation; tick fires LEVITATION while there is one. + let levitating = self + .potions + .effects + .list + .iter() + .any(|e| e.id == "levitation"); + match (levitating, self.adv.levitation) { + (true, None) => { + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + self.adv.levitation = at.map(|at| (at, self.ticks)); + } + (false, Some(_)) => self.adv.levitation = None, + _ => {} + } + if let Some((start, since)) = self.adv.levitation { + self.trigger_levitation(start, (self.ticks - since) as i64); + } if self.ticks.is_multiple_of(20) { let me = self.player_entity(); self.trigger("minecraft:location", |c, w| { @@ -681,6 +714,123 @@ impl Game<'_> { adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w) }); } + /// `LevitationTrigger`: how far the player rose from where it started + /// levitating, and for how long. + fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) { + let me = self.player_entity(); + self.trigger("minecraft:levitation", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position)) + && adv::range_i(&c["duration"], duration) + }); + } + /// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with + /// what the player looks at (`PlayerPredicate.lookingAt`). + pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) { + if self.remote.is_some() || self.no_player_here() { + return; + } + let mut me = self.player_entity(); + me.looking_at = self.looking_at_entity().map(Box::new); + let it = item_view(stack); + self.trigger("minecraft:using_item", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w) + }); + } + /// The mob a 100 block ray from the eyes meets first, if no block is in + /// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`). + fn looking_at_entity(&self) -> Option { + let eye = self.eye_position()?; + let dir = super::view_vector(self.yaw, self.pitch); + let (m, _) = self + .mobs + .iter() + .filter(|m| m.alive()) + .filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?))) + .min_by(|a, b| a.1.total_cmp(&b.1))?; + // `LivingEntity.hasLineOfSight`: eye to the target's eye height, + // `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks. + let from = Vec3::new(eye[0], eye[1], eye[2]); + let to = Vec3::new(m.position.x, m.eye_y(), m.position.z); + let world = self.mob_world_base(&[]); + let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false); + clear.then(|| self.mob_entity(m)) + } + /// `PlayerHurtEntityTrigger`: the player, the damage and the victim. + pub(super) fn trigger_player_hurt_entity( + &mut self, + victim: &EntityView, + damage: &adv::DamageView, + ) { + let me = self.player_entity(); + let (victim, damage) = (victim.clone(), damage.clone()); + self.trigger("minecraft:player_hurt_entity", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let vctx = adv::Entities { + this: Some(&victim), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::damage(&c["damage"], &damage, w, me.position) + && adv::conditions(&c["entity"], &vctx, w) + }); + } + /// The player's hit on `victim` as `LivingEntity.hurtServer` reports it: + /// `kind` damage of `amount` from `direct` (the player's own hand when + /// `None`), nothing blocked. + pub(super) fn player_hit( + &mut self, + victim: &EntityView, + kind: &str, + direct: Option, + amount: f32, + ) { + let me = self.player_entity(); + self.trigger_player_hurt_entity( + victim, + &adv::DamageView { + kind: kind.to_owned(), + is_direct: direct.is_none(), + direct: direct.or_else(|| Some(me.clone())), + source: Some(me), + original: amount, + actual: amount, + blocked: false, + }, + ); + } + /// `EntityHurtPlayerTrigger`: the player and the damage it took. + pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) { + let me = self.player_entity(); + let damage = damage.clone(); + self.trigger("minecraft:entity_hurt_player", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && adv::damage(&c["damage"], &damage, w, me.position) + }); + } /// A player condition plus one item (`item` key). pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) { let me = self.player_entity(); @@ -708,6 +858,72 @@ impl Game<'_> { let it = item_view(stack); self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w)); } + /// `BrewedPotionTrigger`: the potion contents of a stack taken from a + /// brewing stand. + pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) { + let me = self.player_entity(); + let potion = stack.potion().map(str::to_owned); + self.trigger("minecraft:brewed_potion", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + adv::conditions(&c["player"], &pctx, w) + && adv::potions(&c["potion"], potion.as_deref(), w) + }); + } + /// `TradeTrigger`: the villager and the item the trade gave. + pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) { + let me = self.player_entity(); + let villager = villager.clone(); + let it = item_view(stack); + self.trigger("minecraft:villager_trade", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let vctx = adv::Entities { + this: Some(&villager), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::conditions(&c["villager"], &vctx, w) + && adv::item(&c["item"], &it, w) + }); + } + /// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook) + /// and the items it caught. + pub(super) fn trigger_fishing_rod_hooked( + &mut self, + rod: &ItemStack, + hooked: &EntityView, + items: &[ItemStack], + ) { + let me = self.player_entity(); + let rod = item_view(rod); + let hooked = hooked.clone(); + let items: Vec = items.iter().map(item_view).collect(); + self.trigger("minecraft:fishing_rod_hooked", move |c, w| { + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + let ectx = adv::Entities { + this: Some(&hooked), + ..pctx + }; + adv::conditions(&c["player"], &pctx, w) + && adv::item(&c["rod"], &rod, w) + && adv::conditions(&c["entity"], &ectx, w) + && (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w))) + }); + } /// `EnterBlockTrigger`: the block, its state properties and the player. pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { let Some(definition) = self.pack.assets.registry.state(state).cloned() else { diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index fa9dbf3c6..4ab0a5189 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -419,6 +419,9 @@ impl Game<'_> { Screen::Advancements => "advancements", Screen::WorldOptions => "world-options", Screen::Friends => "friends", + Screen::ServerLinks => "server-links", + Screen::ConfirmLink => "confirm-link", + Screen::DialogWarning => "dialog-warning", Screen::CommandBlock => "command-block", Screen::Win => "win", Screen::Mod => "mod", @@ -565,6 +568,22 @@ impl Game<'_> { "generate_structures": options.structures, "bonus_chest": options.bonus_chest(), }), + Mode::CreateFlat { + name, + seed, + options, + flat, + .. + } => json!({ + "mode": "customize-flat", + "fields": { "name": name, "seed": seed }, + "superflat": options.flat, + "biome": flat.settings().biome, + "layers": flat.settings().layers.iter().map(|layer| json!({ + "block": layer.block, + "height": layer.height, + })).collect::>(), + }), Mode::Edit { world, name, @@ -707,9 +726,12 @@ impl Game<'_> { // the recipe buttons of the page, page arrows, filter and tabs. let book = self.recipe_book_targets(); if !book.is_empty() { + let (open, filtering) = self.recipe_book_flags().unwrap_or_default(); v["recipe_book"] = json!({ "targets": book.iter().map(|t| t.rect).collect::>(), "known": self.recipe_book_known(), + "open": open, + "filtering": filtering, }); } v["slot_ids"] = json!( diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index bed835663..ce9410ba5 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -131,7 +131,8 @@ impl Game<'_> { let m = &mut self.mobs[i]; m.extra.ints[1] -= 1; m.extra.ints[3] = 60; - self.spawn_item(ItemStack::new(&item, 1), from, v, 40); + // ItemEntity.setDefaultPickUpDelay. + self.spawn_item(ItemStack::new(&item, 1), from, v, 10); if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 { const PITCHES: [f32; 16] = [ 0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5, @@ -160,7 +161,7 @@ impl Game<'_> { } // Allay.duplicateAllay: entity event 18, three spawnHeartParticle. let id = self.mobs[index].id; - self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY); + self.mob_event_particles(id, 18); self.audio .play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0); self.consume_held(); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index f175af401..8cd8be53a 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -453,6 +453,11 @@ impl Game<'_> { } // ArmorStand.swapItem. match held { + // A player with infinite materials puts a copy of one on an empty + // slot and keeps the stack. + Some(h) if self.creative && current.is_none() => { + self.armor_stands[i].equipment[slot] = Some(ItemStack { count: 1, ..h }); + } Some(h) if h.count > 1 => { if current.is_some() { return true; @@ -706,6 +711,20 @@ impl Game<'_> { let color = undyed.map_or(0xFFFF_FFFF, |c| { 0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF }); + // ArmorStandRenderer's HumanoidArmorLayer: the armour glint + // when `stack.hasFoil()`, as on a living entity. + let item_glint = self + .res + .items() + .enchantments + .props + .get(&stack.item) + .is_some_and(|p| p.glint); + let overlay = if stack.has_foil(item_glint) { + horizon_client::mobs::render::ARMOR_GLINT + } else { + 0 + }; let layer_poses = Poses::new(part); horizon_client::mobs::render::draw( part, @@ -714,7 +733,7 @@ impl Game<'_> { uv, color, packed, - 0, + overlay, out, ); // EquipmentLayerRenderer: the trim layer over the armour. @@ -729,7 +748,7 @@ impl Game<'_> { trim, 0xFFFF_FFFF, packed, - 0, + overlay, out, ); } @@ -872,6 +891,7 @@ impl Game<'_> { lights: Some(horizon_client::block_entity_render::diffuse_lights( self.dim == super::Dim::Nether, )), + glint: self.glint(), }; let profile = stack .extra diff --git a/crates/horizon-client/src/game/beehives.rs b/crates/horizon-client/src/game/beehives.rs index b7f7efb1a..e17a8f6a3 100644 --- a/crates/horizon-client/src/game/beehives.rs +++ b/crates/horizon-client/src/game/beehives.rs @@ -3,8 +3,10 @@ //! player breaks the hive or fire reaches it. use super::Game; use horizon_client::{ + Result, beehive::{self, Beehive, HiveHost, Release}, containers::BlockEntity, + items::ItemStack, mobs::{Mob, Who, bee}, }; use horizon_core::{ @@ -194,19 +196,139 @@ impl Game<'_> { } self.anger_nearby_bees(at); } + /// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take + /// the honeycombs and a glass bottle a honey bottle from a full hive + /// (honey level 5), then the honey level resets and, unless a campfire + /// smokes the hive, the bees come out angry. True when it took the click. + pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result { + let Some(held) = held else { return Ok(false) }; + let shears = &*held.item == "minecraft:shears"; + if !shears && &*held.item != "minecraft:glass_bottle" { + return Ok(false); + } + let state = self.world.block(pos[0], pos[1], pos[2]); + if !matches!( + &*self.res.facts().get(state).name, + "minecraft:beehive" | "minecraft:bee_nest" + ) { + return Ok(false); + } + let prop = |key: &str| { + self.pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get(key).cloned()) + }; + if prop("honey_level").and_then(|v| v.parse::().ok()) != Some(5) { + return Ok(false); + } + let facing = beehive::facing_step(prop("facing").as_deref()); + let at = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]); + if shears { + // dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak. + for stack in self.sequence_loot( + "minecraft:harvest/beehive", + "minecraft:harvest/beehive", + Some(held), + ) { + self.pop_resource(pos, stack); + } + self.audio + .play("minecraft:block.beehive.shear", at, 1.0, 1.0); + self.damage_held(1); + self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None); + } else { + // shrink(1), then the honey bottle in the hand, the inventory or + // on the ground. + self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0); + let cell = self.held_mut(); + if let Some(s) = cell.as_mut() { + s.count -= 1; + if s.count == 0 { + *cell = None; + } + } + let bottle = ItemStack::new("minecraft:honey_bottle", 1); + if self.held().is_none() { + *self.held_mut() = Some(bottle); + } else if let Some(rest) = self.give(bottle) + && let Some(eye) = self.eye_position() + { + self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + } + self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None); + } + self.stats.add("used", &held.item, 1); + let smokey = beehive::smokey(&self.mob_world_base(&[]), pos); + let key = (pos[0], pos[1], pos[2]); + if !smokey + && matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty()) + { + self.anger_nearby_bees(pos); + } + // resetHoneyLevel. + if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) { + self.level_set(pos, reset)?; + } + if !smokey + && let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key) + { + // emptyAllLivingFromHive: the bees within 4 blocks of the player + // go for them. + let released = hive.release_all( + pos, + facing, + None, + Release::Emergency, + &mut GameHive { game: self }, + ); + self.world + .block_entities + .put(key, BlockEntity::Beehive(hive)); + if let Some(p) = self.player_view().map(|p| p.position) { + for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) { + if m.distance_sqr(p) <= 16.0 { + m.ai.target = Some(Who::Player); + } + } + } + } + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, held); + Ok(true) + } /// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down) - /// without a target go for a player in the same box. - fn anger_nearby_bees(&mut self, at: [i32; 3]) { + /// without a target go for a random player in the same box, the window's + /// own or a guest (`None` is the window's player). + pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) { let min = Vec3::new( f64::from(at[0]) - 8.0, f64::from(at[1]) - 6.0, f64::from(at[2]) - 8.0, ); let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0)); - if !self + let mut players: Vec> = Vec::new(); + if self .player_view() .is_some_and(|p| p.bounds.intersects(area)) { + players.push(None); + } + if let Some(host) = self.lan.as_ref() { + players.extend( + host.hub + .guests + .iter() + .filter(|g| host.hub.here(g) && g.loaded && !g.dead) + .filter(|g| super::lan_mobs::guest_bounds(g).intersects(area)) + .map(|g| Some(g.conn)), + ); + } + if players.is_empty() { return; } let mut bees: Vec = (0..self.mobs.len()) @@ -219,9 +341,20 @@ impl Game<'_> { .collect(); bees.retain(|&i| self.mobs[i].ai.target.is_none()); for i in bees { - // Util.getRandom over the one player. - let _ = self.world.random.next_int_bound(1); + // Util.getRandom over the players. + let pick = self + .world + .random + .next_int_bound(players.len() as i32) + .unwrap_or(0) as usize; self.mobs[i].ai.target = Some(Who::Player); + // The player a mob sees is its guest binding, else the window's. + if let Some(host) = self.lan.as_mut() { + match players[pick] { + Some(conn) => host.mob_players.insert(self.mobs[i].id, conn), + None => host.mob_players.remove(&self.mobs[i].id), + }; + } } } } diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 3d452596e..2705d8388 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -5,10 +5,14 @@ //! [`Game::block_anim`] and [`Game::chest_lid`]. use super::Game; use horizon_client::{ - block_entity_anim::{BellEntity, EndGateway, Shulker, ShulkerStatus, Ticker}, + block_entity_anim::{ + BellEntity, ConduitServer, ConduitTarget, EndGateway, Shulker, ShulkerStatus, Ticker, + }, block_entity_render::BlockAnim, collisions::{SceneCollisions, no_collision_empty}, containers::BlockEntity, + effects::EffectInstance, + mobs::{Damage, DamageKind}, particles::{ParticleOptions, ParticleType}, save::number, skins, @@ -16,6 +20,7 @@ use horizon_client::{ use horizon_core::{ geometry::{Aabb, Vec3}, position::BlockPos, + random::RandomSource, }; use horizon_world::nbt::Tag; @@ -71,23 +76,24 @@ impl Game<'_> { /// A player opened (`open`) or closed the container at `key`: the opener /// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity` /// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes, - /// on both halves of a double chest (`CompoundContainer`). - pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) { + /// on both halves of a double chest (`CompoundContainer`). True when the + /// count went from 0 to 1 or back to 0: the only moments the container + /// plays its open or close sound. + pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool { // Spectators never count as openers. - if !self.spectator() { - self.container_openers_changed(key, open); - } + !self.spectator() && self.container_openers_changed(key, open) } /// `container_opener_event` for a player who is not a spectator (a guest). - pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let pos = [key.0, key.1, key.2]; let state = self.world.block(pos[0], pos[1], pos[2]).0; match self.be_anims.ticker(state) { Some(Ticker::Lid) => { - self.lid_openers_changed(key, open); + let edge = self.lid_openers_changed(key, open); if let Some(other) = self.chest_other_half(key) { self.lid_openers_changed(other, open); } + edge } _ if self .res @@ -97,26 +103,27 @@ impl Game<'_> { .as_ref() == "minecraft:barrel" => { - self.barrel_openers_changed(key, open); + self.barrel_openers_changed(key, open) } Some(Ticker::Shulker { .. }) => { let shulker = self.be_anims.shulkers.entry(pos).or_default(); - let (count, _) = if open { + let (count, edge) = if open { shulker.start_open() } else { shulker.stop_open() }; self.block_entity_event(pos, 1, count); self.lan_block_event(pos, 1, count); + edge } - _ => {} + _ => false, } } /// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the /// chest at `key` (the host and the guests are counted), then /// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here - /// and for the guests. - fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + /// and for the guests. True when the count crossed zero. + fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let (before, count) = self.count_opener(key, open); let pos = [key.0, key.1, key.2]; self.block_entity_event(pos, 1, count); @@ -134,12 +141,15 @@ impl Game<'_> { b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block); }); } + (before == 0) != (count == 0) } /// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel /// (`open=true`), the last one to leave closes it (`updateBlockState`). - fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) { + /// True when the count crossed zero. + fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool { let (before, count) = self.count_opener(key, open); - if (before == 0) != (count == 0) { + let edge = (before == 0) != (count == 0); + if edge { self.set_bool_prop( key, "open", @@ -147,6 +157,7 @@ impl Game<'_> { horizon_world::level::flags::UPDATE_ALL, ); } + edge } /// One opener more (`open`) or less at `key`: the count before and after /// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`). @@ -218,9 +229,12 @@ impl Game<'_> { /// The client tickers of the loaded animated block entities, once per /// client tick (`ClientLevel.tickBlockEntities`). pub(super) fn tick_block_entity_anims(&mut self) { + // A server has no window, but its gateways and conduits still run + // their server tickers; a client of one only animates what it is sent. + if self.remote.is_none() { + self.tick_conduit_servers(); + } let Some(r) = &self.renderer else { - // A server has no window, but its gateways still run their - // server ticker. if self.remote.is_none() { self.tick_gateway_servers(); } @@ -337,6 +351,7 @@ impl Game<'_> { } } Ticker::Conduit => { + let target = self.conduit_target(pos); let world = &self.world; let facts = self.res.facts(); let registry = self.pack.assets.registry; @@ -356,7 +371,7 @@ impl Game<'_> { time, is_water, is_frame, - None, + target, self.particles.random(), ); let nautilus = ParticleOptions::simple(ParticleType::Nautilus); @@ -383,6 +398,248 @@ impl Game<'_> { } self.be_anims.present = positions; } + /// `ConduitBlockEntity.serverTick` of every conduit in the chunks that + /// tick blocks, with or without a renderer. The conduits are looked for + /// when the shape is checked, every 40 ticks (they have no block entity + /// of their own here). + fn tick_conduit_servers(&mut self) { + if self.world.game_time % 40 == 0 { + self.find_conduits(); + } + let mut positions: Vec<[i32; 3]> = self.be_anims.conduit_servers.keys().copied().collect(); + positions.sort_unstable(); + for pos in positions { + self.tick_conduit_server(pos); + } + } + /// Sets `be_anims.conduit_servers` to the conduits in the + /// chunks that tick blocks, keeping the state of those already known. + fn find_conduits(&mut self) { + let levels = self.tickets.levels(); + let kinds = &self.be_anims.kinds; + let is_conduit = |id: u32| matches!(kinds.get(id as usize), Some(Some(Ticker::Conduit))); + let mut found = Vec::new(); + for (&(cx, cz), chunk) in &self.world.chunks { + if !levels.block_ticking_at(cx * 16, cz * 16) { + continue; + } + for (si, section) in chunk.sections.iter().enumerate() { + if !section.blocks.maybe_has(is_conduit) { + continue; + } + for i in 0..4096 { + if is_conduit(section.get(i)) { + found.push([ + cx * 16 + (i & 15) as i32, + horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32, + cz * 16 + ((i >> 4) & 15) as i32, + ]); + } + } + } + } + let mut old = std::mem::take(&mut self.be_anims.conduit_servers); + for pos in found { + let known = old.remove(&pos).unwrap_or_default(); + self.be_anims.conduit_servers.insert(pos, known); + } + } + /// `ConduitBlockEntity.serverTick` of the conduit at `pos`, in a chunk + /// that ticks blocks: the shape check every 40 ticks with its sounds, the + /// power for players in range, the hurt for an enemy, then the ambient + /// sounds. + fn tick_conduit_server(&mut self, pos: [i32; 3]) { + let state = self.world.block(pos[0], pos[1], pos[2]).0; + if self.be_anims.ticker(state) != Some(Ticker::Conduit) { + // Broken since the chunks were searched. + self.be_anims.conduit_servers.remove(&pos); + return; + } + if !self.tickets.levels().block_ticking_at(pos[0], pos[2]) + || !self.border.contains(f64::from(pos[0]), f64::from(pos[2])) + { + return; + } + let time = self.world.game_time; + let mut conduit = self + .be_anims + .conduit_servers + .remove(&pos) + .unwrap_or_default(); + let check = { + let world = &self.world; + let facts = self.res.facts(); + let registry = self.pack.assets.registry; + let frames = self + .res + .items() + .block_tags + .get("minecraft:conduit_effect_block"); + // Level.isWaterAt: the fluid at the position is water. + let is_water = |p: [i32; 3]| { + facts.get(world.block(p[0], p[1], p[2])).fluid + == horizon_client::blocks::Fluid::Water + }; + let is_frame = |p: [i32; 3]| { + registry + .state(world.block(p[0], p[1], p[2])) + .is_some_and(|s| frames.is_some_and(|f| f.contains(&s.name))) + }; + conduit.check(pos, time, is_water, is_frame) + }; + let centre = [ + f64::from(pos[0]) + 0.5, + f64::from(pos[1]) + 0.5, + f64::from(pos[2]) + 0.5, + ]; + if let Some(check) = check { + if let Some(active) = check.toggled { + let sound = if active { + "minecraft:block.conduit.activate" + } else { + "minecraft:block.conduit.deactivate" + }; + self.audio.play(sound, Some(centre), 1.0, 1.0); + } + if let Some(frames) = check.frames { + self.conduit_apply_effects(pos, frames); + self.conduit_attack(&mut conduit, pos, frames >= 42); + } + } + let (ambient, short) = conduit.ambient(time, &mut self.world.random); + if ambient { + self.audio + .play("minecraft:block.conduit.ambient", Some(centre), 1.0, 1.0); + } + if short { + self.audio.play( + "minecraft:block.conduit.ambient_short", + Some(centre), + 1.0, + 1.0, + ); + } + self.be_anims.conduit_servers.insert(pos, conduit); + } + /// `ConduitBlockEntity.applyEffects`: conduit power for every player + /// in water or rain within `frames / 7 * 16` blocks. + fn conduit_apply_effects(&mut self, pos: [i32; 3], frames: usize) { + let range = (frames / 7 * 16) as i64; + self.for_each_player(|g| { + let Some(p) = g.player.as_ref().filter(|p| !p.health.dead_or_dying()) else { + return; + }; + if g.spectator() { + return; + } + let at = p.position; + let in_water = p.fluids.was_touching_water; + let top = at.y + f64::from(p.dimensions.height); + // BlockPos.closerThan: the squared distance under range squared. + let d = [at.x, at.y, at.z] + .map(|v| v.floor() as i64) + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::(); + if d >= range * range || !(in_water || g.in_rain(at, top)) { + return; + } + let power = EffectInstance { + ambient: true, + ..EffectInstance::new("conduit_power", 260, 0) + }; + g.potions.effects.add(power); + }); + } + /// `Entity.isInRain`: rain at the block position or at the top of the box. + pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool { + let p = BlockPos::new( + at.x.floor() as i32, + at.y.floor() as i32, + at.z.floor() as i32, + ); + let up = BlockPos::new(p.x, top.floor() as i32, p.z); + self.with_level(|b, level, _| b.is_raining_at(level, p) || b.is_raining_at(level, up)) + } + /// `ConduitBlockEntity.updateAndAttackTarget`: while the conduit hunts + /// (42 frame blocks) its target stays until it dies or leaves the 8 + /// blocks, else a random enemy in water or rain is chosen; the target + /// takes 4 magic damage. + fn conduit_attack(&mut self, conduit: &mut ConduitServer, pos: [i32; 3], hunting: bool) { + conduit.target = match conduit.target { + _ if !hunting => None, + None => self.conduit_new_target(pos), + Some(id) => self + .mobs + .iter() + .find(|m| m.id == id && !m.removed) + .filter(|m| { + let at = [m.position.x, m.position.y, m.position.z].map(|v| v.floor() as i64); + m.alive() + && at + .iter() + .zip(pos) + .map(|(a, b)| (a - i64::from(b)).pow(2)) + .sum::() + < 64 + }) + .map(|m| m.id), + }; + let Some(m) = conduit + .target + .and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) + else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + m.damage(4.0, Damage::of(DamageKind::Magic)); + self.audio + .play("minecraft:block.conduit.attack.target", Some(at), 1.0, 1.0); + } + /// `ConduitBlockEntity.selectNewTarget`: one of the enemies in water or + /// rain whose box meets the conduit's block inflated by 8. + fn conduit_new_target(&mut self, pos: [i32; 3]) -> Option { + let [x, y, z] = pos.map(f64::from); + let area = Aabb::new( + Vec3::new(x - 8.0, y - 8.0, z - 8.0), + Vec3::new(x + 9.0, y + 9.0, z + 9.0), + ); + let near: Vec<(u32, bool, Vec3, f64)> = self + .mobs + .iter() + .filter(|m| m.hostile() && !m.removed && m.bounds().intersects(area)) + .map(|m| { + let top = m.position.y + f64::from(m.height()); + (m.id, m.fluids.in_water, m.position, top) + }) + .collect(); + let candidates: Vec = near + .into_iter() + .filter(|&(_, in_water, at, top)| in_water || self.in_rain(at, top)) + .map(|(id, ..)| id) + .collect(); + if candidates.is_empty() { + return None; + } + let pick = self + .world + .random + .next_int_bound(candidates.len() as i32) + .unwrap_or(0); + Some(candidates[pick as usize]) + } + /// The conduit's `destroyTarget` as the client draws it. + fn conduit_target(&self, pos: [i32; 3]) -> Option { + let id = self.be_anims.conduit_servers.get(&pos)?.target?; + let m = self.mobs.iter().find(|m| m.id == id && !m.removed)?; + Some(ConduitTarget { + position: [m.position.x, m.position.y, m.position.z], + eye_y: m.eye_y(), + width: m.width(), + height: m.height(), + }) + } /// `TheEndGatewayBlockEntity.portalTick` (the server ticker) of the /// gateways a server keeps a copy of, in block-ticking chunks /// (`Level.tickBlockEntities`): the cooldown an entry started runs out, @@ -474,21 +731,38 @@ impl Game<'_> { /// the player head at `pos`. The skin download is started here; while it /// is in flight (or when it cannot be got) the profile's default skin /// stands in. A head with no profile answers None: `SKIN_BY_TYPE`. - pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> { + pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option { let Some(BlockEntity::Skull(skull)) = self.world.block_entities.get(&(pos[0], pos[1], pos[2])) else { return None; }; let skin = skull.skin.as_ref()?; - if let Some((url, location)) = &skin.download { - self.skin_downloads - .borrow_mut() - .state(skins::Kind::Skin, url); - if self.gui.atlas.sprites.contains_key(location) { + // The texture a packed or resolved `textures` property fetches: the + // download of a static profile, or the one the services answer for a + // `Dynamic` (name- or id-only) profile. + let download = skin.download.clone().or_else(|| { + let key = skin.dynamic.as_ref()?; + let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key) + else { + return None; + }; + let (value, signature) = profile.textures()?; + let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?; + let location = skins::skin_location(&skins::texture_hash(&url)?); + Some((url, location)) + }); + if let Some((url, location)) = download { + let mut downloads = self.skin_downloads.borrow_mut(); + if self.gui.atlas.sprites.contains_key(&location) { + let _ = downloads.state(skins::Kind::Skin, &url); return Some(location); } + // Its slot was evicted: offer the image again. + if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready { + downloads.again(&location); + } } - Some(skin.fallback) + Some(skin.fallback.to_owned()) } } diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index e0798b4cb..2778bb531 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -10,6 +10,7 @@ use horizon_client::{ block_entities::{Pos, Ticking}, containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS}, }; +use horizon_core::position::BlockPos; use horizon_world::registry::BlockStateId; impl Game<'_> { @@ -56,6 +57,7 @@ impl Game<'_> { Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos), Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?, Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?, + Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos), Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?, _ => {} } @@ -94,6 +96,7 @@ impl Game<'_> { | "minecraft:beehive" | "minecraft:bee_nest" | "minecraft:brewing_stand" + | "minecraft:daylight_detector" // Lit, cookTick; unlit, cooldownTick. | "minecraft:campfire" | "minecraft:soul_campfire" => Ticking::Yes, @@ -117,6 +120,16 @@ impl Game<'_> { let levels = self.tickets.levels(); move |x, z| levels.block_ticking_at(x, z) } + /// `DaylightDetectorBlock.tickEntity` of the detector at `pos`. + fn tick_daylight_detector(&mut self, pos: Pos) { + // It updates every 20 game ticks (the behaviour checks this too). + if self.world.game_time % 20 != 0 { + return; + } + let at = BlockPos::new(pos.0, pos.1, pos.2); + self.with_level(|b, level, ticks| b.tick_daylight_detector(level, ticks, at)); + let _ = self.process_level_events(); + } /// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and /// its block's `lit` following it. fn tick_furnace(&mut self, pos: Pos) -> Result<()> { diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index 06dfab22d..7e80f6f58 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -862,7 +862,7 @@ impl Game<'_> { let a = alpha as f32; let range = super::entity_culling::default_range( glam::DVec3::new(WIDTH, HEIGHT, WIDTH), - self.settings.entity_distance, + self.view_scale(), ); for b in &self.boats { let entity_start = out.len(); diff --git a/crates/horizon-client/src/game/books.rs b/crates/horizon-client/src/game/books.rs index 2f38dffd9..ddbe47481 100644 --- a/crates/horizon-client/src/game/books.rs +++ b/crates/horizon-client/src/game/books.rs @@ -8,7 +8,7 @@ use horizon_client::{ containers::{BlockEntity, Lectern}, first_person::Hand, hud::{HudBatch, rgba}, - item_data::WrittenBook, + item_data::{StackData, WrittenBook}, items::ItemStack, menu::{Kind, Menu}, text::Text, @@ -59,14 +59,86 @@ pub(super) enum BookScreen { page_edit: ui::EditBox, }, View { - pages: Vec, + pages: Vec, page: usize, lectern: Option<(i32, i32, i32)>, }, } +/// The pages a book screen reads: a written book's components (so their +/// click events stay), else a writable book's text. +fn view_pages(data: Option<&StackData>) -> Vec { + let Some(data) = data else { return Vec::new() }; + let Some(w) = &data.written else { + return data.writable_pages.iter().map(Text::literal).collect(); + }; + let tagged = w.page_tags.len() == w.pages.len(); + w.pages + .iter() + .enumerate() + .map(|(i, page)| match tagged.then(|| &w.page_tags[i]) { + Some(tag @ horizon_world::nbt::Tag::Compound(_)) => { + horizon_client::entity_nbt::name_text(tag) + } + _ => Text::literal(page), + }) + .collect() +} impl Game<'_> { fn book_lines(&self, page: &str) -> Vec { - self.gui.atlas.font.split(&Text::literal(page), 114.0) + self.page_lines(&Text::literal(page)) + } + fn page_lines(&self, page: &Text) -> Vec { + self.gui.atlas.font.split(page, 114.0) + } + /// `BookViewScreen.mouseClicked`: the click event of the text under the + /// cursor on the page. + fn book_link_at(&self, page: &Text, left: f32) -> Option { + let (mx, my) = self.cursor; + let row = ((my - 32.0) / 9.0).floor(); + if row < 0.0 || row >= LINES as f32 { + return None; + } + let lines = self.page_lines(page); + let line = lines.get(row as usize)?.resolve_legacy(); + let mut x = mx - (left + 36.0); + if x < 0.0 { + return None; + } + for (c, style) in line.chars() { + x -= self.gui.atlas.font.advance(c, style); + if x < 0.0 { + return style.click.clone(); + } + } + None + } + /// `BookViewScreen.handleClickEvent`: a page link turns to its page + /// (`forcePage`), a web link goes through the confirm-link flow. + fn book_follow_link( + &mut self, + link: horizon_client::text::ClickEvent, + count: usize, + lectern: Option<(i32, i32, i32)>, + ) { + use horizon_client::text::ClickEvent; + match link { + ClickEvent::ChangePage(target) => { + let target = (target - 1).clamp(0, count.saturating_sub(1) as i32); + let Some(BookScreen::View { page, .. }) = self.book.as_mut() else { + return; + }; + if let Some(key) = lectern { + // LecternScreen.forcePage: the menu's button 100 + page. + if target != *page as i32 { + self.lectern_button(key, 100 + target); + } + } else if target != *page as i32 { + *page = target as usize; + } + } + ClickEvent::OpenUrl(url) => self.click_url(url), + _ => {} + } } /// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`). /// A server's player writes in its own client's editor @@ -115,11 +187,7 @@ impl Game<'_> { } } "minecraft:written_book" => BookScreen::View { - pages: stack - .data() - .and_then(|d| d.written.as_ref()) - .map(|w| w.pages.clone()) - .unwrap_or_default(), + pages: view_pages(stack.data()), page: 0, lectern: None, }, @@ -215,6 +283,7 @@ impl Game<'_> { author, generation: 0, pages, + page_tags: Vec::new(), }); *stack = written; } else { @@ -685,11 +754,7 @@ impl Game<'_> { return false; }; let Some(book) = &l.book else { return false }; - let data = book.data(); - let pages = data - .and_then(|d| d.written.as_ref().map(|w| w.pages.clone())) - .or_else(|| data.map(|d| d.writable_pages.clone())) - .unwrap_or_default(); + let pages = view_pages(book.data()); let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1)); self.stats.custom("interact_with_lectern", 1); self.open_book(BookScreen::View { @@ -811,11 +876,7 @@ impl Game<'_> { page, }) = self.book.as_mut() { - let data = book.as_ref().and_then(ItemStack::data); - *pages = data - .and_then(|d| d.written.as_ref().map(|w| w.pages.clone())) - .or_else(|| data.map(|d| d.writable_pages.clone())) - .unwrap_or_default(); + *pages = view_pages(book.as_ref().and_then(ItemStack::data)); *page = (*page).min(pages.len().saturating_sub(1)); } } @@ -955,7 +1016,10 @@ impl Game<'_> { lectern, }) => { let (page, count, lectern) = (*page, pages.len(), *lectern); - if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) { + // The text's click event first, then the buttons. + if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) { + self.book_follow_link(link, count, lectern); + } else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) { self.turn_page(true); } else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) { self.turn_page(false); @@ -1009,9 +1073,12 @@ impl Game<'_> { } }; let indicator = |batch: &mut HudBatch, page: usize, count: usize| { - let text = tr("book.pageIndicator", "Page %s of %s") - .replacen("%s", &(page + 1).to_string(), 1) - .replacen("%s", &count.max(1).to_string(), 1); + // BookViewScreen.getPageNumberMessage: 26.3's en_us template is + // positional ("Page %1$s of %2$s"). + let text = horizon_protocol::text::format_java( + &tr("book.pageIndicator", "Page %1$s of %2$s"), + &[(page + 1).to_string(), count.max(1).to_string()], + ); let tw = HudBatch::text_width(atlas, &text); batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false); }; @@ -1195,7 +1262,7 @@ impl Game<'_> { } => { indicator(batch, *page, pages.len()); if let Some(text) = pages.get(*page) { - for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() { + for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() { batch.text_styled( atlas, line, diff --git a/crates/horizon-client/src/game/bundles.rs b/crates/horizon-client/src/game/bundles.rs new file mode 100644 index 000000000..198964803 --- /dev/null +++ b/crates/horizon-client/src/game/bundles.rs @@ -0,0 +1,103 @@ +//! `BundleMouseActions`: the wheel over a bundle in a container screen selects +//! one of the items it shows, which a click that takes one out of it then takes +//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the +//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`). +use super::{Game, Screen}; +use horizon_client::{ + bundle::{Contents, toggle_selected}, + mouse::ScrollWheelHandler, +}; +use horizon_protocol::packets::play::{self, Serverbound as S}; + +#[derive(Default)] +pub(super) struct BundleMouse { + wheel: ScrollWheelHandler, + /// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`). + hovered: Option, +} + +impl Game<'_> { + /// A slot a bundle can be selected in: the player's inventory, the off hand and a + /// container's own slots. A server only knows these in the inventory and chest-like menus. + fn bundle_slot(&mut self, slot: usize) -> bool { + (slot < 36 || slot == 504 || (300..500).contains(&slot)) + && (self.remote.is_none() || self.remote_storage_open()) + && (slot < 36 || slot == 504 || self.slots().contains(&slot)) + } + + /// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection + /// through the items its tooltip shows. True when the bundle took the wheel. + pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool { + let Some(slot) = self.inventory_target().0 else { + return false; + }; + if !self.bundle_slot(slot) { + return false; + } + let stack = self.slot_mut(slot).clone(); + let items = self.res.items(); + let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else { + return false; + }; + let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count()); + if shown == 0 { + return false; + } + let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y); + let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y }; + if wheel != 0 { + let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0); + let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32); + if next != selected { + self.select_bundle(slot, next); + } + } + true + } + + /// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there. + fn select_bundle(&mut self, slot: usize, index: i32) { + if let Some(stack) = self.slot_mut(slot) { + toggle_selected(stack, index); + } + if self.remote.is_some() { + let (_, menu) = self.remote_menu(); + if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) { + self.send(S::SelectBundleItem(play::SelectBundleItem { + slot: menu_slot as i32, + index, + })); + } + } + } + + /// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection. + pub(super) fn unselect_bundle(&mut self, slot: usize) { + if !self.bundle_slot(slot) { + return; + } + let selected = self + .slot_mut(slot) + .as_ref() + .and_then(|s| s.data()) + .is_some_and(|d| d.bundle_selected.0 >= 0); + if selected { + self.select_bundle(slot, -1); + } + } + + /// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the + /// screen, clears the bundle's selection. + pub(super) fn tick_bundle_hover(&mut self) { + let now = if self.screen == Screen::Inventory { + self.inventory_target().0 + } else { + None + }; + if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now) + && Some(left) != now + { + self.unselect_bundle(left); + } + } +} diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 03174c009..7344c0e9d 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -695,12 +695,78 @@ impl Game<'_> { Some(ClickEvent::OpenFile(path)) => { super::packs::open_folder(std::path::Path::new(&path)); } + Some(ClickEvent::OpenUrl(url)) => self.click_url(url), Some(ClickEvent::SuggestCommand(command)) => { if let Some(screen) = self.ui.chat_screen.as_mut() { screen.suggest_command(&m, &c, &command); } } - None => {} + // Screen.defaultHandleClickEvent: a page link means nothing in chat. + Some(ClickEvent::ChangePage(_)) | None => {} + } + } + /// `Screen.clickUrlAction`: nothing with Web Links off, else the link + /// opens, after the confirmation unless Prompt on Links is off. + pub(super) fn click_url(&mut self, url: String) { + if !self.settings.chat_links { + return; + } + if self.settings.file.bool("chatLinksPrompt") { + self.link_prompt = Some(url); + } else { + super::packs::open_uri(&url); + } + } + /// `ConfirmLinkScreen` (untrusted): the link and its warning over the + /// chat, answered yes to open it. + fn link_prompt_layout( + &self, + url: &str, + size: (f32, f32), + ) -> horizon_client::confirm_screen::Confirm { + let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC); + let message = Text::literal(url) + .append(Text::literal("\n")) + .append(warning); + horizon_client::confirm_screen::Confirm::new( + &self.gui.atlas.font, + size, + &Text::translate("chat.link.confirm", &[]), + &message, + ) + } + pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) { + let Some(url) = &self.link_prompt else { + return; + }; + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let (yes, no) = ( + Text::translate("gui.yes", &[]), + Text::translate("gui.no", &[]), + ); + self.link_prompt_layout(url, (w, h)) + .draw(batch, atlas, self.cursor, [&yes, &no]); + } + /// The prompt's answer: the link opens on yes; either way the chat is back. + pub(super) fn answer_link_prompt(&mut self, yes: bool) { + if let Some(url) = self.link_prompt.take() + && yes + { + super::packs::open_uri(&url); + } + } + pub(super) fn link_prompt_click(&mut self) { + let Some(url) = &self.link_prompt else { + return; + }; + let answer = self + .link_prompt_layout(url, self.gui_size()) + .answer_at(self.cursor); + if let Some(yes) = answer { + self.audio.play_button_click(); + self.answer_link_prompt(yes); } } /// Chat lines over the HUD (focused while the chat screen is open). @@ -789,6 +855,11 @@ impl Game<'_> { pub(super) fn sync_rules(&mut self) { self.world.health_rules.natural_regeneration = self.ui.rules.bool("natural_health_regeneration"); + // Player.isInvulnerableTo: the damage-type rules. + self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage"); + self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage"); + self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage"); + self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage"); use horizon_world::food::Difficulty; self.world.health_rules.difficulty = match self.ui.difficulty { 0 => Difficulty::Peaceful, @@ -801,7 +872,11 @@ impl Game<'_> { pub(super) fn load_world_state(&mut self) { self.ui.rules = GameRules::load(&self.world_root); self.ui.clock = WorldClock::default(); - self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2); + // PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings. + self.ui.difficulty = horizon_client::title::DIFFICULTIES + .iter() + .position(|d| *d == self.level.difficulty) + .map_or(2, |i| i as u8); if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat")) && let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20) && let Ok((root, _)) = @@ -826,9 +901,9 @@ impl Game<'_> { self.build_registries(); self.sync_rules(); } - /// Save the clock and the difficulty beside level.dat (Level.write saves - /// the game rules: a second writer of game_rules.dat could leave it with - /// the rules loaded when the world opened). + /// Save the clock beside level.dat (Level.write saves the game rules: a + /// second writer of game_rules.dat could leave it with the rules loaded + /// when the world opened). pub(super) fn save_world_state(&mut self) { if self.world_root.as_os_str().is_empty() { return; @@ -876,9 +951,6 @@ impl Game<'_> { if let Err(e) = horizon_client::save::write_dat(&path, root) { log::warn!("the world clocks were not written: {e}"); } - if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) { - log::warn!("the difficulty was not written: {e}"); - } } /// Death message in chat (CombatTracker.getDeathMessage, as far as the /// cause is known to the client). @@ -988,34 +1060,6 @@ pub fn screenshot_name(dir: &std::path::Path) -> String { n += 1; } } -fn read_difficulty(world: &std::path::Path) -> Option { - let bytes = std::fs::read(world.join("level.dat")).ok()?; - let raw = horizon_world::compression::Compression::Gzip - .decode(&bytes, 64 << 20) - .ok()?; - let (root, _) = - horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?; - match root.tag.get("Data")?.get("Difficulty")? { - horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8), - _ => None, - } -} -/// Patch `Data.Difficulty` in level.dat (Level::write stores the default). -fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> { - use horizon_world::{compression::Compression, nbt}; - let path = world.join("level.dat"); - let bytes = std::fs::read(&path)?; - let raw = Compression::Gzip.decode(&bytes, 64 << 20)?; - let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?; - if let Some(data) = root.tag.get_mut("Data") - && let Some(d) = data.get_mut("Difficulty") - { - *d = nbt::Tag::Byte(difficulty as i8); - } - let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?; - std::fs::write(&path, Compression::Gzip.encode(&out)?)?; - Ok(()) -} /// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when /// commands are allowed, else `ALL` (0). diff --git a/crates/horizon-client/src/game/client_ambient.rs b/crates/horizon-client/src/game/client_ambient.rs index a10c6da49..1cf59733d 100644 --- a/crates/horizon-client/src/game/client_ambient.rs +++ b/crates/horizon-client/src/game/client_ambient.rs @@ -4,8 +4,12 @@ //! on the game that has the window, in-frame or following a server, never on //! a server: nothing of them goes over the network. use super::Game; +use horizon_client::blocks::RenderShape; use horizon_client::mobs::{MobEvent, ambient}; -use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_client::motion::{LAVA, WATER}; +use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType}; +use horizon_client::pose::Pose; +use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { pub(super) fn tick_client_ambient(&mut self) { @@ -33,5 +37,209 @@ impl Game<'_> { } } self.eye_particles(); + self.sprint_dust(); + self.effect_swirls(); + } + + /// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the + /// block under a sprinting player's feet streams back from them every + /// tick, for the window's own player and for the other players it sees. + fn sprint_dust(&mut self) { + // Each runner's feet, movement and supporting block. + let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new(); + // Entity.canSpawnSprintParticle. + if let Some(p) = self.player.as_ref() + && p.sprinting + && !p.spectator + && p.pose != Pose::Crouching + && !p.health.dead_or_dying() + && !p.fluids.was_touching_water + && !p.is_in_lava() + && p.fluid_height(WATER) <= 0.0 + && p.fluid_height(LAVA) <= 0.0 + { + let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]); + runners.push((p.position, p.velocity, support)); + } + let guests = self.lan.iter().flat_map(|host| host.models.values()); + for m in self.mobs.iter().chain(guests) { + // Another player's fluids aren't known here: none where they stand. + let feet = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + if m.extra.sprinting + && !m.extra.crouching + && m.alive() + && &*m.kind.id == "minecraft:player" + && self + .res + .facts() + .get(self.world.block(feet[0], feet[1], feet[2])) + .fluid_level + == 0 + { + runners.push((m.position, m.position.subtract(m.previous), None)); + } + } + for (position, movement, support) in runners { + // Entity.getOnPosLegacy: getOnPos(0.2). + let below = (position.y - f64::from(0.2f32)).floor() as i32; + let tall = |game: &Self, s: [i32; 3]| { + let name = game.block_name(s); + [ + "minecraft:fences", + "minecraft:walls", + "minecraft:fence_gates", + ] + .iter() + .any(|tag| game.block_in_tag(tag, &name)) + }; + let at = match support { + Some(s) if tall(self, s) => s, + Some(s) => [s[0], below, s[2]], + None => [position.x.floor() as i32, below, position.z.floor() as i32], + }; + let state = self.world.block(at[0], at[1], at[2]); + if self.res.facts().get(state).render_shape == RenderShape::Invisible { + continue; + } + let Some((sprite, tint)) = self.particle_sprite(state, at) else { + continue; + }; + let width = f64::from(0.6f32); + let r = self.particles.random(); + let mut x = position.x + (r.next_double() - 0.5) * width; + let mut z = position.z + (r.next_double() - 0.5) * width; + // Clamped into the block when the feet are in another column. + if position.x.floor() as i32 != at[0] { + x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0); + } + if position.z.floor() as i32 != at[2] { + z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0); + } + self.particles.add( + &ParticleOptions::block(ParticleType::Block, sprite, tint), + [x, position.y + 0.1, z], + [movement.x * -4.0, 1.5, movement.z * -4.0], + ); + } + } + + /// `LivingEntity.tickEffects` on a client: the swirls around an entity + /// with visible effects, from the particles its synched data lists. The + /// window's player has its own effects, a server's mobs and players the + /// list the server sent, and the mobs and guests of a game in the frame + /// theirs. + fn effect_swirls(&mut self) { + if let Some(p) = self.player.as_ref() { + let effects = &self.potions.effects.list; + let list: Vec<_> = effects + .iter() + .filter(|e| e.visible) + .filter_map(|e| effect_particle(e.id, e.ambient)) + .collect(); + let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient); + let invisible = effects.iter().any(|e| e.id == "invisibility"); + let size = ( + f64::from(p.dimensions.width), + f64::from(p.dimensions.height), + ); + swirl( + &mut self.particles, + p.position, + size, + &list, + ambient, + invisible, + ); + } + let guests = self.lan.iter().flat_map(|host| host.models.values()); + for m in self.mobs.iter().chain(guests) { + if !m.alive() { + continue; + } + let size = (f64::from(m.width()), f64::from(m.height())); + let invisible = m.invisible || m.has_effect("minecraft:invisibility"); + if m.extra.effect_particles.is_empty() { + // A mob of this game: its own effects, visible and not ambient. + if m.extra.effects.is_empty() { + continue; + } + let list: Vec<_> = m + .extra + .effects + .iter() + .filter(|e| !m.extra.hidden_effects.contains(&e.0)) + .filter_map(|e| effect_particle(e.0, false)) + .collect(); + swirl( + &mut self.particles, + m.position, + size, + &list, + false, + invisible, + ); + } else { + let list = &m.extra.effect_particles; + let ambient = m.extra.effect_ambient; + swirl( + &mut self.particles, + m.position, + size, + list, + ambient, + invisible, + ); + } + } } } + +/// `MobEffect.particleFactory`: the omens have particles of their own, any +/// other effect an entity effect in its colour, faint when ambient. +pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option { + Some(match effect.trim_start_matches("minecraft:") { + "trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen), + "raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen), + other => { + let color = horizon_client::brewing::effect(other)?.color; + let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0; + // MobEffect.AMBIENT_ALPHA is 38 of 255. + let alpha = if ambient { 38.0 / 255.0 } else { 1.0 }; + ParticleOptions::colored( + ParticleType::EntityEffect, + [channel(16), channel(8), channel(0), alpha], + ) + } + }) +} + +/// One tick of `LivingEntity.tickEffects`' client branch: a one in four +/// chance (one in fifteen while invisible, a fifth of either when all the +/// effects are ambient) of one of the particles at a random spot of the box. +fn swirl( + particles: &mut ParticleEngine, + feet: Vec3, + size: (f64, f64), + list: &[ParticleOptions], + ambient: bool, + invisible: bool, +) { + if list.is_empty() { + return; + } + let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 }; + let r = particles.random(); + let Ok(0) = r.next_int_bound(bound) else { + return; + }; + // Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ. + let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize; + let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5; + let y = feet.y + size.1 * r.next_double(); + let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5; + particles.add(&list[pick], [x, y, z], [1.0; 3]); +} diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index 106e0e1a1..f125899f3 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -1,7 +1,16 @@ //! The player's `CombatTracker`: the damage taken lately, who dealt it and //! where the player fell from, turned into the vanilla death message. use super::{Game, chat::PLAYER_NAME}; -use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text}; +use horizon_client::{ + health::HealthEvent, + motion::MotionEvent, + particles::{ParticleOptions, ParticleType}, + text::Text, +}; +use horizon_core::random::RandomSource; + +/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage. +pub(super) const DROWN_EVENT: i8 = 67; struct Entry { damage_type: String, @@ -10,6 +19,9 @@ struct Entry { fall_distance: f32, /// The causing entity: its id (for comparisons) and display name. attacker: Option<(u32, Text)>, + /// The direct entity's display name when no entity caused the damage + /// (`DamageSource.directEntity`: a potion with no thrower, a cloud). + direct: Option, } #[derive(Default)] pub(super) struct CombatTracker { @@ -19,6 +31,8 @@ pub(super) struct CombatTracker { /// Attackers of damage dealt this tick, claimed by the matching /// `RecordedDamage` event. pending: Vec<(String, (u32, Text))>, + /// `pending` for a direct entity with no causing entity: only its name. + pending_direct: Vec<(String, Text)>, /// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit). last_hurt_by: Option<((u32, Text), i64)>, /// The mob the player last hurt (`getLastHurtMob`) and when. @@ -31,11 +45,54 @@ pub(super) struct CombatTracker { /// The mob exploding now (`ServerExplosion.source`): its team's friendly /// fire decides whom the blast may hurt. pub(super) explosion_source: Option, + /// The blast now going off comes from a primed TNT, creeper, wither + /// skull, wither or TNT minecart, the sources that empty a hive they + /// destroy (`BeehiveBlock.getDrops`). + pub(super) hive_emptying_blast: bool, + /// The mob that owns the projectile exploding now (`getIndirectSourceEntity`): + /// the mobs the blast hurts record it as their attacker. + pub(super) explosion_owner: Option, + /// Charged creepers whose blast just went off and that have not dropped + /// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list, + /// but the mobs they killed still ask for their loot. + pub(super) charged_exploders: Vec, /// The player's fall distance before this tick's movement (as of the /// end of the last one). fall_before: f64, } impl Game<'_> { + /// `LivingEntity.makeDrownParticles`: eight bubbles around the entity, + /// moving with it. + pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) { + let bubble = ParticleOptions::simple(ParticleType::Bubble); + for _ in 0..8 { + let mut triangle = || { + let r = self.particles.random(); + r.next_double() - r.next_double() + }; + let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()]; + self.particles.add(&bubble, p, velocity); + } + } + /// Entity event 67 from a server, for this client's own player or an + /// entity it draws. + pub(super) fn remote_drown(&mut self, id: i32) { + let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id); + let body = if own { + self.player.as_ref().map(|p| { + let (at, v) = (p.position, p.velocity); + ([at.x, at.y, at.z], [v.x, v.y, v.z]) + }) + } else { + self.mobs.iter().find(|m| m.id == id as u32).map(|m| { + let (at, v) = (m.position, m.velocity); + ([at.x, at.y, at.z], [v.x, v.y, v.z]) + }) + }; + if let Some((at, v)) = body { + self.drown_particles(at, v); + } + } /// The display name of a mob: its custom name or its type's name. pub(super) fn mob_display_name(&self, id: u32) -> Option { let m = self.mobs.iter().find(|m| m.id == id)?; @@ -62,17 +119,38 @@ impl Game<'_> { .pending .push((damage_type.to_owned(), (id, name))); } + /// The `DamageSource.directEntity` of the next `damage_type` damage when + /// nothing caused it (a splash potion nobody threw): the death message + /// names it instead (`getLocalizedDeathMessage`). It is no kill credit. + pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) { + self.combat + .pending_direct + .push((damage_type.to_owned(), name)); + } /// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the /// source's `DamageEffects` (`Player.getHurtSound`). pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) { - let pitch = self.audio.hurt_pitch(); - self.audio.play(sound, Some(at), 1.0, pitch); + // Player.getVoicePitch: always 1.0. + self.audio.play(sound, Some(at), 1.0, 1.0); } /// `CombatTracker.recordDamage` for the motion events of this tick. pub(super) fn record_combat(&mut self, events: &[MotionEvent]) { self.recheck_combat(); for e in events { let (name, amount) = match e { + // LivingEntity.getDamageAfterMagicAbsorb (Resistance) and + // Player.actuallyHurt (absorption hearts) and + // LivingEntity.hurtServer (the shield, only for an accepted + // hit), in tenths. + MotionEvent::Health(HealthEvent::Statistic { name, amount }) => { + match *name { + "resisted" => self.stats.custom("damage_resisted", *amount), + "absorbed" => self.stats.custom("damage_absorbed", *amount), + "blocked" => self.stats.custom("damage_blocked_by_shield", *amount), + _ => {} + } + continue; + } MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => { let Some(d) = self.motion.health.damage.definition(*source) else { continue; @@ -90,6 +168,15 @@ impl Game<'_> { .iter() .position(|(t, _)| *t == name) .map(|i| self.combat.pending.remove(i).1); + let direct = match attacker { + Some(_) => None, + None => self + .combat + .pending_direct + .iter() + .position(|(t, _)| *t == name) + .map(|i| self.combat.pending_direct.remove(i).1), + }; let (fall_location, fall_distance) = self.fall_location(); let now = self.world.game_time; if let Some(a) = &attacker { @@ -107,10 +194,16 @@ impl Game<'_> { fall_location, fall_distance, attacker, + direct, }); self.combat.last_damage = now; + // InfestedMobEffect.onMobHurt. + if self.potions.effects.get("infested").is_some() { + self.player_infested(); + } } self.combat.pending.clear(); + self.combat.pending_direct.clear(); self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance); } /// `FallLocation.getCurrentFallLocation` and the fall distance so far. @@ -220,6 +313,7 @@ impl Game<'_> { let entries = std::mem::take(&mut self.combat.entries); let killing = entries.last().expect("checked above"); let killer = killing.attacker.clone(); + let direct = killing.direct.clone(); if def.1 == "fall_variants" && let Some(knock) = knock.and_then(|i| entries.get(i)) { @@ -244,15 +338,22 @@ impl Game<'_> { .append(Text::literal("]")); return Text::translate(&format!("{key}.message"), &[me, link]); } + // `getLocalizedDeathMessage`: the causing entity's name, or the + // direct entity's when nothing caused the damage. if let Some((_, name)) = killer { return Text::translate(&key, &[me, name]); } + if let Some(name) = direct { + return Text::translate(&key, &[me, name]); + } // getKillCredit: the last mob that hurt the player in the past 100 ticks. if let Some(((_, name), at)) = &self.combat.last_hurt_by && self.world.game_time - at <= 100 { return Text::translate(&format!("{key}.player"), &[me, name.clone()]); } + // No entity at all: the one-argument message, as vanilla sends it (a + // template that names a killer then shows as written). Text::translate(&key, &[me]) } } diff --git a/crates/horizon-client/src/game/command_blocks.rs b/crates/horizon-client/src/game/command_blocks.rs index 76fc6d48c..868346452 100644 --- a/crates/horizon-client/src/game/command_blocks.rs +++ b/crates/horizon-client/src/game/command_blocks.rs @@ -2,7 +2,7 @@ //! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand` //! through the command system with the block's output, and //! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it. -use super::{Game, Screen, commands::execute::Source}; +use super::{Game, Screen, commands::execute::Source, lan::LanHost}; use horizon_client::{ command_block::{self, CommandBlock, Mode}, containers::BlockEntity, @@ -12,6 +12,7 @@ use horizon_client::{ world::World, }; use horizon_core::position::BlockPos; +use horizon_protocol::packets::play::{self, Serverbound as S}; use horizon_world::{ chunk::BlockState, level::flags, @@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen { auto: bool, } +/// What `ServerboundSetCommandBlockPacket` carries. +struct CommandBlockEdit { + pos: Pos, + command: String, + track_output: bool, + mode: Mode, + conditional: bool, + auto: bool, +} + #[derive(Clone, Copy, PartialEq)] enum Button { Output, @@ -186,6 +197,18 @@ impl Game<'_> { pub(super) fn can_use_game_master_blocks(&self) -> bool { self.creative && self.ui.registries.permission >= 2 } + /// `CommandBlock.useWithoutItem` on a server's client, which opens the + /// screen itself (`Player.openCommandBlock`) while the server only + /// answers the click: the block at `pos`, when it is a command block the + /// player may edit. + pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) { + let is_command_block = self + .block_state_at(pos) + .is_some_and(|state| Mode::of_block(&state.name).is_some()); + if is_command_block && self.can_use_game_master_blocks() { + self.open_command_block(pos); + } + } fn command_block(&self, pos: Pos) -> Option<&CommandBlock> { match self.world.block_entities.get(&pos)? { BlockEntity::CommandBlock(c) => Some(c), @@ -572,12 +595,66 @@ impl Game<'_> { s.command.insert_text(&allowed); } } - /// `ServerGamePacketListenerImpl.handleSetCommandBlock`. + /// The edit screen's Done: a server's client sends the packet, the + /// window's own world applies it (`handleSetCommandBlock`). fn set_command_block(&mut self, s: &CommandBlockScreen) { if !self.can_use_game_master_blocks() { self.system_message(Text::translate("advMode.notAllowed", &[])); return; } + let edit = CommandBlockEdit { + pos: s.pos, + command: s.command.value.clone(), + track_output: s.track_output, + mode: s.mode, + conditional: s.conditional, + auto: s.auto, + }; + if self.remote.is_some() { + self.send(S::SetCommandBlock(play::SetCommandBlock { + pos: [edit.pos.0, edit.pos.1, edit.pos.2], + command: edit.command, + mode: edit.mode.ordinal(), + flags: i8::from(edit.track_output) + | (i8::from(edit.conditional) << 1) + | (i8::from(edit.auto) << 2), + })); + } else { + self.apply_command_block(&edit); + } + } + /// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the + /// block is edited only by a creative player with game master commands. + pub(super) fn lan_set_command_block( + &mut self, + host: &mut LanHost, + conn: u32, + p: play::SetCommandBlock, + ) { + let Some(g) = host.hub.guest(conn) else { + return; + }; + let allowed = g.creative() && g.permission >= 2; + let edit = CommandBlockEdit { + pos: (p.pos[0], p.pos[1], p.pos[2]), + command: p.command, + track_output: p.flags & 1 != 0, + mode: Mode::from_ordinal(p.mode), + conditional: p.flags & 2 != 0, + auto: p.flags & 4 != 0, + }; + self.as_guest(host, conn, |game| { + if allowed { + game.apply_command_block(&edit); + // BaseCommandBlock.onUpdated: the clients get the new command. + game.mark_block_entity_updated(edit.pos); + } else { + game.system_message(Text::translate("advMode.notAllowed", &[])); + } + }); + } + /// What `handleSetCommandBlock` does once the player may edit the block. + fn apply_command_block(&mut self, s: &CommandBlockEdit) { let pos = s.pos; let registry = self.pack.assets.registry; let Some(current) = self.block_state_at(pos) else { @@ -609,7 +686,7 @@ impl Game<'_> { } let _ = self.process_level_events(); } - let command = s.command.value.clone(); + let command = s.command.clone(); let Some(c) = command_block_mut(&mut self.world, pos) else { return; }; diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 8f934ad8a..43cdfa51c 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -42,9 +42,10 @@ pub(super) enum Target { Guest(u32), Mob(u32), Item(u32), - Orb(usize), - /// By index in `creatures.projectiles`. - Projectile(usize), + /// An experience orb, by its entity id. + Orb(u32), + /// A projectile, by its entity id. + Projectile(u32), Stand(u32), Frame(u32), Painting(u32), @@ -53,6 +54,10 @@ pub(super) enum Target { /// An entity of a type Horizon does not simulate (markers, displays, /// interactions...): kept as its saved data at a position. Other(u32), + /// An End crystal, by its entity id (`minecraft:end_crystal`). + Crystal(u32), + /// The Ender Dragon, by its entity id (`minecraft:ender_dragon`). + Dragon(u32), } /// An entity of a type Horizon does not simulate: its compound lives in the /// entity memory (`persist.raw`), its position and rotation here. @@ -340,14 +345,16 @@ impl Game<'_> { .iter() .find(|i| i.id == id) .map(|i| [i.position.x, i.position.y, i.position.z]), - Target::Orb(i) => self + Target::Orb(id) => self .orbs - .get(i) + .iter() + .find(|o| o.id == id) .map(|o| [o.position.x, o.position.y, o.position.z]), - Target::Projectile(i) => self + Target::Projectile(id) => self .creatures .projectiles - .get(i) + .iter() + .find(|a| a.id == id) .map(|a| [a.position.x, a.position.y, a.position.z]), Target::Stand(id) => self .armor_stands @@ -385,6 +392,16 @@ impl Game<'_> { .iter() .find(|o| o.id == id) .map(|o| [o.position.x, o.position.y, o.position.z]), + Target::Crystal(id) => self + .crystals + .iter() + .find(|c| c.id == id) + .map(|c| [c.position.x, c.position.y, c.position.z]), + Target::Dragon(id) => self + .dragon + .as_ref() + .filter(|d| d.id == id) + .map(|d| [d.position.x, d.position.y, d.position.z]), } } pub(super) fn target_type(&self, t: Target) -> String { @@ -399,10 +416,11 @@ impl Game<'_> { }), Target::Item(_) => "minecraft:item".into(), Target::Orb(_) => "minecraft:experience_orb".into(), - Target::Projectile(i) => self + Target::Projectile(id) => self .creatures .projectiles - .get(i) + .iter() + .find(|p| p.id == id) .map_or_else(String::new, |p| p.kind.id().into()), Target::Stand(_) => "minecraft:armor_stand".into(), Target::Frame(id) => { @@ -428,6 +446,8 @@ impl Game<'_> { .iter() .find(|o| o.id == id) .map_or_else(String::new, |o| o.kind.clone()), + Target::Crystal(_) => "minecraft:end_crystal".into(), + Target::Dragon(_) => "minecraft:ender_dragon".into(), } } /// The name of the player the game acts for: a guest's own while their @@ -512,51 +532,72 @@ impl Game<'_> { s.pos[2].unwrap_or(p[2]), ] }; - let mut candidates: Vec = Vec::new(); + let mut source_candidates: Vec = Vec::new(); match (s.kind, self.command_source) { // @s: the executing entity (none for a server source). - ('s', Some(source)) => candidates.extend(source.entity), - ('s', None) => candidates.extend(self.source_entity()), + ('s', Some(source)) => source_candidates.extend(source.entity), + ('s', None) => source_candidates.extend(self.source_entity()), // A guest in place is among the players, in join order. _ if self.player.is_some() && self.lan_acting.is_none() => { - candidates.push(Target::Player); + source_candidates.push(Target::Player); } _ => {} } if s.kind != 's' { - use horizon_client::projectiles::ProjectileKind as K; - candidates.extend(self.player_candidates(s)); - candidates.extend( - self.mobs - .iter() - .filter(|m| m.death_time == 0) - .map(|m| Target::Mob(m.id)), - ); - candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id))); - candidates.extend((0..self.orbs.len()).map(Target::Orb)); - candidates.extend( - self.creatures - .projectiles - .iter() - .enumerate() - // The kinds /data can write (entity_nbt). - .filter(|(_, p)| { - p.arrow.is_some() - || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) - }) - .map(|(i, _)| Target::Projectile(i)), - ); - candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id))); - candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id))); - candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id))); - candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id))); - candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id))); - candidates.extend(self.others.iter().map(|o| Target::Other(o.id))); + source_candidates.extend(self.player_candidates(s)); } + let source_dim = self.dim; + // A selector with a spatial constraint reaches the level the command + // runs in only; an unbounded one reaches every loaded level + // (EntitySelector.findEntities). + let dims = if s.world_limited() { + vec![source_dim] + } else { + self.loaded_dims() + }; + let mut out: Vec<(Target, f64)> = Vec::new(); + for dim in dims { + let back = self.enter_command_level(dim); + let mut candidates = Vec::new(); + if dim == source_dim { + candidates.append(&mut source_candidates); + } + if s.kind != 's' { + candidates.extend(self.level_candidates()); + } + out.extend(self.select_in_level(candidates, s, origin)); + if let Some(back) = back { + self.enter_command_level(back); + } + } + match s.sort { + Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)), + Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)), + Sort::Random => { + for i in (1..out.len()).rev() { + let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; + out.swap(i, j); + } + } + Sort::Arbitrary => {} + } + out.into_iter() + .take(s.limit.max(1)) + .map(|(t, _)| t) + .collect() + } + /// The candidates of the level in place that pass the selector + /// (EntitySelector.getPredicate). + fn select_in_level( + &mut self, + candidates: Vec, + s: &Selector, + origin: [f64; 3], + ) -> Vec<(Target, f64)> { let level = self.experience.level; let creative = self.creative; let passing = self.selector_extra_filter(&candidates, s); - let mut out: Vec<(Target, f64)> = candidates + candidates .into_iter() .filter_map(|t| { let p = self.target_position(t)?; @@ -628,23 +669,102 @@ impl Game<'_> { } Some((t, d2)) }) - .collect(); - match s.sort { - Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)), - Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)), - Sort::Random => { - for i in (1..out.len()).rev() { - let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize; - out.swap(i, j); - } - } - Sort::Arbitrary => {} - } - out.into_iter() - .take(s.limit.max(1)) - .map(|(t, _)| t) .collect() } + /// The entities of the level in place a selector can find. + fn level_candidates(&self) -> Vec { + use horizon_client::projectiles::ProjectileKind as K; + let mut candidates = Vec::new(); + candidates.extend( + self.mobs + .iter() + .filter(|m| m.death_time == 0) + .map(|m| Target::Mob(m.id)), + ); + candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id))); + candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id))); + candidates.extend( + self.creatures + .projectiles + .iter() + // The kinds /data can write (entity_nbt). + .filter(|p| { + p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) + }) + .map(|p| Target::Projectile(p.id)), + ); + candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id))); + candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id))); + candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id))); + candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id))); + candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id))); + candidates.extend(self.others.iter().map(|o| Target::Other(o.id))); + // The End crystals and the dragon (EndCrystal and EnderDragon + // are entities of their own). + candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id))); + if let Some(d) = &self.dragon { + candidates.push(Target::Dragon(d.id)); + } + candidates + } + /// The loaded levels, in the order the server holds them: the + /// overworld, the nether, the end (MinecraftServer.getAllLevels). + fn loaded_dims(&self) -> Vec { + let mut dims = vec![self.dim]; + dims.extend(self.levels.parked_dims()); + dims.sort_by_key(|d| match d { + Dim::Overworld => 0, + Dim::Nether => 1, + Dim::End => 2, + }); + dims + } + /// Whether the level in place holds the entity a target names. + fn level_holds(&self, t: Target) -> bool { + match t { + // A player is in no level of its own. + Target::Player | Target::Guest(_) => true, + Target::Mob(id) => self.mobs.iter().any(|m| m.id == id), + Target::Item(id) => self.dropped.iter().any(|i| i.id == id), + Target::Orb(id) => self.orbs.iter().any(|o| o.id == id), + Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id), + Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id), + Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id), + Target::Painting(id) => self.paintings.iter().any(|p| p.id == id), + Target::Boat(id) => self.boats.iter().any(|b| b.id == id), + Target::Cart(id) => self.carts.iter().any(|c| c.id == id), + Target::Other(id) => self.others.iter().any(|o| o.id == id), + Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id), + Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id), + } + } + /// Run `f` with the level that holds the entity a target names in place + /// (the entity ids are unique across the loaded levels) and put the + /// level back afterwards. `f` does not run when no level holds the + /// entity. + pub(in crate::game) fn in_target_level( + &mut self, + t: Target, + f: impl FnOnce(&mut Self) -> R, + ) -> Option { + if self.level_holds(t) { + return Some(f(self)); + } + for dim in self.levels.parked_dims() { + let back = self.enter_command_level(dim); + if self.level_holds(t) { + let r = f(self); + if let Some(back) = back { + self.enter_command_level(back); + } + return Some(r); + } + if let Some(back) = back { + self.enter_command_level(back); + } + } + None + } fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool { let path = self.options.resources.join(format!( "data/minecraft/tags/entity_type/{}.json", @@ -1104,6 +1224,10 @@ impl Game<'_> { Ok(targets.len() as i32) } fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) { + // An entity in another level is teleported in the level that holds it. + let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation)); + } + fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) { let v = Vec3::new(pos[0], pos[1], pos[2]); match t { Target::Guest(_) => { @@ -1152,14 +1276,14 @@ impl Game<'_> { i.previous = v; } } - Target::Orb(i) => { - if let Some(o) = self.orbs.get_mut(i) { + Target::Orb(id) => { + if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) { o.position = v; o.previous = v; } } - Target::Projectile(i) => { - if let Some(a) = self.creatures.projectiles.get_mut(i) { + Target::Projectile(id) => { + if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) { a.position = v; a.previous = v; } @@ -1199,6 +1323,17 @@ impl Game<'_> { } } } + Target::Crystal(id) => { + if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) { + c.position = v; + } + } + Target::Dragon(id) => { + if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) { + d.position = v; + d.previous = v; + } + } // Wall-mounted entities do not move. Target::Frame(_) | Target::Painting(_) => {} } @@ -1558,6 +1693,10 @@ impl Game<'_> { Ok(duration.unwrap_or(-1)) } fn kill_target(&mut self, t: Target) { + // An entity in another level is killed in the level that holds it. + let _ = self.in_target_level(t, |g| g.kill_target_in_level(t)); + } + fn kill_target_in_level(&mut self, t: Target) { match t { Target::Guest(_) => { self.for_player(t, |g| g.kill_target(Target::Player)); @@ -1577,16 +1716,13 @@ impl Game<'_> { } } Target::Item(id) => self.dropped.retain(|i| i.id != id), - Target::Orb(i) => { - if i < self.orbs.len() { - self.orbs[i].age = u32::MAX / 2; + Target::Orb(id) => { + if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) { + o.age = u32::MAX / 2; } } - // cmd_kill goes from the highest index down. - Target::Projectile(i) => { - if i < self.creatures.projectiles.len() { - self.creatures.projectiles.remove(i); - } + Target::Projectile(id) => { + self.creatures.projectiles.retain(|p| p.id != id); } Target::Stand(id) => self.armor_stands.retain(|a| a.id != id), Target::Frame(id) => self.item_frames.retain(|f| f.id != id), @@ -1594,7 +1730,11 @@ impl Game<'_> { Target::Boat(id) => self.boats.retain(|b| b.id != id), Target::Cart(id) => self.carts.retain(|c| c.id != id), Target::Other(id) => self.others.retain(|o| o.id != id), + Target::Crystal(id) => self.kill_crystal(id), + Target::Dragon(id) => self.kill_dragon(id), } + // The orbs marked for removal are gone (Entity.kill removes them). + self.orbs.retain(|o| o.age < u32::MAX / 2); } fn cmd_kill(&mut self, ctx: &Context) -> R { let targets = if ctx.get("targets").is_some() { @@ -1602,19 +1742,18 @@ impl Game<'_> { } else { vec![self.implied_player()] }; - let names: Vec = targets.iter().map(|&t| self.target_name(t)).collect(); - // Remove indexed entities from the back so earlier indices stay valid. - let mut ordered = targets.clone(); - ordered.sort_by_key(|t| { - std::cmp::Reverse(match t { - Target::Orb(i) | Target::Projectile(i) => *i, - _ => 0, - }) - }); - for t in ordered { + let mut names: Vec = Vec::new(); + for &t in &targets { + // The name of an entity in another level is read there. + let name = match self.in_target_level(t, |g| g.target_name(t)) { + Some(name) => name, + None => self.target_name(t), + }; + names.push(name); + } + for &t in &targets { self.kill_target(t); } - self.orbs.retain(|o| o.age < u32::MAX / 2); let message = if targets.len() == 1 { Text::translate("commands.kill.success.single", &[names[0].clone()]) } else { @@ -1697,6 +1836,8 @@ impl Game<'_> { value: 0, age: 0, on_ground: false, + count: 1, + ticks: 0, }); name = Text::translate("entity.minecraft.experience_orb", &[]); } @@ -1704,6 +1845,20 @@ impl Game<'_> { self.spawn_bolt(pos, false); name = Text::translate("entity.minecraft.lightning_bolt", &[]); } + "end_crystal" => { + // EndCrystal's constructor takes an id from the entity + // counter, and the crystal is shown with its base. + self.next_entity += 1; + self.crystals.push(super::dragon::Crystal { + id: self.next_entity, + position: Vec3::new(pos[0], pos[1], pos[2]), + show_bottom: true, + time: self.world.random.next_int_bound(100_000).unwrap_or(0), + beam: None, + invulnerable: false, + }); + name = Text::translate("entity.minecraft.end_crystal", &[]); + } k if super::minecarts::CartKind::from_id(&kind).is_some() => { let cart_kind = super::minecarts::CartKind::from_id(&kind) .unwrap_or(super::minecarts::CartKind::Rideable); @@ -1894,8 +2049,7 @@ impl Game<'_> { return false; } self.world.block_entities.insert(key, new); - let state = self.world.block(pos[0], pos[1], pos[2]); - let _ = self.world.set(pos[0], pos[1], pos[2], state); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); true } /// RandomizableContainer.tryLoadLootTable for a tag a command gives a diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index ac16c5e7c..61bf3abe9 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) { impl Game<'_> { // ---- entity data --------------------------------------------------- - /// `NbtPredicate.getEntityTagToCompare`: the entity as saved. + /// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity + /// in another level is read in the level that holds it. pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option { + self.in_target_level(t, |g| g.entity_nbt_in_level(t)) + .flatten() + } + fn entity_nbt_in_level(&mut self, t: Target) -> Option { let player_uuid = self.level.player_uuid; Some(match t { Target::Guest(_) => { @@ -113,9 +118,11 @@ impl Game<'_> { let o = self.others.iter().find(|o| o.id == id)?.clone(); self.other_to_nbt(&o, uuid) } - Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()), - Target::Projectile(i) => { - let p = self.creatures.projectiles.get(i)?; + Target::Orb(id) => { + entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid()) + } + Target::Projectile(id) => { + let p = self.creatures.projectiles.iter().find(|p| p.id == id)?; let owner = if p.owner == Some(horizon_client::mobs::Who::Player) { player_uuid } else { @@ -129,6 +136,16 @@ impl Game<'_> { entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None) .or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))? } + Target::Crystal(id) => { + let c = self.crystals.iter().find(|c| c.id == id)?; + let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position); + c.to_nbt(uuid.unwrap_or_else(random_uuid), raw) + } + Target::Dragon(id) => { + let d = self.dragon.as_ref().filter(|d| d.id == id)?; + let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position); + d.to_nbt(uuid.unwrap_or_else(random_uuid), raw) + } }) } /// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is @@ -397,6 +414,19 @@ impl Game<'_> { } pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R { let target = self.data_ref(ctx, 0)?; + // An entity in another level is read and written in the level that + // holds it. + if let DataRef::Entity(t) = &target { + let t = *t; + let target = target.clone(); + return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) { + Some(r) => r, + None => Err(err("argument.entity.notfound.entity", &[])), + }; + } + self.cmd_data_in(id, ctx, target) + } + fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R { match id { "data.merge" => { let Some(Arg::Str(nbt)) = ctx.get("nbt") else { diff --git a/crates/horizon-client/src/game/commands/effects.rs b/crates/horizon-client/src/game/commands/effects.rs index 65d6592f7..3a26f6c0d 100644 --- a/crates/horizon-client/src/game/commands/effects.rs +++ b/crates/horizon-client/src/game/commands/effects.rs @@ -1,11 +1,10 @@ //! `/effect` (EffectCommands) and `/enchant` (EnchantCommand). use super::{Arg, Context, Game, R, Target, err, texts}; use horizon_client::{ - brewing, + brewing::{self, namespaced}, effects::EffectInstance, text::{self, Style, Text}, }; -use std::{collections::HashMap, sync::OnceLock}; /// `CommandResponseTracker` over targets: elements with a non-zero result. #[derive(Default)] @@ -21,24 +20,6 @@ impl Tracker { } } } -/// Namespaced effect ids for mob effect storage. -fn namespaced(id: &'static str) -> &'static str { - static IDS: OnceLock> = OnceLock::new(); - IDS.get_or_init(|| { - brewing::EFFECTS - .iter() - .map(|e| { - ( - e.id, - &*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()), - ) - }) - .collect() - }) - .get(id) - .copied() - .unwrap_or(id) -} fn effect_name(id: &str) -> Text { Text::translate(&format!("effect.minecraft.{id}"), &[]) } @@ -91,7 +72,12 @@ impl Game<'_> { } Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) { Some(m) => { - m.add_effect(namespaced(effect.id), duration, amplifier); + let name = namespaced(effect.id); + m.add_effect(name, duration, amplifier); + m.extra.hidden_effects.retain(|h| *h != name); + if !particles { + m.extra.hidden_effects.push(name); + } true } None => false, @@ -154,6 +140,10 @@ impl Game<'_> { m.extra .effects .retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id))); + let left = &m.extra.effects; + m.extra + .hidden_effects + .retain(|h| left.iter().any(|x| x.0 == *h)); m.extra.effects.len() != before } None => false, diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index 3f7e4c2a0..12aedc59a 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -145,9 +145,42 @@ impl Game<'_> { .iter() .find(|m| m.id == id) .map_or(0.0, |m| m.eye_y() - m.position.y), - _ => 0.0, + // ArmorStand.getDefaultDimensions: a marker's are empty, a small + // stand's are BABY_DIMENSIONS, whose eye height is 0.9875F. + Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) { + Some(a) if a.marker => 0.0, + Some(a) if a.small => f64::from(0.9875f32), + _ => self.type_eye_height(&self.target_type(t)), + }, + // Entity.getEyeHeight: the type's dimensions. + _ => self.type_eye_height(&self.target_type(t)), } } + /// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`, + /// read once: a float, widened as `Entity.getEyeY` does. + fn type_eye_height(&self, ty: &str) -> f64 { + static EYE_HEIGHTS: std::sync::OnceLock> = + std::sync::OnceLock::new(); + EYE_HEIGHTS + .get_or_init(|| { + std::fs::read(self.options.resources.join("entity-types.json")) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|v| { + v.as_object().map(|o| { + o.iter() + .filter_map(|(k, t)| { + Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32))) + }) + .collect() + }) + }) + .unwrap_or_default() + }) + .get(ty) + .copied() + .unwrap_or(0.0) + } /// EntityAnchorArgument.Anchor.apply. pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] { match s.entity { @@ -467,9 +500,10 @@ impl Game<'_> { "positioned" if c.has("as") => targets(self)? .into_iter() .filter_map(|t| { + // withPosition keeps the anchor (only `positioned ` + // resets it to the feet). Some(Source { pos: self.target_position(t)?, - eyes: false, ..s }) }) @@ -606,6 +640,73 @@ impl Game<'_> { .collect(), _ => Vec::new(), }, + // Leashable.getLeashHolder: a fence knot is not an entity here. + Some("leasher") => match entity { + Target::Mob(id) => self + .mobs + .iter() + .find(|m| m.id == id) + .and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player))) + .and_then(|(holder, player)| match holder { + // The player who tied it (the host's when unrecorded). + horizon_client::mobs::leash::Holder::Player => { + let uuid = player.or(self.level.player_uuid)?; + self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid)) + } + horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)), + horizon_client::mobs::leash::Holder::Knot(_) => None, + }) + .into_iter() + .collect(), + _ => Vec::new(), + }, + // TraceableEntity.getOwner: who shot a projectile, who threw an + // item, who summoned a vex. + Some("origin") => match entity { + Target::Projectile(id) => self + .creatures + .projectiles + .iter() + .find(|p| p.id == id) + .and_then(|p| match (p.owner?, p.guest) { + // A guest's shot: `Who::Player` is them. + (horizon_client::mobs::Who::Player, Some(conn)) + if self.lan_acting == Some(conn) => + { + Some(Target::Player) + } + (horizon_client::mobs::Who::Player, Some(conn)) => { + Some(Target::Guest(conn)) + } + (horizon_client::mobs::Who::Player, None) => Some(Target::Player), + (horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)), + }) + .into_iter() + .collect(), + Target::Item(id) => self + .dropped + .iter() + .find(|d| d.id == id) + .and_then(|d| d.thrower) + .and_then(|words| { + let uuid = words + .iter() + .fold(0u128, |all, w| all << 32 | u128::from(*w as u32)); + self.target_by_uuid(uuid) + }) + .into_iter() + .collect(), + Target::Mob(id) => self + .mobs + .iter() + .find(|m| m.id == id && m.kind.id == "minecraft:vex") + .map(|m| m.extra.counter2 as u32) + .filter(|owner| *owner != 0) + .map(Target::Mob) + .into_iter() + .collect(), + _ => Vec::new(), + }, Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(), Some("controller") | Some("passengers") => match entity { Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => { @@ -807,10 +908,12 @@ impl Game<'_> { .coords(c, "pos") .map(|co| co.block(self.source_position(), self.source_rotation())); let dimension = self.command_source.map_or(self.dim, |s| s.dimension); - return Ok(flag( - dimension == self.dim - && p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))), - )); + // ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit. + let loaded = |p: [i32; 3]| { + let key = (p[0] >> 4, p[2] >> 4); + self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1) + }; + return Ok(flag(dimension == self.dim && p.is_some_and(loaded))); } if c.has("dimension") { let dimension = self.command_source.map_or(self.dim, |s| s.dimension); diff --git a/crates/horizon-client/src/game/commands/pregen.rs b/crates/horizon-client/src/game/commands/pregen.rs index d497e2734..eb3c37688 100644 --- a/crates/horizon-client/src/game/commands/pregen.rs +++ b/crates/horizon-client/src/game/commands/pregen.rs @@ -244,7 +244,11 @@ impl Game<'_> { /// hand them the next jobs as soon as each one is done, rather than once /// a tick. pub(in crate::game) fn pump_pregen(&mut self, until: Instant) { - while self.pregen_runs_here() { + // Force-loaded chunks too: decorating in vanilla's order, a chunk + // waits for its neighbours far more often than once a tick allows. + while self.pregen_runs_here() + || (!self.streaming.forced.is_empty() && self.streaming.busy()) + { let now = Instant::now(); if now >= until { break; diff --git a/crates/horizon-client/src/game/commands/terrain_cmds.rs b/crates/horizon-client/src/game/commands/terrain_cmds.rs index d4b1c125c..824ca4d60 100644 --- a/crates/horizon-client/src/game/commands/terrain_cmds.rs +++ b/crates/horizon-client/src/game/commands/terrain_cmds.rs @@ -120,6 +120,9 @@ impl Game<'_> { tracker.track(&holder(chunk_text(x, z)), i32::from(changed)); } } + if add { + self.streaming.rank_forced(cx0, cz0, cx1, cz1); + } let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1)); let verb = if add { "added" } else { "removed" }; let (message, total) = tracker.message( diff --git a/crates/horizon-client/src/game/commands/text_commands.rs b/crates/horizon-client/src/game/commands/text_commands.rs index dead07d71..020157f83 100644 --- a/crates/horizon-client/src/game/commands/text_commands.rs +++ b/crates/horizon-client/src/game/commands/text_commands.rs @@ -14,10 +14,11 @@ impl Game<'_> { serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?; // NbtContents.resolve: the path's values in the entity, block or // storage data, rich-styled (or plain, or interpreted), ", "-joined. + // ScoreContents.resolve: the holder's score in the objective. let nbt_values = std::cell::RefCell::new(Vec::new()); { let mut collect = |o: &serde_json::Map| { - if o.contains_key("nbt") { + if o.contains_key("nbt") || o.contains_key("score") { nbt_values.borrow_mut().push(o.clone()); } }; @@ -40,7 +41,11 @@ impl Game<'_> { } let mut resolved: Vec<(serde_json::Map, Text)> = Vec::new(); for o in nbt_values.into_inner() { - let t = self.resolve_nbt_component(&o); + let t = if o.contains_key("score") { + self.resolve_score_component(&o)? + } else { + self.resolve_nbt_component(&o) + }; resolved.push((o, t)); } let mut resolve = |raw: &str| -> Text { @@ -73,6 +78,54 @@ impl Game<'_> { }, )) } + /// The text of a `score` component (ScoreContents.resolve): the named + /// holder's score, `*` being the player the command runs for. + fn resolve_score_component( + &mut self, + o: &serde_json::Map, + ) -> R { + let score = o.get("score"); + let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str()); + let (name, objective) = ( + field("name").unwrap_or(""), + field("objective").unwrap_or(""), + ); + // findTargetName: a selector that matches one entity is that entity, + // one that matches none is just the name. + let mut reader = horizon_client::command::Reader::new(name); + let ty = horizon_client::command::args::ArgType::Entity { + single: false, + players: false, + }; + let mut holder = name.to_owned(); + if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries) + && !reader.can_read() + { + let found = self.select(&sel); + if found.len() > 1 { + return Err(err("argument.entity.toomany", &[])); + } + if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) { + holder = t; + } + } + if holder == "*" { + holder = self.acting_player_name().to_owned(); + } + let board = &self.score.board; + let (Some(objective), Some(score)) = + (board.objective(objective), board.score(&holder, objective)) + else { + return Ok(Text::empty()); + }; + // numberFormatOrDefault(StyledFormat.NO_STYLE). + let format = (score.number_format.clone()) + .or_else(|| objective.number_format.clone()) + .unwrap_or(horizon_client::scoreboard::NumberFormat::Styled( + serde_json::json!({}), + )); + Ok(format.format(score.value)) + } /// The text of an `nbt` component. fn resolve_nbt_component(&mut self, o: &serde_json::Map) -> Text { use horizon_world::nbt::Tag; @@ -136,28 +189,40 @@ impl Game<'_> { } out } - fn player_targets(&mut self, ctx: &Context) -> R { - // Only the local player can receive messages and titles. + /// The players the command reaches: the window's own and the LAN guests. + fn player_targets(&mut self, ctx: &Context) -> R> { let targets = self.entities(ctx, "targets", true)?; - Ok(targets.iter().filter(|t| **t == Target::Player).count()) + Ok(targets + .into_iter() + .filter(|t| matches!(t, Target::Player | Target::Guest(_))) + .collect()) } pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R { - let n = self.player_targets(ctx)?; + let players = self.player_targets(ctx)?; let message = self.component_arg(ctx, "message")?; - if n > 0 { - self.system_message(message); + // Each player's chat (a guest's through the connection). + for &t in &players { + self.for_player(t, |g| g.system_message(message.clone())); } - Ok(n as i32) + Ok(players.len() as i32) } pub(super) fn cmd_title(&mut self, ctx: &Context) -> R { - let n = self.player_targets(ctx)?; - let name = self.target_name(Target::Player); + let players = self.player_targets(ctx)?; + let n = players.len(); + let name = players + .first() + .map_or_else(Text::empty, |t| self.target_name(*t)); let single = n == 1; let key = if ctx.has("clear") { - self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false })); + self.titles_or_packet( + &players, + Titles::clear, + C::ClearTitles(play::Bool1 { value: false }), + ); "cleared" } else if ctx.has("reset") { self.titles_or_packet( + &players, |t| { t.clear(); t.reset_times(); @@ -172,6 +237,7 @@ impl Game<'_> { }; let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut")); self.titles_or_packet( + &players, |t| t.set_times(fade_in, stay, fade_out), C::SetTitlesAnimation(play::TitlesAnimation { fade_in, @@ -186,13 +252,17 @@ impl Game<'_> { text: component_nbt(&text.component()), }; if ctx.has("subtitle") { - self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent)); + self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent)); "show.subtitle" } else if ctx.has("actionbar") { - self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent)); + self.titles_or_packet( + &players, + |t| t.set_overlay(text, false), + C::SetActionBarText(sent), + ); "show.actionbar" } else { - self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent)); + self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent)); "show.title" } }; @@ -207,13 +277,27 @@ impl Game<'_> { self.feedback(message); Ok(n as i32) } - /// The player the title is for: the guest acting now is sent the packet - /// (TitleCommand's `connection.send`), the window's own player shows it. - fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) { - if self.lan_acting.is_some() { - self.guest_packets.push(packet); - } else { - local(&mut self.titles); + /// TitleCommand's `connection.send` for each player: a guest is sent the + /// packet, the window's own player shows it. + fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) { + let mut local = Some(local); + for &t in players { + match t { + Target::Player if self.lan_acting.is_some() => { + self.guest_packets.push(packet.clone()); + } + Target::Player => { + if let Some(local) = local.take() { + local(&mut self.titles); + } + } + Target::Guest(conn) => { + if let Some(host) = self.lan.as_ref() { + host.hub.send_to(conn, &packet); + } + } + _ => {} + } } } } diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 687eb3f88..39587fa00 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -115,9 +115,11 @@ impl Game<'_> { "in_fire" | "campfire" | "hot_floor" => DamageKind::InFire, "lava" => DamageKind::Lava, "drown" => DamageKind::Drown, + "dry_out" => DamageKind::DryOut, "magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic, "sting" => DamageKind::Sting, "cactus" | "sweet_berry_bush" => DamageKind::Cactus, + "freeze" => DamageKind::Freeze, "in_wall" => DamageKind::InWall, "thorns" => DamageKind::Thorns, _ => DamageKind::Generic, @@ -140,27 +142,37 @@ impl Game<'_> { Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(), _ => None, }; + let cause = match ctx.get("cause") { + Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(), + _ => None, + }; let from = match ctx.get("location") { Some(Arg::Coords(_)) => self.position_arg(ctx, "location"), _ => by.and_then(|t| self.target_position(t)), }; let hurt = match target { + // The causing mob is the one the hit is credited to and thorns + // answer; the source position gives the knockback its direction. Target::Player => { - if self.creative || self.spectator() { + // `DamageSource(type, cause, by)`: the death message names the + // causing entity, else the direct one. A mob is noted by + // `hurt_player_from`, a player here. + if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) { + let name = self.target_name(t); + self.note_direct_entity(&ty, name); + } + if self.spectator() { false - } else if let Some(player) = self.player.as_mut() { - match self.motion.health.damage.id(&ty) { - Some(id) => player - .hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false), - None => false, - } } else { - false + let attacker = match cause.or(by) { + Some(Target::Mob(i)) => Some(Some(i)), + _ => Some(None), + }; + self.hurt_player_from(amount, from, &ty, attacker) } } Target::Mob(id) => { - let attacker = by.and_then(|t| match t { + let attacker = cause.or(by).and_then(|t| match t { Target::Player => Some(Who::Player), Target::Mob(i) => Some(Who::Mob(i)), _ => None, @@ -301,9 +313,18 @@ impl Game<'_> { float("minVolume", 0.0), ); let range = if volume > 1.0 { 16.0 * volume } else { 16.0 }; + // PlaySoundCommand: one seed for every player. + let seed = self.world.random.next_long(); + let here = self.dim.id(); let mut tracker = Tracker::default(); for t in targets { - if t != Target::Player { + if !matches!(t, Target::Player | Target::Guest(_)) { + continue; + } + // Only players in the source's level hear it. + if let Target::Guest(conn) = t + && self.command_guest(conn).is_none_or(|g| g.dimension != here) + { continue; } let Some(p) = self.target_position(t) else { @@ -327,8 +348,26 @@ impl Game<'_> { } else { (pos, volume) }; - self.audio.play_on(source, &sound, Some(at), v, pitch); - tracker.mark(&holder(Text::literal(self.acting_player_name()))); + // The connection is sent the sound; the window's own player + // plays it. + let guest = match t { + Target::Guest(conn) => Some(conn), + _ => self.lan_acting, + }; + match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) { + Some((conn, host)) => { + let packet = host + .hub + .sound_packet(&sound, at, v, pitch, source as i32, seed); + if t == Target::Player { + self.guest_packets.push(packet); + } else { + host.hub.send_to(conn, &packet); + } + } + None => self.audio.play_on(source, &sound, Some(at), v, pitch), + } + tracker.mark(&holder(self.target_name(t))); } let name = Text::literal(sound.clone()); let (message, total) = tracker.message( @@ -605,8 +644,10 @@ impl Game<'_> { let reached = reached.len() as i32; if !local { } else if count <= 0 { - // No count: one particle with the delta as velocity. - self.particles.create(&options, pos, delta); + // handleParticleEvent, count 0: one particle, the speed times + // the delta as velocity (both sent as floats, multiplied as floats). + self.particles + .create(&options, pos, delta.map(|d| f64::from(speed * d as f32))); } else { for _ in 0..count { let r = self.particles.random(); diff --git a/crates/horizon-client/src/game/composter.rs b/crates/horizon-client/src/game/composter.rs index 3f57229ad..4013155ad 100644 --- a/crates/horizon-client/src/game/composter.rs +++ b/crates/horizon-client/src/game/composter.rs @@ -5,6 +5,7 @@ use super::{Game, commands::compute::ComputeContext}; use horizon_client::{ items::ItemStack, + mobs::villager, particles::{ParticleOptions, ParticleType}, }; use horizon_core::{position::BlockPos, random::RandomSource}; @@ -86,6 +87,82 @@ impl Game<'_> { self.composter_add(pos, &layers); (stack.count > 0).then_some(stack) } + /// `WorkAtComposter.compostItems` for the villager at `mob`: a full + /// composter is emptied, then up to 20 wheat or beetroot seeds go in + /// (keeping 10 of each kind) until the composter reaches level 7. Level + /// event 1500 plays once, at the end. + pub(super) fn villager_compost_items(&mut self, mob: usize, pos: [i32; 3]) { + let at = BlockPos::new(pos[0], pos[1], pos[2]); + let Some(mut before) = self.composter_level(pos) else { + return; + }; + if before == 8 { + self.with_level(|b, level, ticks| b.composter_extract(level, ticks, at)); + let _ = self.process_level_events(); + before = 0; + } + let mut remaining = 20; + let mut amounts: Vec<(std::sync::Arc, i32)> = Vec::new(); + let slots = villager::ext(&self.mobs[mob]).map_or(0, |e| e.inventory.len()); + 'slots: for slot in (0..slots).rev() { + if remaining <= 0 { + break; + } + let stack = villager::ext_mut(&mut self.mobs[mob]).inventory[slot].clone(); + if !matches!( + &*stack.item, + "minecraft:wheat_seeds" | "minecraft:beetroot_seeds" + ) { + continue; + } + let known = amounts.iter().position(|(item, _)| *item == stack.item); + let total = known.map_or(0, |k| amounts[k].1) + stack.count as i32; + match known { + Some(k) => amounts[k].1 = total, + None => amounts.push((stack.item.clone(), total)), + } + let uses = (total - 10).min(remaining).min(stack.count as i32); + if uses <= 0 { + continue; + } + remaining -= uses; + for _ in 0..uses { + self.villager_insert_item(mob, slot, &stack, pos); + if self.composter_level(pos) == Some(7) { + break 'slots; + } + } + } + let after = self.composter_level(pos).unwrap_or(before); + self.with_level(|b, level, ticks| { + b.composter_fill_effects(level, ticks, at, after != before) + }); + let _ = self.process_level_events(); + } + /// `ComposterBlock.insertItem` with a villager's stack: below level 7 a + /// compostable item is used up and may add a layer. + fn villager_insert_item(&mut self, mob: usize, slot: usize, stack: &ItemStack, pos: [i32; 3]) { + if self.composter_level(pos).is_none_or(|level| level >= 7) { + return; + } + let Some(layers) = self.compostable(stack) else { + return; + }; + let context = ComputeContext { + block: Some(pos), + target: None, + }; + let layers = self.provider_int(&layers, &context, 0).unwrap_or(0); + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.composter_layer(level, ticks, at, layers)); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + let _ = self.process_level_events(); + let inventory = &mut villager::ext_mut(&mut self.mobs[mob]).inventory; + inventory[slot].count -= 1; + if inventory[slot].count == 0 { + inventory.remove(slot); + } + } /// `ComposterBlock.handleFill` (level event 1500) on the client: the /// fill sound and ten composter particles above the compost. pub(super) fn composter_fill_event(&mut self, pos: BlockPos, success: bool) { diff --git a/crates/horizon-client/src/game/consume.rs b/crates/horizon-client/src/game/consume.rs index 93d919911..e5fae08ca 100644 --- a/crates/horizon-client/src/game/consume.rs +++ b/crates/horizon-client/src/game/consume.rs @@ -19,7 +19,17 @@ use std::convert::Infallible; type ItemConsumable = Consumable; +/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use. +pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9; + impl Game<'_> { + /// `ServerPlayer.completeUsingItem`: the player's own client is told + /// first, and completes the use itself (eating the food there too). + pub(super) fn send_use_item_complete(&mut self) { + if self.lan_acting.is_some() { + self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT); + } + } /// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`. pub(super) fn emit_consume( &mut self, @@ -57,6 +67,17 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.burp", self.feet(), 0.5, pitch); } + // SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is + // added as a `MobEffectInstance(effect, duration)` (amplifier 0). + if let Some(data) = stack.extra.as_deref() { + for (id, duration) in &data.stew_effects { + if let Some(def) = horizon_client::brewing::effect(id) { + self.add_player_effect(horizon_client::effects::EffectInstance::new( + def.id, *duration, 0, + )); + } + } + } for effect in &consumable.effects { self.apply_consume_effect(effect); } diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 3202b23f4..0fe5d89ce 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -628,6 +628,9 @@ impl Game<'_> { // The port, or the game rules' search and number boxes. Screen::WorldOptions if text == "\\b" => self.world_options_text(None), Screen::WorldOptions => self.world_options_text(Some(text)), + Screen::Inventory if self.mod_browser_typing() => { + self.mod_browser_text((text != "\\b").then_some(text), text == "\\b") + } Screen::Inventory if self.recipe_search_focused() => { self.recipe_text(Some(text), false) } @@ -875,6 +878,9 @@ impl Game<'_> { Screen::Advancements => "advancements", Screen::WorldOptions => "world-options", Screen::Friends => "friends", + Screen::ServerLinks => "server-links", + Screen::ConfirmLink => "confirm-link", + Screen::DialogWarning => "dialog-warning", Screen::CommandBlock => "command-block", Screen::Win => "win", Screen::Mod => "mod", @@ -1080,6 +1086,9 @@ 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()); + let mut fangs_vertices = Vec::new(); + self.fangs_geometry(glam::DVec3::ZERO, 0.5, &mut fangs_vertices); + status["fangs_vertices"] = json!(fangs_vertices.len()); status["maps"] = json!(self.maps_status()); // The particles made so far, by type. status["particles"] = json!(self.particles.made()); diff --git a/crates/horizon-client/src/game/copper_golems.rs b/crates/horizon-client/src/game/copper_golems.rs index 2f98e1c7e..db4e03b0b 100644 --- a/crates/horizon-client/src/game/copper_golems.rs +++ b/crates/horizon-client/src/game/copper_golems.rs @@ -44,13 +44,13 @@ impl Game<'_> { slot: usize, stack: Option, ) { - if let Some(e) = self - .world - .block_entities - .get_mut(&(chest[0], chest[1], chest[2])) + let key = (chest[0], chest[1], chest[2]); + if let Some(e) = self.world.block_entities.get_mut(&key) && let Some(s) = e.slots_mut().get_mut(slot) { *s = stack; + // TransportItemsBetweenContainers calls Container.setChanged. + self.container_changed(key); } } /// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems diff --git a/crates/horizon-client/src/game/creaking.rs b/crates/horizon-client/src/game/creaking.rs index c75325c2f..19b3442c2 100644 --- a/crates/horizon-client/src/game/creaking.rs +++ b/crates/horizon-client/src/game/creaking.rs @@ -218,8 +218,9 @@ impl Game<'_> { ); } /// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to - /// death and a natural heart drops experience. - pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) { + /// death and a natural heart drops experience. `onExplosionHit` does the + /// same for the creaking (`by_player` false), without the experience. + pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) { let key = (pos[0], pos[1], pos[2]); if !matches!( self.world.block_entities.get(&key), @@ -242,7 +243,7 @@ impl Game<'_> { .state(state) .and_then(|s| s.properties.get("natural").cloned()) .is_some_and(|v| v == "true"); - if natural && !self.creative && !self.spectator() { + if by_player && natural && !self.creative && !self.spectator() { let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32; self.spawn_orbs( [ diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 2b65a3289..34319d462 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -18,6 +18,7 @@ use horizon_client::{ spawning::{self, BiomeFacts}, }, projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld}, + text::Text, }; use horizon_core::{ geometry::{Aabb, Vec3, traverse_blocks}, @@ -45,10 +46,22 @@ pub(super) struct Creatures { pub projectiles: Vec, /// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs). pub wind_bursts: Vec, + /// Explosion packets from a guest's turn, waiting for it to end + /// (centre, small, radius, blocks hit; game/lan_particles.rs). + pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>, /// Ticks left of the elder guardian's apparition (MobAppearanceParticle). pub elder_apparition: i32, /// The wandering trader spawner's clock. pub trader: super::wandering_trader::Spawner, + /// Particles from a guest's turn, waiting for it to end + /// (`lan_send_particles`: options, centre, spread, speed, count). + pub lan_particles: Vec<( + horizon_client::particles::ParticleOptions, + [f64; 3], + [f64; 3], + f64, + u32, + )>, } pub(super) struct ProjWorld<'a, 'w> { pub w: &'a MobWorld<'w>, @@ -145,6 +158,8 @@ fn flame_geometry( layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } h -= 0.45; @@ -186,6 +201,7 @@ impl Game<'_> { off_hand: None, head: self.armor[0].as_ref().map(|s| s.item.clone()), armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0, + invisible: self.potions.effects.get("invisibility").is_some(), spectator: self.spectator(), gold_armor: self.wearing_gold(), hurt_by: self.player_hurt_by(), @@ -193,9 +209,11 @@ impl Game<'_> { velocity: p.velocity, yaw: self.yaw, hero: self.potions.effects.get("hero_of_the_village").is_some(), + effects: self.potions.effects.list.iter().map(|e| e.id).collect(), uuid: self.level.player_uuid.unwrap_or_default(), allies: std::sync::Arc::new(self.player_allies()), remote: false, + passenger: self.riding.is_some(), }) } /// Biome facts at a position for finalizeSpawn and variant rules. @@ -400,6 +418,7 @@ impl Game<'_> { world: &self.world, registry: self.pack.assets.registry, facts: self.res.facts(), + fluid_tables: &self.motion.fluids, movement: &self.motion.blocks, collision: self.collision, data: self.res.mob_data(), @@ -417,11 +436,13 @@ impl Game<'_> { block_tags: &self.res.items().block_tags, biomes: self.res.biomes(), mob_griefing: self.ui.rules.bool("mob_griefing"), + mob_drops: self.ui.rules.bool("mob_drops"), end: self.dim == Dim::End, world_seed: self.level.seed, sea_level: if self.dim == Dim::Nether { 32 } else { 63 }, teams: std::sync::Arc::new(self.mob_teams()), items: Some(self.res.items()), + lid_openers: &self.lid_openers, difficulty: self.local_difficulty( self.player .as_ref() @@ -429,6 +450,12 @@ impl Game<'_> { ), } } + /// What the generator finalizes this level's structure mobs with. + pub(super) fn share_spawn_conditions(&self) { + if let Some(terrain) = self.end.locator() { + terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time); + } + } /// `ServerLevel.getCurrentDifficultyAt`. pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty { use horizon_world::food::Difficulty; @@ -497,11 +524,14 @@ impl Game<'_> { self.died.insert(m.id); self.mob_died(&m); } + // Their victims have been looted: no charged creeper is waiting any more. + self.combat.charged_exploders.clear(); let uuid = self.level.player_uuid; let mut snapshot: Vec = self.mobs.iter().map(|m| seen(m, uuid)).collect(); let mut events = Vec::new(); let mut mobs = std::mem::take(&mut self.mobs); let mut dead = Vec::new(); + let mut corpses = Vec::new(); let mut died = std::mem::take(&mut self.died); let ticking = self.entity_ticking_area(); // With guests on, each mob sees its nearest player (game/lan_mobs.rs). @@ -523,6 +553,8 @@ impl Game<'_> { _ => Vec::new(), }; let mut guest_events = Vec::new(); + // The mobs whose bodies go this tick: (id, feet, width, height). + let mut gone = Vec::new(); let level_random; { // The level random, lent to the mobs (brain behaviours draw from @@ -594,7 +626,17 @@ impl Game<'_> { horizon_client::mobs::apply_mob_pushes(m, &mut pushes); let from = events.len(); let kept = m.tick(&w, &mut scratch, &mut events); + // A killed mob's body is removed: its effects that act on + // death run (`LivingEntity.remove` with KILLED). + if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() { + corpses.push(m.clone()); + } horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes); + // LivingEntity.tickDeath: after twenty ticks the body is removed. + if !kept && !m.removed && !m.alive() && m.death_time >= 20 { + let (width, height) = (f64::from(m.width()), f64::from(m.height())); + gone.push((m.id, m.position, width, height)); + } if let Some(conn) = seen_guest { // ShulkerBullet.finalTarget is the guest's entity: the // bullet homes on it while the projectiles tick, where @@ -653,15 +695,32 @@ impl Game<'_> { } self.mobs.append(&mut mobs); self.died = died; + // ... and entity event 60 goes out as it is: the poof. + for (id, at, width, height) in gone { + let at = [at.x, at.y, at.z]; + self.lan_entity_event(id as i32, 60, at); + self.death_poof(at, width, height); + } self.sync_riders(); self.tick_skeleton_traps(); self.tick_apparition(); + self.guardian_bubbles(); self.villager_reputation_tick(); self.merchant_tick(); self.villager_pickups(); for m in dead { self.mob_died(&m); } + for mut m in corpses { + let names: Vec<&'static str> = m + .extra + .effects + .iter() + .map(|e| e.0.trim_start_matches("minecraft:")) + .collect(); + let bounds = m.bounds(); + self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random)); + } self.handle_mob_events(events)?; self.lan_guest_mob_events(guest_events); self.tick_projectiles()?; @@ -679,6 +738,22 @@ impl Game<'_> { self.handle_mob_events(queued)?; Ok(()) } + /// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box. + pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) { + let options = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Poof, + ); + for _ in 0..20 { + let r = self.particles.level_random(); + let xa = r.next_gaussian() * 0.02; + let ya = r.next_gaussian() * 0.02; + let za = r.next_gaussian() * 0.02; + let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0; + let y = at[1] + height * r.next_double() - ya * 10.0; + let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0; + self.particles.add(&options, [x, y, z], [xa, ya, za]); + } + } /// LivingEntity.die / dropAllDeathLoot. fn mob_died(&mut self, m: &Mob) { let killer = self.last_hurt_by_player.remove(&m.id); @@ -718,6 +793,7 @@ impl Game<'_> { self.villager_murdered(m, by_player); let pos = [m.position.x, m.position.y + 0.5, m.position.z]; self.drop_death_loot(&mut dying, by_player, consumed, pos); + self.create_wither_rose(m); if m.species() == Species::CopperGolem { // CopperGolem.dropEquipment: the preserved equipment too. self.golem_drop_equipment(&mut dying); @@ -779,7 +855,17 @@ impl Game<'_> { .mobs .iter() .find(|o| o.id == id) - .map(|o| drops::loot_entity(o, None)), + .map(|o| drops::loot_entity(o, None)) + // A charged creeper's blast kills after the creeper left the list. + .or_else(|| { + self.combat + .charged_exploders + .contains(&id) + .then(|| LootEntity { + powered: true, + ..LootEntity::new("minecraft:creeper") + }) + }), None => None, }; let direct = match d.kind { @@ -815,12 +901,12 @@ impl Game<'_> { world_seed: self.level.seed, }; let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences); - if skulls - && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) - && let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) - { + if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) { // Creeper.droppedSkulls: one head per charged explosion. - creeper.extra.charged = false; + self.combat.charged_exploders.retain(|c| *c != id); + if let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id) { + creeper.extra.charged = false; + } } let raider = horizon_client::mobs::raider::is_raider(m.species()); for d in items { @@ -838,11 +924,85 @@ impl Game<'_> { } } } + /// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up. + pub(super) fn mob_infested(&mut self, mob: u32) { + if let Some(m) = self.mobs.iter().find(|m| m.id == mob) { + let at = [ + m.position.x, + m.position.y + f64::from(m.height()) / 2.0, + m.position.z, + ]; + let look = m.view_vector(); + self.infested_hurt(at, look, Some(mob)); + } + } + /// `LivingEntity.dropFromGiftLootTable`: the gift table from its random + /// sequence with the mob as `this` (a chicken's egg depends on its + /// variant); the loot data of the game's resources decides, and without it + /// the items' table engine (no entity) rolls it. + fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) { + use horizon_client::mobs::drops; + use horizon_world::loot::EntityLootParams; + let loot = horizon_client::structure_blocks::loot_data(&self.options.resources); + let this = self + .mobs + .iter() + .find(|m| m.id == mob) + .map(|m| drops::loot_entity(m, None)); + let stacks = match (loot, this) { + (Some(loot), Some(this)) if loot.has_table(table) => { + let params = EntityLootParams { + this, + ..EntityLootParams::default() + }; + let seed = self.level.seed; + match loot.random_sequence(table) { + Some(id) => { + let random = self.rand_sequences.get(id, seed); + loot.roll_entity(table, random, ¶ms) + .iter() + .filter(|s| !s.is_empty()) + .map(from_loot) + .collect() + } + None => Vec::new(), + } + } + _ => self + .res + .items() + .loot_table(table, false, None, &mut self.world.random), + }; + for d in stacks { + self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); + } + } + /// Adds a projectile, giving it an id from the entity counter + /// (`Entity.id`, the same counter as the rest) when it has none. + pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) { + if p.id == 0 { + self.next_entity += 1; + p.id = self.next_entity; + } + self.creatures.projectiles.push(p); + } pub(super) fn handle_mob_events(&mut self, events: Vec) -> Result<()> { for e in events { match e { MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at), MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at), + MobEvent::Drown { mob, at } => { + self.lan_entity_event(mob as i32, super::combat::DROWN_EVENT, at); + let v = self + .mobs + .iter() + .find(|m| m.id == mob) + .map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]); + self.drown_particles(at, v); + } + MobEvent::EntityEvent { mob, event, at } => { + self.lan_entity_event(mob as i32, event, at) + } MobEvent::Sound(name, at, volume, pitch) | MobEvent::LocalSound(name, at, volume, pitch) => { self.audio @@ -890,6 +1050,21 @@ impl Game<'_> { } } } + MobEvent::Shift { target, delta } => { + let d = Vec3::new(delta[0], delta[1], delta[2]); + match target { + Who::Player => { + if let Some(p) = self.player.as_mut() { + p.position = p.position.add_vector(d); + } + } + Who::Mob(id) => { + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { + m.position = m.position.add_vector(d); + } + } + } + } MobEvent::Wololo { mob } => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) { // DyeColor.RED. @@ -903,21 +1078,36 @@ impl Game<'_> { owner, } => self.add_fangs(at, yaw, delay, owner), MobEvent::Shoot(..) => {} - MobEvent::Explode(at, radius) => { + MobEvent::ExplosionSource { mob, charged } => { + self.combat.explosion_source = Some(mob); + if charged { + self.combat.charged_exploders.push(mob); + } + } + MobEvent::Explode(at, radius, species) => { // The exploding mob causes the damage (Creeper.explodeCreeper). - let source_mob = self - .mobs - .iter() - .filter(|m| { - (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) < 1.0 - }) - .map(|m| m.id) - .next(); + let source_mob = self.combat.explosion_source.or_else(|| { + self.mobs + .iter() + .filter(|m| { + (m.position.x - at[0]).powi(2) + (m.position.z - at[2]).powi(2) + < 1.0 + }) + .map(|m| m.id) + .next() + }); if let Some(id) = source_mob { self.note_attacker("minecraft:explosion", id); } self.combat.explosion_source = source_mob; - let result = self.explode(at, radius); + // Only a creeper or a wither empties the hives it blows up. + // The creeper is already gone from the list, so the event + // carries its kind. + let result = if matches!(species, Species::Creeper | Species::Wither) { + self.with_hive_emptying_blast(|g| g.explode(at, radius)) + } else { + self.explode(at, radius) + }; self.combat.explosion_source = None; result? } @@ -928,6 +1118,7 @@ impl Game<'_> { } => { let mut killed = false; let mut hurt = false; + let mut infested = false; if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) { let hit = t.damage(amount, source); hurt = hit; @@ -936,17 +1127,27 @@ impl Game<'_> { // The hurt or death sound the mob raised. let center = t.center(); for event in std::mem::take(&mut t.outbox) { - if let MobEvent::Sound(s, _, volume, pitch) = event { - self.audio.play( - &format!("minecraft:{s}"), - Some(center), - volume, - pitch, - ); + match event { + MobEvent::Sound(s, _, volume, pitch) => { + self.audio.play( + &format!("minecraft:{s}"), + Some(center), + volume, + pitch, + ); + } + MobEvent::Infested { .. } => infested = true, + other => { + // Left for the end of the tick. + t.outbox.push(other); + } } } } } + if infested { + self.mob_infested(target); + } if hurt && let Some(Who::Mob(attacker)) = source.attacker && let Some(m) = self @@ -992,6 +1193,7 @@ impl Game<'_> { } MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob), MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive), + MobEvent::Infested { mob } => self.mob_infested(mob), MobEvent::Gift { target, item } => { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target) && m.saddle_item().is_none() @@ -1013,7 +1215,12 @@ impl Game<'_> { self.next_entity += 1; p.id = self.next_entity; let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]); - self.creatures.projectiles.push(p); + // LlamaSpit.recreateFromPacket: the spit's burst on the client. + if p.kind == ProjectileKind::LlamaSpit { + let v = p.velocity; + self.particles.llama_spit_burst(at, [v.x, v.y, v.z]); + } + self.add_projectile(p); self.projectile_event("minecraft:projectile_shoot", owner, at); } MobEvent::Spawn(mut m) => { @@ -1037,14 +1244,48 @@ impl Game<'_> { [0.0, 0.2, 0.0], 10, ), - MobEvent::SetBlock { pos, state } => { - self.world.set(pos[0], pos[1], pos[2], state)? - } + // `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop + // above trampled farmland breaks and drops. + MobEvent::SetBlock { pos, state } => self.level_set(pos, 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::BreakDoor { pos } => { + // BreakDoorGoal.tick: removeBlock, then level events 1021 + // and 2001 with the state that is there now. + let air = self.world.air; + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3)); + self.process_level_events()?; + self.level_event_at(1021, pos, 0); + let left = self.world.block(pos[0], pos[1], pos[2]); + self.level_event_at(2001, pos, left.0 as i32); + } + MobEvent::DestroyProgress { mob, pos, progress } => { + self.destroy_block_progress(mob as i32, pos, progress); + // ServerLevel.destroyBlockProgress: every player nearby sees it. + if let Some(host) = self.lan.as_ref() { + host.hub.broadcast_near( + [f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])], + 64.0, + &horizon_protocol::packets::play::Clientbound::BlockDestruction( + horizon_protocol::packets::play::BlockDestruction { + id: mob as i32, + pos, + progress: if (0..10).contains(&progress) { + progress as u8 + } else { + 255 + }, + }, + ), + None, + ); + } + } + MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data), MobEvent::SetBlockName { pos, name, effect } => { if effect { let old = self.world.block(pos[0], pos[1], pos[2]); @@ -1116,19 +1357,24 @@ impl Game<'_> { } } MobEvent::AroundSelf { + mob, + event, kind, feet, width, height, burst, - } => self.particles_around(kind, feet, [width, height], burst), + } => { + self.lan_entity_event(mob as i32, event, feet); + self.particles_around(kind, feet, [width, height], burst) + } MobEvent::ParticleAt { kind, at, velocity } => { if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) { let options = horizon_client::particles::ParticleOptions::simple(ty); self.particles.add(&options, at, velocity); } } - MobEvent::Experience { at, amount } => self.spawn_orbs(at, amount), + MobEvent::Experience { at, amount } => self.spawn_breeding_orb(at, amount), MobEvent::AxolotlSupportPlayer => { let previous = self .potions @@ -1190,6 +1436,10 @@ impl Game<'_> { self.mob_vibration_particle(from, to, ticks) } MobEvent::DigParticles { at } => self.warden_dig_particles(at), + MobEvent::BlockBreakEffect { pos, block } => { + let state = self.world.block(block[0], block[1], block[2]); + self.destroy_particles(pos, state); + } MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius), MobEvent::CreakingHurt { home, @@ -1200,12 +1450,14 @@ impl Game<'_> { self.creaking_tear_down(center, spread) } MobEvent::CureZombieVillager { mob } => self.cure_zombie_villager(mob), + MobEvent::Reinforcements { mob, target } => self.call_reinforcements(mob, target), MobEvent::VillagerMeet { mob, other } => self.villager_meet(mob, other), MobEvent::VillagerBirth { parent, partner, child, } => self.villager_birth(parent, partner, child), + MobEvent::WorkAtComposter { mob, pos } => self.villager_work_at_composter(mob, pos), MobEvent::GolemCheck { mob, needed } => self.villager_golem_check(mob, needed), MobEvent::Firework { at, color, flight } => { self.villager_firework(at, color, flight) @@ -1226,19 +1478,67 @@ impl Game<'_> { m.ai.aggressive |= aggressive; } } - MobEvent::GiftLoot { table, at } => { - let drops = - self.res - .items() - .loot_table(table, false, None, &mut self.world.random); + MobEvent::ShedScute { mob, at, pitch } => { + // The mob already checked `shouldDropLoot` (a baby, `mob_drops`). + let table = "minecraft:gameplay/armadillo_shed"; + let drops = self.sequence_loot(table, table, None); for d in drops { - self.spawn_item(d, at, [0.0, 0.2, 0.0], 10); + self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d); } + self.audio.play( + "minecraft:entity.armadillo.scute_drop", + Some(at), + 1.0, + pitch, + ); + let ctx = self.who_context(Some(Who::Mob(mob)), None); + self.post_game_event("minecraft:entity_place", at, ctx); + } + MobEvent::GiftLoot { mob, table, at } => { + // Turtle.ageBoundaryReached: only with mob loot on. + if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops") + { + continue; + } + self.drop_gift_loot(mob, table, at); } } } Ok(()) } + /// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle + /// per egg, a day from grown, with the egg as its home (the sound, the + /// crumbs and the block's removal came from the level). + pub(super) fn hatch_turtles( + &mut self, + pos: [i32; 3], + state: horizon_world::registry::BlockStateId, + ) { + let eggs = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("eggs")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + for i in 0..eggs { + let at = [ + f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2, + f64::from(pos[1]), + f64::from(pos[2]) + 0.3, + ]; + if let Some(id) = self.spawn_mob("minecraft:turtle", at, true) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.extra.age = -24000; + m.extra.home = Some(pos); + m.yaw = 0.0; + m.body_yaw = 0.0; + m.head_yaw = 0.0; + } + } + } fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) { let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { return; @@ -1272,6 +1572,13 @@ impl Game<'_> { m.extra.egg_time = life; } } + /// `Animal.finalizeSpawnChildFromBreeding`'s experience orb: only when the + /// `mob_drops` game rule is on. + fn spawn_breeding_orb(&mut self, at: [f64; 3], amount: u32) { + if self.ui.rules.bool("mob_drops") { + self.spawn_orbs(at, amount); + } + } /// Animal.spawnChildFromBreeding fn breed( &mut self, @@ -1294,6 +1601,8 @@ impl Game<'_> { .unwrap_or_else(|| parent.clone()); let mut child = parent.clone(); child.baby = true; + // TurtleBreedGoal.breed triggers it without a child. + let turtle = p.species() == Species::Turtle; if matches!( ( p.species(), @@ -1306,18 +1615,33 @@ impl Game<'_> { ) { child.kind = "minecraft:mule".into(); } - self.trigger_bred(&parent, &partner_view, Some(&child)); + self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child)); } if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) { q.extra.age = 6000; q.extra.in_love = 0; } let q = self.mobs.iter().find(|m| m.id == partner).cloned(); + // Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent. + self.mob_event_particles(parent, 18); // Sniffer.spawnChildFromBreeding: an egg instead of a baby. if p.species() == Species::Sniffer { self.lay_sniffer_egg(&p); return; } + // TurtleBreedGoal.breed: the goal gave the mother her egg; only the + // experience is left, and only with mob loot on. + if p.species() == Species::Turtle { + // One `new ExperienceOrb` of 1 to 7 from the mother's random (not + // split up as `ExperienceOrb.award` does). + if self.ui.rules.bool("mob_drops") + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent) + { + let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32; + self.add_orb([p.position.x, p.position.y, p.position.z], xp); + } + return; + } let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0; let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0; // A horse and a donkey have a mule. @@ -1365,7 +1689,7 @@ impl Game<'_> { } self.world.random = r; let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Cat { @@ -1386,7 +1710,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } if p.species() == Species::Wolf { @@ -1408,7 +1732,7 @@ impl Game<'_> { }; } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); return; } // Nautilus.getBreedOffspring: a tame parent's baby is tame too. @@ -1424,7 +1748,7 @@ impl Game<'_> { } } let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32; - self.spawn_orbs([p.position.x, p.position.y, p.position.z], xp); + self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp); } /// `MobEffectUtil.addEffectToPlayersAround` for an elder guardian: /// mining fatigue III for five minutes unless a stronger or longer one @@ -1481,11 +1805,34 @@ impl Game<'_> { pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) { self.mob_hits_player(amount, from, kind); } + /// Damage whose causing mob is known (`cause`, or none): not guessed + /// from the mob nearest to `from`. + pub(super) fn mob_hits_player_caused( + &mut self, + amount: f32, + from: [f64; 3], + kind: &str, + cause: Option, + ) { + self.hurt_player_from(amount, Some(from), kind, Some(cause)); + } pub(super) fn mob_hits_player( &mut self, amount: f32, from: [f64; 3], damage_type: &str, + ) -> bool { + self.hurt_player_from(amount, Some(from), damage_type, None) + } + /// `hurtServer` on the player. `from` is the source's position (none: no + /// direction), `known`: the causing mob when it is given (`/damage`), + /// else the melee attacker is the mob nearest to `from`. + pub(super) fn hurt_player_from( + &mut self, + amount: f32, + from: Option<[f64; 3]>, + damage_type: &str, + known: Option>, ) -> bool { // ServerPlayer.canHarmPlayer refused another player's projectile. if self.creative || self.combat.refuse_player_hit { @@ -1498,37 +1845,44 @@ impl Game<'_> { .damage_tags .get("minecraft:is_projectile") .is_some_and(|t| t.contains(damage_type)); - let blocking = self.player_blocking(Some(from), damage_type, false); + let blocking = self.player_blocking(from, damage_type, false); let raised = blocking.is_some(); // Melee: the attacker stands at `from` (another player's hit has no mob behind it). - let attacker = (!projectile && damage_type != "minecraft:player_attack") - .then(|| { - self.mobs - .iter() - .filter(|m| m.alive()) - .min_by(|a, b| { - let d = |m: &horizon_client::mobs::Mob| { - (m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2) - }; - d(a).total_cmp(&d(b)) - }) - .map(|m| m.id) - }) - .flatten(); + let attacker = match known { + Some(known) => known, + None => from + .filter(|_| !projectile && damage_type != "minecraft:player_attack") + .and_then(|from| { + self.mobs + .iter() + .filter(|m| m.alive()) + .min_by(|a, b| { + let d = |m: &horizon_client::mobs::Mob| { + (m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2) + }; + d(a).total_cmp(&d(b)) + }) + .map(|m| m.id) + }), + }; // The causing entity for the death message. let owner = self.combat.projectile_owner.take(); if let Some(a) = attacker.or(owner) { self.note_attacker(damage_type, a); } + // DamageSource.scalesWithDifficulty: a mob (or its projectile's + // owner) caused the hit, not a player. + let living_cause = attacker.or(owner).is_some(); let mut outcome = horizon_client::health::BlockOutcome::default(); if let Some(player) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(damage_type) - && let Ok(o) = player.hurt_blocking( + && let Ok(o) = player.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + living_cause, ) && { outcome = o; @@ -1538,7 +1892,14 @@ impl Game<'_> { // 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); + // No source position: a random direction. + let (xd, zd) = from.map_or((0.0, 0.0), |from| { + (from[0] - player.position.x, from[2] - player.position.z) + }); + // dealDefaultKnockback's `indicateDamage`, unless blocked. + if o.blocked <= 0.0 { + player.health.indicate_damage(xd, zd, player.yaw); + } 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() @@ -1546,12 +1907,28 @@ impl Game<'_> { r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]); } } - self.hurt_armor(amount); // The thorns hurt the entity that caused the hit, if there is one. if let Some(a) = attacker.or(owner) { self.thorns(a); } } + // LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned + // early (invulnerable, or inside the hurt cooldown). + if outcome.hurt || outcome.sound { + let source = attacker + .or(owner) + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .map(|m| self.mob_entity(m)); + self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView { + kind: damage_type.to_owned(), + direct: source.clone().filter(|_| !projectile), + is_direct: !projectile, + source, + original: amount, + actual: amount - outcome.blocked, + blocked: outcome.blocked > 0.0, + }); + } if raised { self.after_block(outcome, amount, attacker, projectile); } @@ -1688,6 +2065,7 @@ impl Game<'_> { let mut hits = Vec::new(); let mut deflections: Vec<[f64; 3]> = Vec::new(); let mut arrow_events = Vec::new(); + let mut trails = Vec::new(); { let mut targets: Vec<(Who, Aabb)> = self .mobs @@ -1779,6 +2157,25 @@ impl Game<'_> { if shooter.is_some() { p.owner = Some(Who::Player); } + // AbstractHurtingProjectile.tick ends with createParticleTrail. + if super::remote_projectiles::has_smoke_trail(&p.kind) { + trails.push(p.position); + } + // ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn. + if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) { + let (at, h) = (p.position, 0.125); + horizon_client::motion::bubble_column_push( + w.world, + w.registry, + w.facts, + w.collision, + Aabb::new( + Vec3::new(at.x - h, at.y, at.z - h), + Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h), + ), + &mut p.velocity, + ); + } if let Some(a) = p.arrow.as_mut() { arrow_events.extend(a.events.drain(..).map(|e| (p.position, e))); } @@ -1821,6 +2218,7 @@ impl Game<'_> { keep }); } + self.smoke_trails(trails); for at in deflections { self.audio .play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0); @@ -1853,6 +2251,7 @@ impl Game<'_> { _ => self.projectile_hit(&p, hit)?, } } + self.flush_lan_explosions(); self.flush_wind_bursts() } /// `projectile_hit` for a projectile the guest `conn` shot, run as them; @@ -1877,6 +2276,33 @@ impl Game<'_> { None => self.projectile_hit(p, hit), } } + /// What an arrow or a thrown trident hits an entity of type `victim` + /// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a + /// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident + /// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`). + fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 { + if !matches!(shot.kind, ProjectileKind::Trident) { + return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage; + } + let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else { + return 8.0; + }; + let ctx = horizon_client::enchant::EffectContext { + this: victim, + attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"), + direct_attacker: Some("minecraft:trident"), + damage_type: Some("minecraft:trident"), + items: Some(self.res.items()), + ..Default::default() + }; + self.res.stations().enchantments.modify_value( + Some(&trident), + "minecraft:damage", + &ctx, + &mut self.world.random, + 8.0, + ) + } /// What an arrow's tick asked for: its particles and sounds. fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) { use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction}; @@ -1905,6 +2331,8 @@ impl Game<'_> { ArrowSound::Splash => "minecraft:entity.generic.splash", ArrowSound::HitGround => "minecraft:entity.arrow.hit", ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire", + ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground", + ArrowSound::TridentHit => "minecraft:item.trident.hit", }; self.audio .play(name, Some([at.x, at.y, at.z]), volume, pitch); @@ -1956,11 +2384,9 @@ impl Game<'_> { ArrowEvent::ShootEvent | ArrowEvent::Drop => {} } } - /// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's - /// precipitation is not checked here). + /// `Level.isRainingAt` for arrows. fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ { - let raining = self.world.weather.levels(1.0).0 > 0.2; - move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15 + move |p: BlockPos| self.raining_at(p) } /// The level as arrows see it. fn arrow_scene<'s>( @@ -1998,7 +2424,24 @@ impl Game<'_> { } } if keep { - self.creatures.projectiles.push(arrow); + self.add_projectile(arrow); + } + } + /// `TargetBlock.onProjectileHit`: a target block takes its power from + /// where the projectile landed and resets after 20 ticks for an arrow, 8 + /// for any other projectile. A player's shot counts as a target hit. + fn target_block_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) { + let block = BlockPos::new(pos[0], pos[1], pos[2]); + let arrow = p.arrow.is_some(); + let location = [at.x, at.y, at.z]; + let hit = self.with_level(|b, level, ticks| { + b.target_projectile_hit(level, ticks, block, location, face, arrow) + }); + if hit.is_some() { + let _ = self.process_level_events(); + if p.owner == Some(Who::Player) { + self.stats.custom("target_hit", 1); + } } } pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> { @@ -2010,6 +2453,13 @@ impl Game<'_> { Hit::Entity { at, .. } | Hit::Block { at, .. } => at, Hit::None => return Ok(()), }; + // Projectile.onHitBlock: BlockState.onProjectileHit, for every + // projectile (a wind charge out of the world's top has no face). + if let Hit::Block { pos, face, .. } = hit + && face != [0; 3] + { + self.target_block_hit(p, pos, at, face); + } if let Hit::Entity { who: Who::Mob(id), at, @@ -2030,11 +2480,11 @@ impl Game<'_> { && id >= super::dragon::SPECIAL { let by_player = p.owner == Some(Who::Player); - if let ProjectileKind::Arrow { .. } = p.kind { + if p.arrow.is_some() { // AbstractArrow.onHitEntity: spent if the part or crystal took // it, else turned back (a perched dragon sets it alight first). let mut arrow = p.clone(); - let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage; + let amount = self.shot_damage(&mut arrow, None); let outcome = match self.hit_special(id, amount, by_player, true) { super::dragon::Hurt::Landed { .. } => EntityHit::Hurt, super::dragon::Hurt::Refused => EntityHit::Refused, @@ -2055,17 +2505,15 @@ impl Game<'_> { self.hit_special(id, amount, by_player, false); return Ok(()); } - if let ( - ProjectileKind::Arrow { .. }, - Hit::Entity { + if p.arrow.is_some() + && let Hit::Entity { who: Who::Mob(id), .. - }, - ) = (&p.kind, hit) + } = hit && let Some(i) = self.carts.iter().position(|c| c.id == id) { // AbstractArrow.onHitEntity on a minecart. let mut arrow = p.clone(); - let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage; + let amount = self.shot_damage(&mut arrow, None); let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire()); let creative = p.owner == Some(Who::Player) && self.creative; self.arrow_hits_cart(i, amount, p.velocity, burning, creative); @@ -2076,7 +2524,7 @@ impl Game<'_> { // Projectile.onHit: the landing is heard. self.projectile_event("minecraft:projectile_land", p.owner, atp); let source_kind = match p.kind { - ProjectileKind::Arrow { .. } => DamageKind::Arrow, + ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow, ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => { DamageKind::Fireball } @@ -2088,6 +2536,40 @@ impl Game<'_> { from: Some([p.previous.x, p.previous.y, p.previous.z]), }; match (&p.kind, hit) { + // ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs), + // dealt whether or not the hurt lands, then it bounces off. + (ProjectileKind::Trident, Hit::Entity { who, .. }) => { + let mut trident = p.clone(); + let victim = match who { + Who::Mob(id) => self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| m.kind.id.clone()), + Who::Player => Some("minecraft:player".to_owned()), + }; + let amount = self.shot_damage(&mut trident, victim.as_deref()); + let hurt = match who { + Who::Player => self.mob_hits_player( + amount, + [p.previous.x, p.previous.y, p.previous.z], + "minecraft:trident", + ), + Who::Mob(id) => self + .mobs + .iter_mut() + .find(|m| m.id == id) + .is_some_and(|m| m.damage(amount, source)), + }; + let outcome = if hurt { + EntityHit::Hurt + } else { + EntityHit::Refused + }; + self.finish_arrow_hit(trident, outcome); + } + // AbstractArrow.onHitBlock plays its sound itself (ArrowEvent). + (ProjectileKind::Trident, Hit::Block { .. }) => {} (ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => { let mut arrow = p.clone(); let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage; @@ -2119,14 +2601,46 @@ impl Game<'_> { .is_some_and(|m| m.damage(amount, source)); if p.owner == Some(Who::Player) { self.last_hurt_by_player.insert(id, self.lan_acting); + if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) { + // LivingEntity.hurtServer: PLAYER_HURT_ENTITY. + let victim = self.mob_entity(m); + let spectral = p.arrow.as_ref().is_some_and(|a| { + &*a.pickup_item.item == "minecraft:spectral_arrow" + }); + let direct = horizon_client::advancements::EntityView { + kind: if spectral { + "minecraft:spectral_arrow" + } else { + "minecraft:arrow" + } + .into(), + position: [p.position.x, p.position.y, p.position.z], + ..Default::default() + }; + self.player_hit(&victim, "minecraft:arrow", Some(direct), amount); + } } hurt } }; - // doKnockback: Punch pushes along the flight. + // doKnockback: Punch pushes along the flight, less the target's + // knockback resistance. let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt(); - if hurt && p.bonus.punch > 0 && flight > 1e-6 { - let k = f64::from(p.bonus.punch) * 0.6 / flight; + let resistance = match who { + Who::Player => self + .player + .as_mut() + .and_then(|pl| pl.attribute("minecraft:knockback_resistance").ok()) + .unwrap_or(0.0), + Who::Mob(id) => self + .mobs + .iter() + .find(|m| m.id == id) + .map_or(0.0, |m| m.attrs.knockback_resistance), + }; + let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0); + if hurt && push > 0.0 && flight > 1e-6 { + let k = push / flight; self.handle_mob_events(vec![MobEvent::Push { target: who, velocity: [p.velocity.x * k, 0.1, p.velocity.z * k], @@ -2162,6 +2676,7 @@ impl Game<'_> { }; m.damage(amount, source); } + self.thrown_break_event(p.id, atp); self.item_burst(atp, "minecraft:snowball"); } (ProjectileKind::Egg, hit) => { @@ -2187,6 +2702,7 @@ impl Game<'_> { } } } + self.thrown_break_event(p.id, atp); self.item_burst(atp, "minecraft:egg"); } (ProjectileKind::EnderPearl, hit) => { @@ -2290,7 +2806,10 @@ impl Game<'_> { if let Some(owner) = self.combat.projectile_owner { self.note_attacker("minecraft:explosion", owner); } - self.explode(atp, *power as f32)?; + self.combat.explosion_owner = self.combat.projectile_owner; + let result = self.explode(atp, *power as f32); + self.combat.explosion_owner = None; + result?; } ( ProjectileKind::Potion { @@ -2309,14 +2828,30 @@ impl Game<'_> { if d2 < 16.0 && !self.creative { let scale = 1.0 - d2.sqrt() / 4.0; let t = (f64::from(*ticks) * scale + 0.5) as i32; - if let Some(owner) = self.combat.projectile_owner { - self.note_attacker("minecraft:indirect_magic", owner); - } match *effect { - "minecraft:instant_damage" => self.hurt_player_by( - "minecraft:indirect_magic", - 6.0 * (1 << *amplifier) as f32 * scale as f32, - ), + "minecraft:instant_damage" => { + // indirectMagic(potion, owner), built where the + // hurt happens: the thrower, or the potion + // itself when it has none. A status effect + // deals no damage, so it records no source. + let owner = self + .combat + .projectile_owner + .filter(|id| self.mob_display_name(*id).is_some()); + match owner { + Some(owner) => { + self.note_attacker("minecraft:indirect_magic", owner) + } + None => self.note_direct_entity( + "minecraft:indirect_magic", + Text::translate("entity.minecraft.splash_potion", &[]), + ), + } + self.hurt_player_by( + "minecraft:indirect_magic", + 6.0 * (1 << *amplifier) as f32 * scale as f32, + ) + } e if t > 20 => self.give_player_effect(e, t, *amplifier), _ => {} } @@ -2372,12 +2907,14 @@ impl Game<'_> { // ShulkerBullet.onHitEntity: 4 damage, then levitation for 10 s. (ProjectileKind::ShulkerBullet { .. }, Hit::Entity { who, .. }) => match who { Who::Player => { - if !self.creative { - self.mob_hits_player( + // Only a hit that hurt (`wasHurt`) brings the levitation. + if !self.creative + && self.mob_hits_player( 4.0, [p.previous.x, p.previous.y, p.previous.z], "minecraft:mob_projectile", - ); + ) + { self.give_player_effect("minecraft:levitation", 200, 0); } } @@ -2386,6 +2923,8 @@ impl Game<'_> { && m.damage(4.0, source) { m.extra.levitation = 200; + // Shulker.hurtServer: a shulker hit by a bullet. + horizon_client::mobs::shulker::hit_by_bullet(m); } } }, @@ -2416,10 +2955,17 @@ impl Game<'_> { } Ok(()) } + /// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the + /// projectile is discarded (the clients lose it when the tracker next runs). + fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) { + if id != 0 { + self.lan_entity_event(id as i32, 3, at); + } + } /// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the /// thrown item where it broke, at rest for a snowball, scattered by the /// client entity's random (the particle engine's here) for an egg. - fn item_burst(&mut self, at: [f64; 3], item: &str) { + pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) { let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1)); let egg = item != "minecraft:snowball"; for _ in 0..8 { @@ -2455,6 +3001,10 @@ impl Game<'_> { if self.name_mob(index) { return Ok(true); } + if self.spawn_egg_offspring(index) { + self.swing_arm(); + return Ok(true); + } { let item = self.held().map(|h| h.item.to_string()).unwrap_or_default(); if self.interact_leash(index, &item) { @@ -2467,21 +3017,22 @@ impl Game<'_> { .as_ref() .map(|h| h.item.to_string()) .unwrap_or_default(); + // Bucketable.bucketMobPickup: a water bucket scoops up a fish, a + // tadpole or an axolotl, with the data its bucket carries. if item == "minecraft:water_bucket" - && self.mobs[index].species() == Species::Axolotl && self.mobs[index].alive() + && let Some((bucket, sound)) = bucketable(self.mobs[index].species()) { - let mut filled = ItemStack::new("minecraft:axolotl_bucket", 1); + let mut filled = ItemStack::new(bucket, 1); filled.extra = Some(Box::new(horizon_client::entity_nbt::bucket_components( &self.mobs[index], ))); - self.audio.play( - "minecraft:item.bucket.fill_axolotl", - Some(self.mobs[index].center()), - 1.0, - 1.0, - ); + self.audio + .play(sound, Some(self.mobs[index].center()), 1.0, 1.0); self.trigger_item("minecraft:filled_bucket", &filled); + // Leashable.dropLeash, then discard. + let id = self.mobs[index].id; + self.break_leash(id, true, false); self.mobs[index].removed = true; if self.creative { if let Some(rest) = self.give(filled) { @@ -2515,6 +3066,14 @@ impl Game<'_> { self.swing_arm(); return Ok(true); } + if self.interact_armadillo(index, &item) { + if let Some(stack) = &held { + let target = self.mob_entity(&self.mobs[index]); + self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target); + } + self.swing_arm(); + return Ok(true); + } if self.interact_copper_golem(index, &item) { self.swing_arm(); return Ok(true); @@ -2535,6 +3094,20 @@ impl Game<'_> { } return Ok(true); } + // Creeper.mobInteract: any item in #creeper_igniters, damaged when it + // is damageable and used up otherwise. + let creeper_igniter = self + .res + .items() + .item_tags + .get("minecraft:creeper_igniters") + .is_some_and(|t| t.contains(&*item)); + let damageable = self + .res + .items() + .get(&item) + .is_some_and(|i| i.max_damage > 0); + let acting = self.acting_player_uuid(); let m = &mut self.mobs[index]; if !m.alive() { return Ok(false); @@ -2550,12 +3123,23 @@ impl Game<'_> { let mut handled = true; let mut drops: Vec<(String, u32)> = Vec::new(); let is_food = m.kind.food.contains(&item); + let baby_turtle = species == Species::Turtle && m.is_baby(); match species { Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => { give = Some("minecraft:milk_bucket"); consume = true; sound = Some("entity.cow.milk"); } + // Goat.mobInteract: a bucket on an adult gives milk. + Species::Goat if item == "minecraft:bucket" && !m.is_baby() => { + give = Some("minecraft:milk_bucket"); + consume = true; + sound = Some(if m.extra.screaming { + "entity.goat.screaming.milk" + } else { + "entity.goat.milk" + }); + } Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => { give = Some("minecraft:mushroom_stew"); consume = true; @@ -2628,6 +3212,7 @@ impl Game<'_> { } Species::Wolf if m.extra.tame + && m.extra.owner == Some(acting) && item.ends_with("_dye") && horizon_client::mobs::render::DYE_NAMES .iter() @@ -2650,7 +3235,9 @@ impl Game<'_> { consume = true; } Species::Wolf - if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) => + if m.extra.tame + && m.extra.owner == Some(acting) + && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) => { m.extra.sitting = !m.extra.sitting; m.ai.jumping = false; @@ -2670,20 +3257,14 @@ impl Game<'_> { let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0; if tamed { horizon_client::mobs::tame::set_tame(m, true); - m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1])); + m.extra.owner = Some(acting); m.persistent = true; m.nav_stop(); m.ai.target = None; m.extra.sitting = true; } - // TamableAnimal.spawnTamingParticles. - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); @@ -2697,6 +3278,7 @@ impl Game<'_> { // Cat.mobInteract: dye, heal or sit when tame; fish may tame a wild one. Species::Cat if m.extra.tame + && m.extra.owner == Some(acting) && item.ends_with("_dye") && horizon_client::mobs::render::DYE_NAMES .iter() @@ -2719,7 +3301,12 @@ impl Game<'_> { m.persistent = true; consume = true; } - Species::Cat if m.extra.tame && is_food && m.health < m.attrs.max_health => { + Species::Cat + if m.extra.tame + && m.extra.owner == Some(acting) + && is_food + && m.health < m.attrs.max_health => + { let heal = self .res .items() @@ -2732,7 +3319,9 @@ impl Game<'_> { eat = eating_sound(m, &mut self.world.random); } Species::Cat - if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) => + if m.extra.tame + && m.extra.owner == Some(acting) + && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) => { m.extra.sitting = !m.extra.sitting; self.world.modified.insert(( @@ -2746,17 +3335,12 @@ impl Game<'_> { let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0; if tamed { m.extra.tame = true; - m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1])); + m.extra.owner = Some(acting); m.extra.sitting = true; } eat = eating_sound(m, &mut self.world.random); - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); @@ -2775,10 +3359,21 @@ impl Game<'_> { consume = true; sound = Some("entity.iron_golem.repair"); } - Species::Creeper if item == "minecraft:flint_and_steel" => { + Species::Creeper if creeper_igniter => { m.extra.flag = true; - sound = Some("item.flintandsteel.use"); - self.damage_held_tool(); + // FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE, + // at the creeper's feet with a pitch from its own random. + let event = if item == "minecraft:fire_charge" { + "item.firecharge.use" + } else { + "item.flintandsteel.use" + }; + eat = Some((event, 1.0, m.random.next_float() * 0.4 + 0.8)); + if damageable { + self.damage_held_tool(); + } else { + consume = true; + } } // Parrot.mobInteract: parrot food may tame a wild one (one time in // ten); a parrot never takes it as Animal food (isFood is false). @@ -2793,18 +3388,26 @@ impl Game<'_> { m.extra.tame = true; m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1])); } - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); if tamed { let view = self.mob_entity(&self.mobs[index]); self.trigger_entity("minecraft:tame_animal", "entity", &view); } } + // Tadpole.feed: frog food ages it by a tenth of the time left to + // grow into a frog (it grows on its next tick once past 24000). + Species::Tadpole if is_food && !m.extra.age_locked => { + consume = true; + let left = 24_000 - m.extra.age; + m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20; + // One happy villager particle at rest about the body. + self.particles_around_mob( + self.mobs[index].id, + "happy_villager", + horizon_client::particles::Burst::HORSE_EATING, + ); + } // HappyGhast.canFallInLove: never, so only a ghastling takes food. _ if is_food && m.kind.flags.animal @@ -2817,6 +3420,11 @@ impl Game<'_> { Some(sound) => { consume = true; eat = sound; + // Animal.setInLove: entity event 18 (a baby that + // only grew is not in love). + if self.mobs[index].extra.in_love > 0 { + self.mob_event_particles(self.mobs[index].id, 18); + } } // Animal.mobInteract still consumes the client click when // an adult cannot breed, without swinging or taking food. @@ -2828,6 +3436,14 @@ impl Game<'_> { if !handled { return Ok(false); } + // Turtle.ageBoundaryReached: food that finishes a baby's growth. + if baby_turtle && !self.mobs[index].is_baby() { + self.handle_mob_events(vec![MobEvent::GiftLoot { + mob: self.mobs[index].id, + table: "minecraft:gameplay/turtle_grow", + at: feet, + }])?; + } self.swing_arm(); if let Some(s) = sound { let pitch = @@ -2884,6 +3500,50 @@ impl Game<'_> { } Ok(true) } + /// `Armadillo.mobInteract`: a brush brushes a scute off an adult + /// (`brushOffScute`) and loses 16 durability. + fn interact_armadillo(&mut self, index: usize, item: &str) -> bool { + let m = &self.mobs[index]; + if item != "minecraft:brush" + || m.species() != Species::Armadillo + || !m.alive() + || m.is_baby() + { + return false; + } + let (id, at) = (m.id, m.position); + let feet = [at.x, at.y, at.z]; + let tool = self.held().cloned(); + let table = "minecraft:brush/armadillo"; + for d in self.sequence_loot(table, table, tool.as_ref()) { + self.spawn_at_location(at, 0.0, d); + } + self.audio + .play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0); + let ctx = self.who_context(Some(Who::Mob(id)), None); + self.post_game_event("minecraft:entity_interact", feet, ctx); + // ItemStack.hurtAndBreak(16). + if !self.creative + && let Some(stack) = tool + { + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |i| i.max_damage); + if max > 0 { + let loss = self.durability_loss(&stack, 16); + let slot = self.held_slot(); + if let Some(held) = self.stack_in_mut(slot).as_mut() { + held.damage += loss; + if held.damage >= max { + *self.stack_in_mut(slot) = None; + } + } + } + } + true + } pub(super) fn damage_held_tool(&mut self) { if self.creative { return; @@ -2938,7 +3598,7 @@ impl Game<'_> { velocity, on_ground, ); - self.creatures.projectiles.push(proj); + self.add_projectile(proj); self.player_projectile_shot(); // ItemStack.use's applyAfterUseComponentSideEffects: the pearl's // second is the `use_cooldown` component, as for any thrown item. @@ -2961,6 +3621,45 @@ impl Game<'_> { } Ok(true) } + /// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob + /// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby + /// at the mob's feet, taking the parent's variant as `getBreedOffspring` + /// does, and one egg used. False (PASS) when the mob has no baby form. + fn spawn_egg_offspring(&mut self, index: usize) -> bool { + let Some(stack) = self.held().cloned() else { + return false; + }; + let parent = &self.mobs[index]; + if !parent.alive() + || stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str()) + || !horizon_client::mobs::drops::can_be_baby(parent.species()) + // WanderingTrader.getBreedOffspring returns null: no offspring, the + // click carries on to trading. + || parent.species() == Species::WanderingTrader + { + return false; + } + let (kind, at) = ( + parent.kind.id.clone(), + [parent.position.x, parent.position.y, parent.position.z], + ); + let (species, variant, color) = + (parent.species(), parent.extra.variant, parent.extra.color); + let target = self.mob_entity(&self.mobs[index]); + let Some(id) = self.spawn_mob(&kind, at, true) else { + return false; + }; + if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) { + horizon_client::mobs::finalize::set_baby(child, true); + child.extra.variant = variant; + if species == Species::Sheep { + child.extra.color = color; + } + } + self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target); + self.consume_held(); + true + } fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result { let Some(hit) = self.pick() else { return Ok(false); @@ -3031,22 +3730,85 @@ impl Game<'_> { } } } + /// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian + /// that swims along, and bubbles up the beam to its target. The server + /// never draws them, so the mob's own random stays untouched. + pub(super) fn guardian_bubbles(&mut self) { + use horizon_client::mobs::guardian; + use horizon_client::particles::{ParticleOptions, ParticleType}; + let mut swimming = Vec::new(); + let mut beams = Vec::new(); + for m in &self.mobs { + if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) { + continue; + } + let at = [m.position.x, m.position.y, m.position.z]; + if m.extra.flag2 && m.fluids.in_water { + let (width, height, _) = m.dims(); + swimming.push((at, width, height, m.view_vector())); + } + if let Some((_, scale)) = guardian::beam(m, 0.0) + && let Some(t) = self.beam_target(m.ai.target, 1.0) + { + beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale))); + } + } + let bubble = ParticleOptions::simple(ParticleType::Bubble); + for (at, width, height, view) in swimming { + for _ in 0..2 { + let (x, y, z) = { + let r = self.particles.random(); + (r.next_double(), r.next_double(), r.next_double()) + }; + let p = [ + at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5, + at[1] + height * y - view[1] * 1.5, + at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5, + ]; + self.particles.add(&bubble, p, [0.0; 3]); + } + } + for (eye, target, scale) in beams { + let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]; + let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(); + if length <= 0.0 { + continue; + } + let mut dist = self.particles.random().next_double(); + while dist < length { + let r = self.particles.random(); + dist += 1.8 - scale + r.next_double() * (1.7 - scale); + let f = dist / length; + let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f]; + self.particles.add(&bubble, p, [0.0; 3]); + } + } + } /// Mob models and projectiles for the entity pass. pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { let atlas = &self.gui.atlas; let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); - let scale = f64::from(self.settings.entity_distance); + let scale = self.view_scale(); for mob in &self.mobs { let entity_start = out.len(); - // Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws. - if mob.invisible { + let outline = self.mob_outline(mob); + // Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and + // the outline of a glowing mob, LivingEntityRenderer.getRenderType). + if mob.invisible && outline.is_none() { continue; } let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's - // mean edge, scaled by the entity distance option. + // mean edge, scaled by the view scale. RemotePlayer's box counts + // ten times. let (w, h, _) = mob.dims(); - let range = (w + h + w) / 3.0 * 64.0 * scale; + let edge = (w + h + w) / 3.0 + * if &*mob.kind.id == "minecraft:player" { + 10.0 + } else { + 1.0 + }; + let range = edge * 64.0 * scale; if d.length_squared() >= range * range { continue; } @@ -3101,9 +3863,18 @@ impl Game<'_> { out, ); self.mob_block_geometry(mob, camera, alpha, packed, out); + if let Some(rgb) = outline { + self.mark_outline(&out[entity_start..], rgb); + if mob.invisible { + out.truncate(entity_start); + continue; + } + } // Glowing outlines and a guardian's beam remain visible independently // of whether the body is hidden behind terrain. - if !mob.glowing && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() { + if outline.is_none() + && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() + { self.cull_entity_vertices(out, entity_start); } // GuardianRenderer: the laser to the locked target. @@ -3151,7 +3922,15 @@ impl Game<'_> { super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))), _ => None, }); + // A glowing projectile's outline: what it drew up to the next one. + let mut glowing = None; for (a, at) in live.chain(taken) { + if let Some((start, rgb)) = glowing.take() { + self.mark_outline(&out[start..], rgb); + } + if a.id != 0 && self.entity_glowing(a.id) { + glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id)))); + } let p = glam::DVec3::from_array(at) - camera; let c = p.as_vec3(); let light = self.world.light( @@ -3187,6 +3966,30 @@ impl Game<'_> { } continue; } + // ThrownTridentRenderer. + if matches!(a.kind, ProjectileKind::Trident) { + let Some(model) = self.trident_model.as_ref() else { + continue; + }; + let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else { + continue; + }; + let m = glam::Mat4::from_translation(c) + * glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians()) + * glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians()); + let poses = horizon_client::mobs::anim::Poses::new(model); + horizon_client::mobs::render::draw( + model, + m, + &poses, + [r.u0, r.v0, r.u1, r.v1], + 0xFFFF_FFFF, + packed, + 0, + out, + ); + continue; + } // WindChargeRenderer. if matches!( a.kind, @@ -3256,11 +4059,36 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } } + if let Some((start, rgb)) = glowing { + self.mark_outline(&out[start..], rgb); + } } } +/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water +/// bucket picks up. +fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { + let fish = "minecraft:item.bucket.fill_fish"; + Some(match species { + Species::Cod => ("minecraft:cod_bucket", fish), + Species::Salmon => ("minecraft:salmon_bucket", fish), + Species::Pufferfish => ("minecraft:pufferfish_bucket", fish), + Species::TropicalFish => ("minecraft:tropical_fish_bucket", fish), + Species::Tadpole => ( + "minecraft:tadpole_bucket", + "minecraft:item.bucket.fill_tadpole", + ), + Species::Axolotl => ( + "minecraft:axolotl_bucket", + "minecraft:item.bucket.fill_axolotl", + ), + _ => return None, + }) +} /// Mob.getBaseExperienceReward /// The damage type id of a Horizon damage kind. pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str { @@ -3280,6 +4108,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::InFire => "minecraft:in_fire", K::Lava => "minecraft:lava", K::Drown => "minecraft:drown", + K::DryOut => "minecraft:dry_out", K::Magic => "minecraft:magic", K::Sting => "minecraft:sting", K::Cactus => "minecraft:cactus", @@ -3287,6 +4116,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) K::InWall => "minecraft:in_wall", K::Thorns => "minecraft:thorns", K::SonicBoom => "minecraft:sonic_boom", + K::Freeze => "minecraft:freeze", K::OutOfWorld => "minecraft:out_of_world", } } @@ -3305,6 +4135,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// A generated loot stack as a Horizon item stack. diff --git a/crates/horizon-client/src/game/crossbow.rs b/crates/horizon-client/src/game/crossbow.rs index 70ccba8d3..d66610183 100644 --- a/crates/horizon-client/src/game/crossbow.rs +++ b/crates/horizon-client/src/game/crossbow.rs @@ -6,6 +6,7 @@ //! finds the ammo for bows and crossbows. use super::{Game, shield::OFFHAND_SLOT, view_vector}; use horizon_client::{ + enchant::EffectContext, items::ItemStack, mobs::Who, projectiles::{Projectile, ProjectileKind}, @@ -152,6 +153,20 @@ impl Game<'_> { }; let charged = std::mem::take(&mut bow.data_mut().charged); bow.tidy(); + let weapon = bow.clone(); + // AbstractArrow's constructor: `getPiercingCount` of the weapon. + let pierce = self + .res + .stations() + .enchantments + .modify_value( + Some(&weapon), + "minecraft:projectile_piercing", + &EffectContext::default(), + &mut self.world.random, + 0.0, + ) + .clamp(0.0, 127.0) as u8; let count = charged.len(); // getShootingPower: a rocket among them slows every shot. let power = if charged.iter().any(|s| &*s.item == FIREWORK_ROCKET) { @@ -204,9 +219,10 @@ impl Game<'_> { if let Some(a) = arrow.arrow.as_mut() { a.set_item(ammo); a.set_owner(Some(Who::Player)); + a.pierce = pierce; } arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0); - self.creatures.projectiles.push(arrow); + self.add_projectile(arrow); } self.player_projectile_shot(); let pitch = if index == 0 { diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 0847321d6..989009d82 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -348,11 +348,15 @@ impl Game<'_> { let Some(state) = self.pack.assets.registry.default_state(&name) else { return; }; - let at = [ - c.position.x.floor() as i32, - c.position.y.floor() as i32, - c.position.z.floor() as i32, - ]; + let p = c.position; + // Entity.getY(2/3) is relative to the cushion's quarter-block height. + let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z]; + // Guest actions temporarily take self.lan; flush once the hub is back. + // Queue before resolving a sprite: a dedicated server has no renderer. + if self.audio.capture.is_some() { + self.lan_cushion_breaks.push((state, particle_at)); + } + let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; let Some((sprite, tint)) = self.particle_sprite(state, at) else { return; }; @@ -361,14 +365,8 @@ impl Game<'_> { sprite, tint, ); - let p = c.position; - self.particles.send_particles( - &options, - [p.x, p.y + 0.666_666_7, p.z], - [0.25, 0.0625, 0.25], - 0.05, - 10, - ); + self.particles + .send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10); } /// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100. @@ -455,8 +453,7 @@ impl Game<'_> { glam::DVec3::new(lo.x, lo.y, lo.z), glam::DVec3::new(hi.x, hi.y, hi.z), ); - let range = - super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + let range = super::entity_culling::default_range(hi - lo, self.view_scale()); if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { continue; } diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 16e7fc6e7..dc79bf8c1 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -13,7 +13,7 @@ use horizon_client::{ save::VERSION_NAME, text::Text, }; -use horizon_core::geometry::Axis; +use horizon_core::geometry::{Axis, Vec3}; use winit::keyboard::KeyCode; /// `ChatFormatting.YELLOW` and `RED`. @@ -766,7 +766,8 @@ impl Game<'_> { } let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a); let lerpf = |a: f32, b: f32| a + alpha * (b - a); - for m in &self.mobs { + let guests = self.lan.iter().flat_map(|h| h.models.values()); + for m in self.mobs.iter().chain(guests) { if m.removed || m.invisible || m.has_effect("minecraft:invisibility") { continue; } @@ -796,6 +797,159 @@ impl Game<'_> { // EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125. g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0)); } + // The other entities (EntityTypes sizes; eyes at 0.85 of the height + // unless the type sets one). `at` is the interpolated position. + let at = |previous: Vec3, now: Vec3| { + [ + lerp(previous.x, now.x), + lerp(previous.y, now.y), + lerp(previous.z, now.z), + ] + }; + for p in &self.paintings { + let (min, max) = p.bounds(); + let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0); + g.entity_box(min, max, centre, 0.425, false, step_view(p.step())); + } + for f in self.item_frames.iter().filter(|f| !f.invisible) { + let (min, max) = f.bounds(); + let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0); + g.entity_box(min, max, centre, 0.0, false, step_view(f.step())); + } + for c in &self.cushions { + let (min, max) = c.bounds(); + let p = c.position; + g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw)); + } + for a in self.armor_stands.iter().filter(|a| !a.invisible) { + // ArmorStand.getDimensions: a marker has none, a small one is + // half scale with its eyes at 0.9875. + let (size, eye) = match (a.marker, a.small) { + (true, _) => ((0.0, 0.0), 0.0), + (_, true) => ((0.25, 0.9875), 0.9875), + _ => ((0.5, 1.975), 1.7775), + }; + let p = a.position; + g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw)); + } + for b in &self.boats { + let h = super::boats::HEIGHT as f32; + g.hitbox( + at(b.previous, b.position), + (super::boats::WIDTH as f32, h), + h, + false, + (0.0, b.yaw), + ); + } + for c in &self.carts { + let (w, h) = ( + super::minecarts::WIDTH as f32, + super::minecarts::HEIGHT as f32, + ); + g.hitbox( + at(c.previous, c.position), + (w, h), + h * 0.85, + false, + (0.0, c.yaw), + ); + } + for o in &self.orbs { + g.hitbox( + at(o.previous, o.position), + (0.5, 0.5), + 0.425, + false, + (0.0, 0.0), + ); + } + for f in &self.falling { + g.hitbox( + at(f.previous, f.position), + (0.98, 0.98), + 0.833, + false, + (0.0, 0.0), + ); + } + for t in &self.tnts { + g.hitbox( + at(t.previous, t.position), + (0.98, 0.98), + 0.15, + false, + (0.0, 0.0), + ); + } + for c in &self.crystals { + let p = c.position; + g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0)); + } + for p in &self.creatures.projectiles { + use horizon_client::projectiles::ProjectileKind as K; + let (size, eye) = match p.kind { + K::Arrow { .. } | K::Trident => (0.5, 0.13), + K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => { + (0.25, 0.2125) + } + K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => { + (0.3125, 0.265_625) + } + K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0), + K::LargeFireball { .. } => (1.0, 0.85), + }; + g.hitbox( + at(p.previous, p.position), + (size, size), + eye, + false, + (p.pitch, p.yaw), + ); + } + for t in &self.tridents { + g.hitbox( + at(t.previous, t.position), + (0.5, 0.5), + 0.13, + false, + (t.pitch, t.yaw), + ); + } + for r in &self.rockets { + // Projectile.updateRotation: the look follows the motion. + let v = r.velocity; + let yaw = v.x.atan2(v.z).to_degrees() as f32; + let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32; + g.hitbox( + at(r.previous, r.position), + (0.25, 0.25), + 0.2125, + false, + (pitch, yaw), + ); + } + for h in self + .hook + .iter() + .map(|h| (h.previous, h.position)) + .chain(self.remote_hooks.iter().map(|h| (h.previous, h.position))) + { + g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0)); + } + for b in &self.bolts { + g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0)); + } + // The dragon: 16 by 8, a living entity; its parts follow. + if let Some(d) = &self.dragon { + g.hitbox( + at(d.previous, d.position), + (16.0, 8.0), + 6.8, + true, + (0.0, d.yaw), + ); + } // The dragon's parts. for (id, b) in self.special_targets() { if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) { @@ -828,3 +982,12 @@ impl Game<'_> { g } } + +/// The (x, y) rotations whose view vector is the step of a direction. +fn step_view(step: [i32; 3]) -> (f32, f32) { + let [x, y, z] = step.map(f64::from); + ( + (-y).asin().to_degrees() as f32, + (-x.atan2(z)).to_degrees() as f32, + ) +} diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index fa5365195..73c6fd4cc 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -24,6 +24,7 @@ use horizon_client::{ use horizon_protocol::text::translatable; use horizon_world::nbt::{JavaString, Tag}; use std::{ + io::Write, path::PathBuf, sync::{Arc, Mutex, OnceLock, mpsc}, time::{Duration, Instant}, @@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) { } }); // Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log. - println!( + // The launcher can close its stdout pipe while the server keeps running. + let _ = writeln!( + std::io::stdout().lock(), "[{}] [{thread}/{level}]: {message}", chrono::Local::now().format("%H:%M:%S") ); @@ -618,6 +621,22 @@ impl Game<'_> { log_ips: props.log_ips, }), resource_pack: props.resource_pack.clone(), + // DedicatedServer.serverLinks: `bug-report-link` as the only link + // (ServerLinks.KnownLinkType.BUG_REPORT is id 0). + server_links: if props.bug_report_link.is_empty() { + Vec::new() + } else if super::server_links::valid_untrusted_uri(&props.bug_report_link) { + vec![horizon_protocol::packets::configuration::ServerLink { + kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0), + link: props.bug_report_link.clone(), + }] + } else { + log( + "WARN", + &format!("Failed to parse bug link {}", props.bug_report_link), + ); + Vec::new() + }, }; let config = HubConfig { default_game_mode: game_mode, @@ -654,6 +673,9 @@ impl Game<'_> { // DedicatedServer.initServer ends with saveEverything(false, true, true). self.save_game()?; self.log_saved_levels(); + if let Some(host) = self.lan.as_mut() { + host.hub.entity_range_percent = props.entity_broadcast_range_percentage; + } let mut state = ServerState { lists, console, @@ -1308,3 +1330,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?); Some((w, h)) } + +#[cfg(test)] +mod tests { + use super::{capture_log, log, log_from}; + use std::{ + fs, + io::{self, BufRead, BufReader, Read, Write}, + process::{Command, Stdio}, + time::{SystemTime, UNIX_EPOCH}, + }; + + const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)"; + const WARNING: &str = "Authentication warning"; + const READY: &str = "horizon-console-log-ready\n"; + const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME"; + + fn assert_log_lines(text: &str) { + let expected = [ + format!("] [Server thread/INFO]: {LOGIN}"), + format!("] [User Authenticator #1/WARN]: {WARNING}"), + ]; + assert_eq!(text.lines().count(), expected.len()); + for (line, suffix) in text.lines().zip(expected) { + assert!(line.starts_with('[')); + assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok()); + assert_eq!(&line[9..], suffix); + } + } + + #[test] + fn console_logging_survives_closed_stdout() { + if let Some(home) = std::env::var_os(CHILD_HOME) { + horizon_client::logging::init(std::path::Path::new(&home), "Server thread"); + // Let the parent close the real stdout pipe only after libtest's startup output. + io::stdout().write_all(READY.as_bytes()).unwrap(); + io::stdout().flush().unwrap(); + io::stdin().read_exact(&mut [0]).unwrap(); + std::thread::Builder::new() + .name("Server thread".into()) + .spawn(|| { + let captured = capture_log(|| { + log("INFO", LOGIN); + log_from("User Authenticator #1", "WARN", WARNING); + }); + assert_eq!(captured, [LOGIN, WARNING]); + horizon_client::logging::flush(); + }) + .unwrap() + .join() + .unwrap(); + // Libtest's final status also writes to stdout; only the game logger is under test. + std::process::exit(0); + } + + for closed_stdout in [false, true] { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let home = std::env::temp_dir().join(format!( + "horizon-console-log-test-{}-{unique}", + std::process::id() + )); + fs::create_dir(&home).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "game::dedicated::tests::console_logging_survives_closed_stdout", + "--nocapture", + ]) + .env(CHILD_HOME, &home) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + loop { + let mut line = String::new(); + assert!( + stdout.read_line(&mut line).unwrap() > 0, + "child was not ready" + ); + if line.ends_with(READY) { + break; + } + } + if closed_stdout { + drop(stdout); // No reader remains: the next console write gets EPIPE. + } else { + child.stdout = Some(stdout.into_inner()); + } + child.stdin.take().unwrap().write_all(&[1]).unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "closed_stdout={closed_stdout}: {}", + String::from_utf8_lossy(&output.stderr) + ); + if !closed_stdout { + assert_log_lines(std::str::from_utf8(&output.stdout).unwrap()); + } + assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap()); + fs::remove_dir_all(home).unwrap(); + } + } +} diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index a08de87b3..088374a34 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -120,6 +120,11 @@ impl Game<'_> { p.guest = guest; // Not into the one who turned it on its way out. p.left_owner = false; + // `AbstractHurtingProjectile.onDeflection` by an attack, and the + // `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn + // at once, not at the fireball's next update ten ticks later. + p.acceleration = 0.1; + p.needs_sync = true; self.reset_attack_strength(); self.swing_arm(); let from = self diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index 267f323c3..33da8b5ce 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -725,6 +725,11 @@ impl Game<'_> { match self.world.block_entities.get_mut(&p) { Some(BlockEntity::Jukebox(j)) => j.record = stack, Some(BlockEntity::Lectern(l)) => l.book = stack, + Some(BlockEntity::Campfire(c)) => { + if let Some(cell) = c.items.get_mut(slot) { + *cell = stack; + } + } Some(e) => { if let Some(cell) = e.slots_mut().get_mut(slot) { *cell = stack; @@ -1126,14 +1131,18 @@ impl Game<'_> { let (p, v) = (b.position, b.velocity); out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], "")); } + // A merged orb prints once per count, as the oracle does: whether orbs + // merge depends on entity ids, which differ from vanilla's. for o in &self.orbs { let (p, v) = (o.position, o.velocity); - out.push(line( - "minecraft:experience_orb", - [p.x, p.y, p.z], - [v.x, v.y, v.z], - "", - )); + for _ in 0..o.count.max(1) { + out.push(line( + "minecraft:experience_orb", + [p.x, p.y, p.z], + [v.x, v.y, v.z], + "", + )); + } } for c in &self.carts { let (p, v) = (c.position, c.velocity); diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index 61725c6ab..a0a9bfd93 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -982,7 +982,7 @@ impl Game<'_> { | "minecraft:lingering_potion" => { let mut shot = Projectile::new(ProjectileKind::Snowball, at, None); shot.shoot(&mut own, dir, power, uncertainty); - self.launch_thrown(one, at, shot.velocity); + self.launch_thrown(one, at, shot.velocity, None); } "minecraft:firework_rocket" => { let mut shot = Projectile::new(ProjectileKind::Snowball, at, None); @@ -1034,7 +1034,7 @@ impl Game<'_> { a.pickup = horizon_client::arrows::Pickup::Allowed; } shot.shoot(&mut own, dir, power, uncertainty); - self.creatures.projectiles.push(shot); + self.add_projectile(shot); } } } diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index fff03ef1c..250887b30 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096; const CRYSTAL_IDS: u32 = SPECIAL + 16; const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat"; const MAX_HEALTH: f32 = 200.0; +/// `Mob.getAmbientSoundInterval`. +const AMBIENT_INTERVAL: i32 = 80; #[derive(Default)] pub(super) struct Fight { @@ -48,6 +50,9 @@ pub(super) struct Fight { respawn: Option<(Respawn, i32)>, /// The four crystals on the exit portal driving the respawn. respawn_crystals: Vec, + /// The player behind the crystal blast under way, whose `explosion` + /// damage source names them (`EndCrystal.hurtServer`). + blast_by: Option, dirty: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -262,6 +267,9 @@ pub(super) struct Dragon { /// only damage above the last hit lands, and only the excess. invulnerable: i32, last_hurt: f32, + /// `lastHurtByPlayer` with its 100 tick memory: the player credited with + /// the kill, and the dragon tick of their last hit. + hurt_by_player: Option<(FightWho, i32)>, pub death_time: i32, pub flap: f32, pub old_flap: f32, @@ -270,6 +278,8 @@ pub(super) struct Dragon { flame_count: i32, sitting_damage: f32, growl: i32, + /// `Mob.ambientSoundTime`. + ambient_time: i32, ticks: i32, /// DragonFlightHistory: (y, yRot) samples, newest at `head`. history: [(f64, f32); 64], @@ -291,11 +301,11 @@ pub(super) enum FightWho { } /// A player in the End as the dragon sees it. #[derive(Clone, Copy, Debug)] -struct FightPlayer { - who: FightWho, - position: Vec3, - bounds: Aabb, - alive: bool, +pub(super) struct FightPlayer { + pub(super) who: FightWho, + pub(super) position: Vec3, + pub(super) bounds: Aabb, + pub(super) alive: bool, creative: bool, spectator: bool, } @@ -385,6 +395,7 @@ impl Dragon { hurt_time: 0, invulnerable: 0, last_hurt: 0.0, + hurt_by_player: None, death_time: 0, flap: 0.0, old_flap: 0.0, @@ -397,6 +408,7 @@ impl Dragon { flame_count: 0, sitting_damage: 0.0, growl: 100, + ambient_time: 0, ticks: 0, history: [(position.y, yaw); 64], head: 0, @@ -426,6 +438,10 @@ impl Dragon { pub fn alive(&self) -> bool { self.health > 0.0 } + /// `isDeadOrDying` for the killing blow: the dying flight, or no health. + fn down(&self) -> bool { + matches!(self.phase, Phase::Dying { .. }) || !self.alive() + } fn part_box(&self, i: usize) -> Aabb { let (w, h) = PART_SIZES[i]; let p = self.parts[i].0; @@ -693,7 +709,7 @@ impl Game<'_> { } /// The players in the level the dragon sees (`level.players()`): the /// window's own, then every guest in the level. - fn fight_players(&self) -> Vec { + pub(super) fn fight_players(&self) -> Vec { let mut out = Vec::new(); if let Some(p) = self.player.as_ref() { out.push(FightPlayer { @@ -722,7 +738,7 @@ impl Game<'_> { } /// Run `f` as the fight's player `who` (a guest's turn, its knockback /// sent to its client). - fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { + pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) { match who { FightWho::Host => f(self), FightWho::Guest(conn) => { @@ -828,8 +844,10 @@ impl Game<'_> { continue; } let time = self.world.random.next_int_bound(100_000).unwrap_or(0); + // EndCrystal's constructor takes an id from the entity counter. + self.next_entity += 1; self.crystals.push(Crystal { - id: 0, + id: self.next_entity, position: at, show_bottom: true, time, @@ -891,8 +909,12 @@ impl Game<'_> { .any(|p| p.distance_squared(c.position) < 0.01); if damage == CrystalDamage::Direct { let at = [c.position.x, c.position.y, c.position.z]; + // The blast's damage source carries the player, so the dragon + // remembers them before the blast can kill it. + self.fight.blast_by = by; let _ = self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false); + self.fight.blast_by = None; } if respawn_crystal { self.abort_respawn(); @@ -903,7 +925,7 @@ impl Game<'_> { if let Some(d) = self.dragon.as_mut() { if d.nearest_crystal == Some(index) { d.nearest_crystal = None; - self.hurt_dragon(0, 10.0, true, false); + self.hurt_dragon(0, 10.0, true, by, false); } else if let Some(n) = d.nearest_crystal.as_mut() && *n > index { @@ -975,18 +997,46 @@ impl Game<'_> { self.destroy_crystal(index, by, CrystalDamage::Direct); Hurt::Landed { full: true } } else if id >= SPECIAL { - self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow) + let by = by_player.then(|| self.acting_player()); + self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow) } else { Hurt::Refused } } /// `EnderDragon.hurt(part, source, damage)` on the dragon in the level. - fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt { + /// `by` is the player behind the hit, remembered for the kill credit. + fn hurt_dragon( + &mut self, + part: usize, + damage: f32, + counts: bool, + by: Option, + arrow: bool, + ) -> Hurt { let Some(d) = self.dragon.as_mut() else { return Hurt::Refused; }; + let was_down = d.down(); + let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt); let hurt = d.hurt(part, damage, counts, arrow); + // LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer + // (100 ticks); a hit the damage cooldown absorbs returns before that. + if counts + && matches!(hurt, Hurt::Landed { .. }) + && (d.invulnerable != cooldown || d.last_hurt != last_hurt) + && let Some(who) = by + { + d.hurt_by_player = Some((who, d.ticks)); + } + if !was_down && d.down() { + self.credit_dragon_kill(); + } + let Some(d) = self.dragon.as_mut() else { + return hurt; + }; if hurt == (Hurt::Landed { full: true }) { + // Mob.playHurtSound: the ambient sound clock starts again. + d.ambient_time = -AMBIENT_INTERVAL; let at = [d.position.x, d.position.y, d.position.z]; let pitch = 0.8 + self.world.random.next_float() * 0.3; self.audio @@ -994,6 +1044,47 @@ impl Game<'_> { } hurt } + /// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player + /// who hurt the dragon within the last 100 ticks. A guest's kill is + /// theirs: their statistics, scores and advancements. + fn credit_dragon_kill(&mut self) { + let Some(d) = self.dragon.as_ref() else { + return; + }; + let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else { + return; + }; + let view = horizon_client::advancements::EntityView { + kind: "minecraft:ender_dragon".to_owned(), + position: [d.position.x, d.position.y, d.position.z], + ..Default::default() + }; + let id = d.id; + let guest = match who { + FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn), + _ => None, + }; + let credited = guest.is_some_and(|conn| { + let Some(mut host) = self.lan.take() else { + return false; + }; + let done = self + .as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view)) + .is_some(); + self.lan = Some(host); + done + }); + if !credited { + self.award_dragon_kill(id, &view); + } + } + /// `ServerPlayer.awardKillScore` for the dragon, on the killer's state. + fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) { + self.stats.add("killed", "minecraft:ender_dragon", 1); + self.stats.custom("mob_kills", 1); + self.score_player_killed(super::commands::Target::Mob(id)); + self.trigger_entity("minecraft:player_killed_entity", "entity", view); + } /// Player melee on the dragon's parts or a crystal under the crosshair. /// The dragon part under the crosshair within reach: its id and distance. pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> { @@ -1566,6 +1657,7 @@ impl Game<'_> { age: 0, duration: 200, from_dragon: true, + potion: None, }); } } @@ -1658,6 +1750,24 @@ impl Game<'_> { self.tick_dragon_death(); return; } + // Mob.baseTick: the ambient sound, rarer right after one. + let roll = d.random.next_int_bound(1000).unwrap_or(999); + let waited = d.ambient_time; + d.ambient_time += 1; + if roll < waited { + d.ambient_time = -AMBIENT_INTERVAL; + let at = [d.position.x, d.position.y, d.position.z]; + // LivingEntity.getVoicePitch, from the dragon's own random. + let r = &mut d.random; + let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0; + self.audio.play( + "minecraft:entity.ender_dragon.ambient", + Some(at), + 5.0, + pitch, + ); + } + let d = self.dragon.as_mut().expect("dragon"); // Flapping, growls and the flap sound. let flap_was = (d.old_flap * std::f32::consts::TAU).cos(); let v = d.velocity; @@ -2078,12 +2188,18 @@ impl Game<'_> { age: 0, duration: 600, from_dragon: false, + potion: None, }); - self.audio.play( - "minecraft:entity.dragon_fireball.explode", - Some([next.x, next.y, next.z]), - 1.0, - 1.0, + // DragonFireball.onHit: level event 2006 (the burst of dragon + // breath and the explosion sound) at its block position. + self.level_event_at( + 2006, + [ + next.x.floor() as i32, + next.y.floor() as i32, + next.z.floor() as i32, + ], + 1, ); return false; } @@ -2110,6 +2226,10 @@ impl Game<'_> { let mut harmed: Vec<(FightWho, f32)> = Vec::new(); let mut clouds = std::mem::take(&mut self.breath_clouds); clouds.retain_mut(|c| { + // Lingering potions' clouds tick with the potions. + if c.potion.is_some() { + return true; + } c.age += 1; if c.age >= c.duration { return false; @@ -2127,10 +2247,12 @@ impl Game<'_> { c.at[1], c.at[2] + f64::from(a.sin() * d), ]; - self.particles.add( + self.particles.add_with( &ParticleOptions::simple(ParticleType::DragonBreath), at, [0.0, 0.0, 0.0], + false, + true, ); } // AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity. @@ -2157,11 +2279,14 @@ impl Game<'_> { } /// Damage from the dragon, which is its causing entity /// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the - /// death message names it. + /// death message names it, or the cloud once the dragon is gone. fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) { if let Some(id) = self.dragon.as_ref().map(|d| d.id) { let name = Text::translate("entity.minecraft.ender_dragon", &[]); self.note_named_attacker(damage_type, id, name); + } else if damage_type == "minecraft:indirect_magic" { + let name = Text::translate("entity.minecraft.area_effect_cloud", &[]); + self.note_direct_entity(damage_type, name); } self.hurt_player_by(damage_type, amount); } @@ -2254,8 +2379,22 @@ pub(super) struct Breath { pub(super) at: [f64; 3], pub(super) radius: f32, pub(super) age: i32, - duration: i32, - from_dragon: bool, + pub(super) duration: i32, + pub(super) from_dragon: bool, + /// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies. + pub(super) potion: Option, +} +impl Breath { + /// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a + /// lingering potion's). + pub(super) fn waiting(&self) -> bool { + self.age + < if self.potion.is_some() { + super::potions::LINGERING_WAIT + } else { + 20 + } + } } impl Game<'_> { /// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`. @@ -2482,6 +2621,8 @@ impl Game<'_> { layer: 2, color: c, light: 0, + + glint_uv: [0.0, 0.0], }); } } @@ -2547,6 +2688,8 @@ impl Game<'_> { layer: 6, color: c, light: wrap_light, + + glint_uv: [0.0, 0.0], }); } (last_sin, last_cos, last_u) = (sin, cos, uu); @@ -2592,13 +2735,17 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } } pub(super) fn fight_ready(&mut self) -> bool { self.load_fight(); - self.fight.loaded + // spawnExitPortal runs from scanState and the dragon's death, both + // only with players in the fight. + self.fight.loaded && self.fight.players } pub(super) fn exit_portal(&self) -> Option<[i32; 3]> { self.fight.exit_portal @@ -2715,8 +2862,9 @@ impl Game<'_> { let (lo, hi) = (b.min_position(), b.max_position()); self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]) }); + let by = self.fight.blast_by; for (part, amount) in hits { - self.hurt_dragon(part, amount, true, false); + self.hurt_dragon(part, amount, true, by, false); } } } @@ -2830,8 +2978,11 @@ impl Game<'_> { return false; } let time = self.world.random.next_int_bound(100_000).unwrap_or(0); + // EndCrystal's constructor takes an id from the entity counter, so + // the crystal is an entity a command can name (`@e[type=end_crystal]`). + self.next_entity += 1; self.crystals.push(Crystal { - id: 0, + id: self.next_entity, position: Vec3::new(x + 0.5, y, z + 0.5), show_bottom: false, time, @@ -2848,6 +2999,21 @@ impl Game<'_> { self.swing_arm(); true } + /// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast + /// (`Entity.kill` does not hurt it). + pub(super) fn kill_crystal(&mut self, id: u32) { + if let Some(i) = self.crystals.iter().position(|c| c.id == id) { + self.destroy_crystal(i, None, CrystalDamage::Explosion); + } + } + /// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it + /// does not hurt it (the dragon flies its death flight when its health + /// runs out). + pub(super) fn kill_dragon(&mut self, id: u32) { + if self.dragon.as_ref().is_some_and(|d| d.id == id) { + self.dragon = None; + } + } /// Any entity touching `room` (`Level.getEntities(null, box)`). fn entity_in(&self, room: Aabb) -> bool { let touches = |lo: Vec3, hi: Vec3| { diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 7cdb3e526..5688a3226 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -3,10 +3,14 @@ //! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power //! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`), //! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and -//! the armor attribute effects (respiration, depth strider, ...). +//! the armor attribute effects (respiration, depth strider, ...) and Soul +//! Speed's `location_changed` and `tick` effects. use super::Game; -use horizon_client::{enchant::EffectContext, items::ItemStack}; -use horizon_core::random::RandomSource; +use horizon_client::{ + enchant::{EffectContext, Enchantments, level_value}, + items::ItemStack, +}; +use horizon_core::{position::BlockPos, random::RandomSource}; /// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`). #[derive(Clone, Copy)] @@ -153,8 +157,10 @@ impl Game<'_> { items: Some(self.res.items()), ..Default::default() }; - // minecraft:post_attack ignite effects of the attacker's weapon (fire aspect). + // minecraft:post_attack effects of the attacker's weapon on the victim: + // ignite (fire aspect) and apply_mob_effect (bane of arthropods). let mut ignite = 0.0f32; + let mut mob_effects = Vec::new(); for (id, level) in held.enchantments() { let Some(def) = self.res.stations().enchantments.get(id) else { continue; @@ -164,24 +170,33 @@ impl Game<'_> { .into_iter() .flatten() { - if effect["enchanted"] == "attacker" - && effect["affected"] == "victim" - && effect["effect"]["type"] == "minecraft:ignite" + let e = &effect["effect"]; + let kind_of = e["type"].as_str(); + if effect["enchanted"] != "attacker" + || effect["affected"] != "victim" + || !matches!( + kind_of, + Some("minecraft:ignite" | "minecraft:apply_mob_effect") + ) { - let mut random = self.world.random.clone(); - let ok = effect.get("requirements").is_none_or(|r| { - self.res - .stations() - .enchantments - .condition(r, *level, &ctx, &mut random) - }); - self.world.random = random; - if ok { - ignite = ignite.max(horizon_client::enchant::level_value( - &effect["effect"]["duration"], - *level, - )); - } + continue; + } + let mut random = self.world.random.clone(); + let ok = effect.get("requirements").is_none_or(|r| { + self.res + .stations() + .enchantments + .condition(r, *level, &ctx, &mut random) + }); + self.world.random = random; + if !ok { + continue; + } + if kind_of == Some("minecraft:ignite") { + ignite = + ignite.max(horizon_client::enchant::level_value(&e["duration"], *level)); + } else { + mob_effects.push((e.clone(), *level)); } } } @@ -191,8 +206,41 @@ impl Game<'_> { { m.ignite_for_ticks((ignite * 20.0) as i32); } + for (effect, level) in mob_effects { + self.apply_mob_effect(mob, &effect, level); + } self.post_attack_bursts(&held, &kind); } + /// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a + /// random duration and amplifier between the level-based bounds. + fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) { + use horizon_client::enchant::level_value; + let Some(m) = self.mobs.get_mut(mob) else { + return; + }; + let ids: Vec<&str> = match &effect["to_apply"] { + serde_json::Value::String(id) => vec![id.as_str()], + serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(), + _ => Vec::new(), + }; + if ids.is_empty() { + return; + } + // HolderSet.getRandomElement, then Mth.randomBetween for both values. + let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize; + let mut between = |min: &str, max: &str| { + let (lo, hi) = ( + level_value(&effect[min], level), + level_value(&effect[max], level), + ); + m.random.next_float() * (hi - lo) + lo + }; + let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32; + let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0); + if let Some(id) = super::potions::mob_effect_id(ids[pick]) { + m.add_effect(id, ticks, amplifier); + } + } /// `minecraft:post_attack` explode effects on the attacker (wind burst): /// a burst at the player's feet that only knocks back and triggers /// blocks (`ExplodeEffect` with `block_interaction: trigger`). @@ -303,8 +351,10 @@ impl Game<'_> { continue; } hits.push(1.0 + self.world.random.next_float() * 4.0); + // ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies. + let loss = self.durability_loss(&stack, 2); if let Some(s) = self.armor[slot].as_mut() { - s.damage += 2; + s.damage += loss; if self .res .items() @@ -384,7 +434,9 @@ impl Game<'_> { } } /// Bow enchantments at release: (extra base damage, infinity keeps the arrow). - pub(super) fn bow_enchantments(&mut self) -> (f64, bool) { + /// `ammo` is the item being fired: `Enchantment.itemContext` hands it to the + /// `ammo_use` conditions as the tool, so Infinity only frees plain arrows. + pub(super) fn bow_enchantments(&mut self, ammo: &str) -> (f64, bool) { let Some(bow) = self.inventory[self.selected].clone() else { return (0.0, false); }; @@ -403,7 +455,7 @@ impl Game<'_> { 0.0, ); let ammo_ctx = EffectContext { - tool: Some("minecraft:arrow"), + tool: Some(ammo), items: Some(self.res.items()), ..Default::default() }; @@ -456,6 +508,134 @@ impl Game<'_> { } ignite } + /// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of + /// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The + /// `location_changed` effects run when the player enters a new block, lands, + /// or its boots change: + /// - the speed and movement efficiency modifiers turn on over a soul speed + /// block (not flying, not riding) and stay on while airborne, then off + /// (`enchantment_active_check`); + /// - the boots lose 1 durability with a 4% chance when on such a block. + /// + /// Every fifth tick the player walking on one also gets a soul particle + /// (returned as its position and velocity, for the caller to show or send + /// to the players around) and sometimes a sound. Runs for the window's own + /// player and for each guest on the server (`guest_server_tick`); a remote + /// client leaves it to its server. + pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> { + const ID: &str = "minecraft:soul_speed"; + let player = self.player.as_mut()?; + if player.client_side { + return None; + } + let level = self.armor[3] + .as_ref() + .map_or(0, |s| s.enchantment_level(ID)); + let position = player.position; + let (on_ground, flying) = (player.ground.on_ground, player.flying); + let moved = position.subtract(player.old_position); + let (tick, width) = (player.health.tick_count, player.dimensions.width); + let fx = &mut player.location_effects; + let on_soul = fx.on_soul_speed_block; + let block = BlockPos::containing(position); + let changed_block = fx.block.replace(block) != Some(block); + let landed = std::mem::take(&mut fx.landed); + // Changed boots: `stopLocationBasedEffects`, then the effects run again. + let equipped = std::mem::replace(&mut fx.boots_level, level) != level; + let was_active = fx.applied_level; + let mut active = was_active > 0 && !equipped; + let mut wear = false; + if level > 0 && (changed_block || landed || equipped) { + let riding = self.riding.is_some(); + let wanted = !riding && !flying && (on_soul || (active && !on_ground)); + active = wanted; + // `random_chance` is the first term of the wear's `all_of`: it + // draws from the level random before the block is looked at. + let roll = self.world.random.next_float() < 0.04; + wear = roll && on_ground && on_soul; + } + fx.applied_level = if active { level } else { 0 }; + // `Entity.getKnownMovement` horizontal speed of at least 1.0E-5. + let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10; + let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0; + // The attribute effects were applied to the player before: swap them. + if was_active > 0 && (!active || equipped) { + for (attribute, id, _, op) in + soul_speed_modifiers(&self.res.stations().enchantments, was_active) + { + let _ = player.set_modifier(&attribute, &id, 0.0, &op, false); + } + } + if active && (was_active == 0 || equipped) { + for (attribute, id, amount, op) in + soul_speed_modifiers(&self.res.stations().enchantments, level) + { + let _ = player.set_modifier(&attribute, &id, amount, &op, true); + } + } + if wear && !self.creative { + self.wear_boots(1); + } + if level == 0 { + return None; + } + let r = &mut self.world.random; + // `spawn_particles`: in the bounding box, 0.1 above the feet, thrown + // back against the movement (-0.2) and up (0.1). + let particle = tick_effects.then(|| { + let x = position.x + (r.next_double() - 0.5) * f64::from(width); + let z = position.z + (r.next_double() - 0.5) * f64::from(width); + ( + [x, position.y + 0.1, z], + [moved.x * -0.2, 0.1, moved.z * -0.2], + ) + }); + // `play_sound`: its `random_chance` (35%) comes first in its `all_of`, + // so the level random is drawn every tick the boots are worn; volume + // 0.6, pitch 0.6 to 1.0. + if r.next_float() < 0.35 && tick_effects { + let pitch = r.next_float() * 0.4 + 0.6; + self.audio.play( + "minecraft:particle.soul_escape", + Some([position.x, position.y, position.z]), + 0.6, + pitch, + ); + } + particle + } + /// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`): + /// Unbreaking applies, and they break when worn out. + fn wear_boots(&mut self, amount: u32) { + let Some(stack) = self.armor[3].clone() else { + return; + }; + let loss = self.durability_loss(&stack, amount); + let max = self + .res + .items() + .get(&stack.item) + .map_or(0, |info| info.max_damage); + if max == 0 { + return; + } + let Some(boots) = self.armor[3].as_mut() else { + return; + }; + boots.damage += loss; + if boots.damage >= max { + self.armor[3] = None; + if let Some(eye) = self.eye_position() { + self.audio.play( + "minecraft:entity.item.break", + Some(eye), + 0.8, + 0.8 + self.world.random.next_float() * 0.4, + ); + } + self.apply_equipment(); + } + } /// Enchantment attribute modifiers of worn armor (respiration, depth strider...). /// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`), /// so the same enchantment on two pieces stacks instead of replacing itself. @@ -479,3 +659,28 @@ impl Game<'_> { self.potions.enchant_modifiers = wanted; } } + +/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation), +/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`. +fn soul_speed_modifiers( + enchantments: &Enchantments, + level: u32, +) -> Vec<(String, String, f64, String)> { + let Some(e) = enchantments.get("minecraft:soul_speed") else { + return Vec::new(); + }; + e.effects["minecraft:location_changed"][0]["effect"]["effects"] + .as_array() + .into_iter() + .flatten() + .filter(|a| a["type"] == "minecraft:attribute") + .map(|a| { + ( + a["attribute"].as_str().unwrap_or("").to_owned(), + format!("{}/feet", a["id"].as_str().unwrap_or("")), + f64::from(level_value(&a["amount"], level)), + a["operation"].as_str().unwrap_or("add_value").to_owned(), + ) + }) + .collect() +} diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 32d5113b0..41c4e1628 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -5,6 +5,7 @@ use super::{Dim, Game}; use horizon_client::{ Result, + containers::{BlockEntity, EndGateway}, environment::Environment, items::ItemStack, mesher::Vertex, @@ -19,7 +20,7 @@ use horizon_core::{ use std::{collections::HashMap, sync::Arc}; /// ServerLevel.END_SPAWN_POINT. -const END_SPAWN: [i32; 3] = [100, 50, 0]; +pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0]; /// EnderDragonFight gateway ring radius and height. const GATEWAY_RADIUS: f64 = 96.0; const GATEWAY_Y: i32 = 75; @@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75; const GATEWAY_FILE: &str = "horizon-end-gateways.json"; /// A gateway teleport waiting for its destination chunks. -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum GatewayJob { /// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway. - Find { gateway: [i32; 3] }, + /// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded. + Find { gateway: [i32; 3], exact: bool }, /// A known exit (exact, or findExitPosition around it). Known { exit: [i32; 3], exact: bool }, } +impl GatewayJob { + /// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks. + fn for_gateway( + gateway: [i32; 3], + entity: Option<&BlockEntity>, + legacy: Option<([i32; 3], bool)>, + ) -> Self { + let configured = match entity { + Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)), + _ => None, + }; + match configured.or(legacy) { + Some((exit, exact)) => Self::Known { exit, exact }, + // Worldgen return gateways (end_gateway_return) lead exactly to the End spawn. + None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known { + exit: END_SPAWN, + exact: true, + }, + None => Self::Find { + gateway, + exact: exact_teleport(entity), + }, + } + } +} + +/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway +/// (false without one). +fn exact_teleport(entity: Option<&BlockEntity>) -> bool { + matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport) +} + +/// Moves an exit that only the side file knew into the gateway's block entity, unless the block +/// entity has its own; true when it took the saved one. +fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool { + if entity.exit_portal.is_some() { + return false; + } + entity.exit_portal = Some(exit); + entity.exact_teleport = exact; + true +} + +/// The end of `getPortalPosition`: the exit itself when the teleport is exact, +/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`). +fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] { + if exact { exit } else { find(exit) } +} + #[derive(Default)] pub(super) struct EndState { /// Gateway position -> (exit, exact teleport); loaded lazily from the save. @@ -247,16 +298,25 @@ impl Game<'_> { layer: 2, color, light: 0, + + glint_uv: [0.0, 0.0], }); } } out } - /// LightmapRenderStateExtractor: End flashes add to the sky light factor. + /// LightmapRenderStateExtractor: End flashes add to the sky light factor + /// unless Hide Lightning Flashes is on (the sky sprite is not gated), a + /// third of it under a boss bar's world fog. pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) { - if self.dim == Dim::End { - sky.sky_light_factor += self.end.flash.intensity(partial as f32); + if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") { + let intensity = self.end.flash.intensity(partial as f32); + sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() { + intensity / 3.0 + } else { + intensity + }; } } @@ -599,7 +659,9 @@ impl Game<'_> { /// EnderDragonFight without a dragon: once the centre of the main island /// is loaded, place the active exit portal (EndPodiumFeature) and the - /// first gateway (spawnNewGateway) if the portal is not there yet. + /// first gateway (spawnNewGateway) if the portal is not there yet. Like + /// scanState and spawnExitPortal, only while the fight has no exit portal + /// location: a podium that is later broken stays broken. fn ensure_exit_portal(&mut self) -> Result<()> { if self.travelling.is_some() || !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz))) @@ -609,8 +671,14 @@ impl Game<'_> { } let won = self.fight.previously_killed; let gateway = self.first_gateway(); - if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") { - // The podium exists; the gateway may have been out of range when it was placed. + if self.exit_portal().is_none() { + // findExitPortal remembers the location of a podium that is already there. + if let Some(level) = self.podium_level() { + self.set_exit_portal([0, level, 0]); + } + } + if self.exit_portal().is_some() { + // The gateway may have been out of range when it was placed. if won && self.world.loaded(gateway[0], gateway[2]) && &*self.block_name(gateway) != "minecraft:end_gateway" @@ -641,17 +709,40 @@ impl Game<'_> { } Ok(()) } + /// The portal level of the podium around the centre column, read from the + /// bedrock that EndPodiumFeature places (rim at the portal level, disc + /// below it, the torch column) so a broken column does not hide it. + fn podium_level(&self) -> Option { + let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect(); + for z in -4..=4i32 { + for x in -4..=4i32 { + let flat = f64::from(x * x + z * z); + if (6.25..12.25).contains(&flat) { + offsets.push([x, 0, z]); + } else if flat + 1.0 < 6.25 { + offsets.push([x, -1, z]); + } + } + } + (1..253) + .map(|y| { + let score = offsets + .iter() + .filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock") + .count(); + (score, y) + }) + .filter(|&(score, _)| score >= 3) + .max() + .map(|(_, y)| y) + } /// `spawnExitPortal(true)` after the dragon dies. pub(super) fn activate_exit_portal(&mut self) -> Result<()> { let origin = match self.exit_portal() { Some(o) => o, None => { - // The rim's level: the top of the bedrock column minus three. - let top = (0..256) - .rev() - .find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") - .unwrap_or(64); - [0, top - 3, 0] + // The rim's level, found without assuming the centre column is intact. + [0, self.podium_level().unwrap_or(61), 0] } }; self.place_podium(origin, true) @@ -754,6 +845,11 @@ impl Game<'_> { } } } + self.world + .block_entities + .get_or_insert_with((origin[0], origin[1], origin[2]), || { + BlockEntity::EndGateway(Default::default()) + }); Ok(()) } @@ -775,8 +871,7 @@ impl Game<'_> { self.end.exits.as_mut().expect("loaded exits") } - fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> { - self.gateway_exits().insert(gateway, (exit, exact)); + fn save_gateway_exits(&mut self) -> Result<()> { let list: Vec<_> = self .gateway_exits() .iter() @@ -788,6 +883,35 @@ impl Game<'_> { Ok(()) } + /// Drops a gateway's entry from the side file: its block entity is the source of truth. + fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> { + if self.gateway_exits().remove(&gateway).is_some() { + self.save_gateway_exits()?; + } + Ok(()) + } + + fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> { + // TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit). + let key = (gateway[0], gateway[1], gateway[2]); + let chunk = (gateway[0] >> 4, gateway[2] >> 4); + // The gateway's chunk can be unloaded by now (travel waits for the destination): the + // exit then waits in the side file until the gateway is used again (`gateway_job`). + if self.world.chunks.contains_key(&chunk) + && let BlockEntity::EndGateway(g) = self + .world + .block_entities + .get_or_insert_with(key, || BlockEntity::EndGateway(Default::default())) + { + g.exit_portal = Some(exit); + g.exact_teleport = exact; + self.world.modified.insert(chunk); + return self.forget_gateway_exit(gateway); + } + self.gateway_exits().insert(gateway, (exit, exact)); + self.save_gateway_exits() + } + /// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition: /// travel within the End, waiting for the destination chunks to load. fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> { @@ -807,25 +931,39 @@ impl Game<'_> { /// its exit, or the search for one, and the spot to load before either /// is resolved ([`Game::resolve_gateway`]). pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) { - let known = self.gateway_exits().get(&gateway).copied(); - let job = match known { - Some((exit, exact)) => GatewayJob::Known { exit, exact }, - // Worldgen return gateways (end_gateway_return) lead exactly to the End spawn. - None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => { - GatewayJob::Known { - exit: END_SPAWN, - exact: true, - } + let mut known = self.gateway_exits().get(&gateway).copied(); + // Once the gateway's chunk is loaded its block entity is the source of truth: an exit the + // side file kept for it moves there, and the entry is dropped, so removing or replacing + // the exit (`/data remove block`) is not undone by an old entry. + let key = (gateway[0], gateway[1], gateway[2]); + let chunk = (gateway[0] >> 4, gateway[2] >> 4); + if let Some(saved) = known + && self.world.chunks.contains_key(&chunk) + && let BlockEntity::EndGateway(g) = self + .world + .block_entities + .get_or_insert_with(key, || BlockEntity::EndGateway(Default::default())) + { + if adopt_saved_exit(g, saved) { + self.world.modified.insert(chunk); } - None => GatewayJob::Find { gateway }, - }; + known = None; + let _ = self.forget_gateway_exit(gateway); + } + let job = GatewayJob::for_gateway( + gateway, + self.world + .block_entities + .get(&(gateway[0], gateway[1], gateway[2])), + known, + ); let destination = match job { GatewayJob::Known { exit, .. } => Vec3::new( f64::from(exit[0]) + 0.5, f64::from(exit[1]), f64::from(exit[2]) + 0.5, ), - GatewayJob::Find { gateway } => { + GatewayJob::Find { gateway, .. } => { let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2])) .normalize_or_zero() * 1024.0; @@ -882,12 +1020,10 @@ impl Game<'_> { [p[0], p[1] + 1, p[2]] }; match job { - GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact { - exit - } else { - find_exit(self, exit) - })), - GatewayJob::Find { gateway } => { + GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| { + find_exit(self, e) + }))), + GatewayJob::Find { gateway, exact } => { // findExitPortalXZPosTentative over the loaded chunks. let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2])) .normalize_or_zero(); @@ -947,8 +1083,11 @@ impl Game<'_> { let exit = [tallest[0], tallest[1] + 10, tallest[2]]; self.place_gateway(exit)?; self.set_gateway_exit(exit, gateway, false)?; - self.set_gateway_exit(gateway, exit, false)?; - Ok(bottom_center(find_exit(self, exit))) + // setExitPosition keeps the gateway's own exactTeleport. + self.set_gateway_exit(gateway, exit, exact)?; + Ok(bottom_center(exit_target(exit, exact, |e| { + find_exit(self, e) + }))) } } } @@ -1240,8 +1379,103 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: full, + + glint_uv: [0.0, 0.0], }); } } } } + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::containers::EndGateway; + + #[test] + fn a_saved_exit_moves_into_a_gateway_without_one() { + let saved = ([8, 100, 0], true); + let mut bare = EndGateway::default(); + assert!(adopt_saved_exit(&mut bare, saved)); + assert_eq!(bare.exit_portal, Some([8, 100, 0])); + assert!(bare.exact_teleport); + // The block entity's own exit wins over a stale entry. + let mut own = EndGateway { + exit_portal: Some([1, 2, 3]), + ..Default::default() + }; + assert!(!adopt_saved_exit(&mut own, saved)); + assert_eq!(own.exit_portal, Some([1, 2, 3])); + assert!(!own.exact_teleport); + } + + #[test] + fn a_new_exit_keeps_the_gateways_exact_teleport() { + let exact = BlockEntity::EndGateway(EndGateway { + exact_teleport: true, + ..Default::default() + }); + let plain = BlockEntity::EndGateway(EndGateway::default()); + assert!(exact_teleport(Some(&exact))); + assert!(!exact_teleport(Some(&plain))); + assert!(!exact_teleport(None)); + assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector))); + let exit = [8, 100, 0]; + // Exact: the exit itself, nothing is searched for. + assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit); + assert_eq!( + exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]), + [8, 111, 0] + ); + } + + #[test] + fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() { + let gateway = [702, 100, 0]; + let exit = [8, 100, 0]; + for exact in [true, false] { + let entity = BlockEntity::EndGateway(EndGateway { + exit_portal: Some(exit), + exact_teleport: exact, + ..Default::default() + }); + assert_eq!( + GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))), + GatewayJob::Known { exit, exact }, + ); + } + let empty = BlockEntity::EndGateway(EndGateway::default()); + for entity in [None, Some(&empty)] { + assert_eq!( + GatewayJob::for_gateway(gateway, entity, Some((exit, false))), + GatewayJob::Known { exit, exact: false } + ); + assert_eq!( + GatewayJob::for_gateway(gateway, entity, None), + GatewayJob::Known { + exit: END_SPAWN, + exact: true + } + ); + let nearby = [96, 75, 0]; + let exact = BlockEntity::EndGateway(EndGateway { + exact_teleport: true, + ..Default::default() + }); + assert_eq!( + GatewayJob::for_gateway(nearby, Some(&exact), None), + GatewayJob::Find { + gateway: nearby, + exact: true + } + ); + assert_eq!( + GatewayJob::for_gateway(nearby, entity, None), + GatewayJob::Find { + gateway: nearby, + exact: false + } + ); + } + } +} diff --git a/crates/horizon-client/src/game/entity_culling.rs b/crates/horizon-client/src/game/entity_culling.rs index 66fbaa3e4..971639a42 100644 --- a/crates/horizon-client/src/game/entity_culling.rs +++ b/crates/horizon-client/src/game/entity_culling.rs @@ -60,12 +60,27 @@ impl Game<'_> { } } +impl Game<'_> { + /// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity + /// distance option times the render distance's boost — 1 at 8 chunks, 2.5 + /// at 20 or more. The render distance is the client setting, capped by + /// the server's view distance when connected + /// (Options.getEffectiveRenderDistance). + pub(super) fn view_scale(&self) -> f64 { + let distance = match &self.remote { + Some(r) => self.settings.render_distance.min(r.view_distance), + None => self.settings.render_distance, + }; + (f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance) + } +} + /// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding -/// box's mean edge, scaled by the entity distance option. -pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 { +/// box's mean edge, scaled by the view scale. +pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 { let edge = (size.x + size.y + size.z) / 3.0; let edge = if edge.is_nan() { 1.0 } else { edge }; - edge * 64.0 * f64::from(view_scale) + edge * 64.0 * view_scale } impl Game<'_> { diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 6ecba3a54..88ef4263a 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -296,15 +296,23 @@ impl Game<'_> { if !creative && !spectator && hurtable - && let Ok(o) = p.hurt_blocking( + && let Ok(o) = p.hurt_caused_by( &self.motion.health, self.world.health_rules, id, amount, blocking, + // The exploding mob is the causing entity. + exploding.is_some(), ) { outcome = o; + // dealDefaultKnockback's `indicateDamage` points at the blast + // (the source position), not the no-direction fallback. + if o.knockback && o.blocked <= 0.0 { + p.health + .indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw); + } } if !spectator && !(creative && flying) { p.velocity = Vec3::new( @@ -314,9 +322,6 @@ impl Game<'_> { ); } } - if outcome.hurt && !creative { - self.hurt_armor(amount); - } if raised { self.after_block(outcome, amount, None, false); } @@ -391,9 +396,18 @@ impl Game<'_> { [feet[0] - w, feet[1], feet[2] - w], [feet[0] + w, feet[1] + h, feet[2] + w], ); + // The exploding mob is the damage source's entity, so victims + // it kills drop its charged-creeper loot (`Creeper.killedEntity`). let source = horizon_client::mobs::Damage { kind: horizon_client::mobs::DamageKind::Explosion, - attacker: None, + // A projectile's owner (`getIndirectSourceEntity`), else + // the exploding mob itself. + attacker: self + .combat + .explosion_owner + .or(exploding) + .filter(|&o| o != m.id) + .map(horizon_client::mobs::Who::Mob), from: Some(center), }; if hurtable && !m.damage(damage(dist, exposure), source) { @@ -489,6 +503,15 @@ impl Game<'_> { ); } } + /// Runs an explosion whose source empties the hives it destroys + /// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither, + /// TNT minecart). + pub(super) fn with_hive_emptying_blast(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R { + self.combat.hive_emptying_blast = true; + let result = blast(self); + self.combat.hive_emptying_blast = false; + result + } /// `Level.explode` on the server: positions, entities, blocks, fire; then /// the client's explosion sound. pub(super) fn server_explode( @@ -571,16 +594,16 @@ impl Game<'_> { let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7; self.audio .play("minecraft:entity.generic.explode", Some(center), 4.0, pitch); - let block_count = if interaction == BlockInteraction::Keep { - 0 - } else { - targets.len() - }; + // ServerLevel.explode sends toBlow.size() whatever the interaction, and + // `ServerExplosion.isSmall` also covers an explosion that keeps blocks. + let block_count = targets.len(); + let small = radius < 2.0 || interaction == BlockInteraction::Keep; let (world, facts) = (&self.world, self.res.facts()); - self.particles.explosion(center, radius, block_count, &|b| { - facts.is_air(world.block(b[0], b[1], b[2])) - }); - self.lan_explode(center, radius, block_count); + self.particles + .explosion(center, small, block_count, radius, &|b| { + facts.is_air(world.block(b[0], b[1], b[2])) + }); + self.lan_explode(center, small, radius, block_count); Ok(()) } /// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse). @@ -597,9 +620,30 @@ impl Game<'_> { } let name = self.res.facts().get(state).name.clone(); let is_tnt = &*name == "minecraft:tnt"; + // CreakingHeartBlock.onExplosionHit: its creaking dies (no + // experience, as Horizon does not track a TNT's owner). + if &*name == "minecraft:creaking_heart" { + self.creaking_heart_broken(pos, false); + } // Block.onExplosionHit: the drops copy from the block entity, which // then spills its contents as the block turns to air. let key = (pos[0], pos[1], pos[2]); + let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest"); + // BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a + // creeper, a wither skull, a wither or a TNT minecart come out. + if is_hive && self.combat.hive_emptying_blast { + let facing = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("facing").cloned()); + self.empty_hive( + pos, + horizon_client::beehive::facing_step(facing.as_deref()), + horizon_client::beehive::Release::Emergency, + ); + } if let Some(entity) = self.world.block_entities.get(&key).cloned() { let custom = self.world.block_names.get(&key).cloned(); self.pending.record(pos, &entity, &name, custom); @@ -620,6 +664,7 @@ impl Game<'_> { &mut self.world.random, Some((&mut self.rand_sequences, self.level.seed)), decay, + false, ); for mut stack in drops { if &*stack.item == "minecraft:air" || stack.count == 0 { @@ -631,6 +676,10 @@ impl Game<'_> { } self.pending.clear(pos); self.level_set(pos, self.world.air)?; + // BeehiveBlock.onExplosionHit: the bees about turn on the player. + if is_hive { + self.anger_nearby_bees(pos); + } // TntBlock.wasExploded: new PrimedTnt (its push drawn first), then // getRandomShortFuse(80); no game event. if is_tnt && self.ui.rules.bool("tnt_explodes") { diff --git a/crates/horizon-client/src/game/falling_damage.rs b/crates/horizon-client/src/game/falling_damage.rs index 8eec28d1d..41c714561 100644 --- a/crates/horizon-client/src/game/falling_damage.rs +++ b/crates/horizon-client/src/game/falling_damage.rs @@ -64,6 +64,25 @@ impl Game<'_> { pos: BlockPos::new(pos[0], pos[1], pos[2]), data: 0, }); + } else if self + .res + .facts() + .get(block.state) + .name + .starts_with("minecraft:suspicious_") + { + // BrushableBlock.onBrokenAfterFall. + let at = BlockPos::new(pos[0], pos[1], pos[2]); + self.level_events.push(LevelEvent::LevelEvent { + id: 2001, + pos: at, + data: block.state.0 as i32, + }); + self.level_events.push(LevelEvent::GameEvent { + pos: at, + event: "minecraft:block_destroy", + state: None, + }); } return; } @@ -108,16 +127,10 @@ impl Game<'_> { } else { amount }; - let hurt = if let Some(p) = self.player.as_mut() + if let Some(p) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(kind) { - p.hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false) - } else { - false - }; - if hurt { - self.hurt_armor(amount); + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); } } } diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index cdf2d17a5..6c20ccf93 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -7,12 +7,13 @@ use std::sync::Arc; #[derive(Clone, Debug)] pub(super) struct Fangs { - pos: [f64; 3], - yaw: f32, + pub id: u32, + pub pos: [f64; 3], + pub yaw: f32, warmup: i32, - life: i32, + pub life: i32, owner: u32, - started: bool, + pub started: bool, } #[derive(Default)] pub(super) struct FangState { @@ -21,6 +22,11 @@ pub(super) struct FangState { } impl Game<'_> { pub(super) fn add_fangs(&mut self, at: [f64; 3], yaw: f32, delay: i32, owner: u32) { + self.next_entity += 1; + self.insert_fangs(self.next_entity, at, yaw, delay, owner); + } + + pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) { if self.fangs.model.is_none() { self.fangs.model = horizon_client::mobs::load_model( &self.options.resources, @@ -29,6 +35,7 @@ impl Game<'_> { .map(Arc::new); } self.fangs.list.push(Fangs { + id, pos: at, yaw, warmup: delay, @@ -37,6 +44,30 @@ impl Game<'_> { started: false, }); } + /// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack. + pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) { + if event == 4 + && let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) + { + f.started = true; + self.audio.play_unshared( + "minecraft:entity.evoker_fangs.attack", + Some(f.pos), + 1.0, + 1.0, + ); + } + } + + /// `EvokerFangs.tick`: clients only advance animation, never damage or discard. + pub(super) fn tick_remote_fangs(&mut self) { + for f in &mut self.fangs.list { + if f.started { + f.life -= 1; + } + } + } + /// `EvokerFangs.tick`. pub(super) fn tick_fangs(&mut self) { let mut list = std::mem::take(&mut self.fangs.list); @@ -50,7 +81,8 @@ impl Game<'_> { } if !f.started { f.started = true; - self.audio.play( + self.lan_entity_event(f.id as i32, 4, f.pos); + self.audio.play_unshared( "minecraft:entity.evoker_fangs.attack", Some(f.pos), 1.0, @@ -71,26 +103,49 @@ impl Game<'_> { let b = horizon_core::geometry::Aabb::new(lo, hi); // Every player in the box, the server's players too. let pos = f.pos; + // `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the + // owner lives, plain magic (nobody to name) once it is gone. + let owner = self + .mobs + .iter() + .any(|m| m.id == f.owner && m.alive()) + .then_some(f.owner); 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"); + let kind = match owner { + Some(_) => "minecraft:indirect_magic", + None => "minecraft:magic", + }; + g.mob_hits_player_caused(6.0, pos, kind, owner); } }; bite(self); self.each_guest_hit(bite); + // `AbstractIllager.considersEntityAsAlly`: with no teams, the illager + // tag is allied; `Evoker.considersEntityAsAlly` adds a vex whose + // owner is allied (itself or another illager). Teams are not modelled. + let illager = |s: Species| { + matches!( + s, + Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator + ) + }; + let illagers: Vec = self + .mobs + .iter() + .filter(|m| illager(m.species())) + .map(|m| m.id) + .collect(); + // A missing owner hurts everyone (`currentOwner == null`). + let owner_known = illagers.contains(&f.owner); for m in &mut self.mobs { - let ally = matches!( - m.species(), - Species::Evoker - | Species::Illusioner - | Species::Vex - | Species::Pillager - | Species::Vindicator - | Species::Witch - ); + let ally = owner_known + && (illager(m.species()) + || m.species() == Species::Vex + && illagers.contains(&(m.extra.counter2 as u32))); if m.id != f.owner && !ally && m.alive() && m.bounds().intersects(b) { m.damage( 6.0, diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index fd160a920..f66fd4d3a 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -23,7 +23,7 @@ pub(super) struct Rocket { /// The entity id a server tracks it by (0 until it is tracked). pub id: u32, pub(super) position: Vec3, - previous: Vec3, + pub(super) previous: Vec3, pub(super) velocity: Vec3, life: i32, lifetime: i32, @@ -62,11 +62,56 @@ impl Rocket { item, } } - /// One tick of a server's client: the previous position, then `step`. - pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) { + /// One tick of a server's client: the rocket flies on by itself between + /// the server's updates (a rocket that follows an entity is placed by the + /// caller). + pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) { self.previous = self.position; + if self.attached_to.is_none() { + self.free_flight(scene); + } self.life += 1; - self.position = step(self.position); + } + /// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed + /// up (an angled one keeps its motion) and rise, then move. True when a + /// block stopped it. + fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool { + let h = 0.125; + let bounds = Aabb::new( + Vec3::new(self.position.x - h, self.position.y, self.position.z - h), + Vec3::new( + self.position.x + h, + self.position.y + 2.0 * h, + self.position.z + h, + ), + ); + let wanted = if self.angled { + self.velocity + } else { + Vec3::new( + self.velocity.x * 1.15, + self.velocity.y + 0.04, + self.velocity.z * 1.15, + ) + }; + let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else { + return false; + }; + let blocked = (moved.x - wanted.x).abs() > 1e-7 + || (moved.y - wanted.y).abs() > 1e-7 + || (moved.z - wanted.z).abs() > 1e-7; + let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7; + self.position = Vec3::new( + self.position.x + moved.x, + self.position.y + moved.y, + self.position.z + moved.z, + ); + self.velocity = if horizontal { + Vec3::new(self.velocity.x, wanted.y, self.velocity.z) + } else { + wanted + }; + blocked } /// Position and motion (the dispenser parity fixture). pub(super) fn motion(&self) -> (Vec3, Vec3) { @@ -191,46 +236,14 @@ impl Game<'_> { r.velocity = p.velocity; } } else { - // Not shot at an angle: accelerate (unless blocked sideways) and rise. let mut scene = SceneCollisions { world: &self.world, binding: self.collision, subject: None, }; - let h = 0.125; - let bounds = Aabb::new( - Vec3::new(r.position.x - h, r.position.y, r.position.z - h), - Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h), - ); - let wanted = if r.angled { - r.velocity - } else { - Vec3::new( - r.velocity.x * 1.15, - r.velocity.y + 0.04, - r.velocity.z * 1.15, - ) - }; - if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) { - let blocked = (moved.x - wanted.x).abs() > 1e-7 - || (moved.y - wanted.y).abs() > 1e-7 - || (moved.z - wanted.z).abs() > 1e-7; - if r.angled && blocked { - // hitTargetOrDeflectSelf: explode on the block. - r.life = r.lifetime; - } - let horizontal = - (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7; - r.position = Vec3::new( - r.position.x + moved.x, - r.position.y + moved.y, - r.position.z + moved.z, - ); - r.velocity = if horizontal { - Vec3::new(r.velocity.x, wanted.y, r.velocity.z) - } else { - wanted - }; + if r.free_flight(&mut scene) && r.angled { + // hitTargetOrDeflectSelf: explode on the block. + r.life = r.lifetime; } } let at = [r.position.x, r.position.y, r.position.z]; @@ -372,24 +385,23 @@ impl Game<'_> { if self.creative || self.spectator() { return; } - let hurt = if let Some(p) = self.player.as_mut() + if let Some(p) = self.player.as_mut() && !p.health.dead_or_dying() && let Some(id) = self.motion.health.damage.id("minecraft:fireworks") { - p.hurt(&self.motion.health, self.world.health_rules, id, amount) - .unwrap_or(false) - } else { - false - }; - if hurt { - self.hurt_armor(amount); + let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount); } } /// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera, /// lit at the rocket's light probe. pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { let orientation = glam::Mat4::from_quat(self.camera_orientation()); - for r in self.rockets.iter().filter(|r| !r.attached) { + // shouldRender: not while attached to an entity (`isAttachedToEntity`). + for r in self + .rockets + .iter() + .filter(|r| !r.attached && r.attached_to.is_none()) + { // FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks; // EntityRenderer.shouldRender: its 0.25 box in the view. if !self.standing_entity_in_view( diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index 22cb3774b..e3f4fd025 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -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::advancements::EntityView; use horizon_client::lan::{entity::States, hub::Hub}; use horizon_client::{ blocks::Fluid, @@ -30,8 +31,8 @@ enum State { pub(super) struct Hook { /// Its entity id, which a server's players track it under. id: u32, - position: Vec3, - previous: Vec3, + pub(super) position: Vec3, + pub(super) previous: Vec3, velocity: Vec3, state: State, life: i32, @@ -77,7 +78,7 @@ impl Game<'_> { let at = [p.position.x, p.position.y, p.position.z]; let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8); if self.hook.is_some() { - let damage = self.retrieve_hook(); + let damage = self.retrieve_hook(stack); self.damage_held(damage); self.audio.play( "minecraft:entity.fishing_bobber.retrieve", @@ -143,7 +144,7 @@ impl Game<'_> { true } /// `FishingHook.retrieve`: the rod damage it costs. - fn retrieve_hook(&mut self) -> u32 { + fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 { let Some(hook) = self.hook.take() else { return 0; }; @@ -154,37 +155,64 @@ impl Game<'_> { let mut damage = 0; if let State::Hooked(id) = hook.state { // pullEntity: 0.1 of the way to the owner. + let d = Vec3::new( + owner.x - hook.position.x, + owner.y - hook.position.y, + owner.z - hook.position.z, + ); + let pulled = |v: Vec3| Vec3::new(v.x + d.x * 0.1, v.y + d.y * 0.1, v.z + d.z * 0.1); + let mut hooked_item = false; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - let d = Vec3::new( - owner.x - hook.position.x, - owner.y - hook.position.y, - owner.z - hook.position.z, - ); - m.velocity = Vec3::new( - m.velocity.x + d.x * 0.1, - m.velocity.y + d.y * 0.1, - m.velocity.z + d.z * 0.1, - ); + m.velocity = pulled(m.velocity); + } else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) { + item.velocity = pulled(item.velocity); + hooked_item = true; } - damage = 5; + // CriteriaTriggers.FISHING_ROD_HOOKED with no items. + let hooked = self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| self.mob_entity(m)); + if let Some(hooked) = hooked { + self.trigger_fishing_rod_hooked(rod, &hooked, &[]); + } + // `hookedIn instanceof ItemEntity ? 3 : 5`. + damage = if hooked_item { 3 } else { 5 }; } else if hook.nibble > 0 { let biome = self.biome_name_at(hook.position); let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources) else { return damage; }; + // `withLuck(this.luck + owner.getLuck())`: the rod's Luck of the Sea + // plus the owner's luck attribute (the Luck and Bad Luck effects). + let owner_luck = self + .player + .as_mut() + .and_then(|p| p.attribute("minecraft:luck").ok()) + .unwrap_or(0.0) as f32; let mut random = self.world.random.clone(); let items = loot.roll( "minecraft:gameplay/fishing", &mut random, - hook.luck as f32, + hook.luck as f32 + owner_luck, biome.as_deref(), Some(hook.open_water), ); self.world.random = random; - // FishingHook.retrieve: fish_caught for a catch. - self.stats.custom("fish_caught", 1); + let mut caught = Vec::new(); for stack in items { + // FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes. + if self + .res + .items() + .item_tags + .get("minecraft:fishes") + .is_some_and(|t| t.contains(stack.item.as_str())) + { + self.stats.custom("fish_caught", 1); + } let mut item = ItemStack { item: Arc::from(stack.item.as_str()), count: stack.count.max(1) as u32, @@ -205,6 +233,7 @@ impl Game<'_> { if let Some(potion) = &stack.potion { item = item.with_potion(potion); } + caught.push(item.clone()); let (dx, dy, dz) = ( owner.x - hook.position.x, owner.y - hook.position.y, @@ -224,6 +253,15 @@ impl Game<'_> { let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1; self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp); } + // CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity. + let bobber = EntityView { + kind: "minecraft:fishing_bobber".into(), + position: [hook.position.x, hook.position.y, hook.position.z], + on_ground: hook.on_ground, + fishing_open_water: Some(hook.open_water), + ..Default::default() + }; + self.trigger_fishing_rod_hooked(rod, &bobber, &caught); damage = 1; } if hook.on_ground { @@ -416,6 +454,8 @@ impl Game<'_> { let options = ParticleOptions::simple(kind); self.particles .send_particles(&options, at, spread, speed, count); + // The players a server serves are sent them too. + self.lan_send_particles(&options, at, spread, speed, count); } /// `FishingHook.tick`. pub(super) fn tick_fishing(&mut self) { @@ -457,35 +497,47 @@ impl Game<'_> { self.hook = Some(h); return; } - // checkCollision: hook the first living mob it touches. + // checkCollision: hook the first living mob it touches, or (via + // `canHitEntity`) a dropped item. let bb = bounds(h.position); let (lo, hi) = (bb.min_position(), bb.max_position()); - if let Some(m) = self.mobs.iter().find(|m| { - let mb = m.bounds(); - let (a, z) = (mb.min_position(), mb.max_position()); - m.alive() - && a.x < hi.x - && lo.x < z.x - && a.y < hi.y - && lo.y < z.y - && a.z < hi.z - && lo.z < z.z - }) { - h.state = State::Hooked(m.id); + let touches = |b: Aabb| { + let (a, z) = (b.min_position(), b.max_position()); + a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z + }; + let hit = self + .mobs + .iter() + .find(|m| m.alive() && touches(m.bounds())) + .map(|m| m.id) + .or_else(|| { + self.dropped + .iter() + .find(|i| touches(i.bounds())) + .map(|i| i.id) + }); + if let Some(id) = hit { + h.state = State::Hooked(id); h.velocity = Vec3::ZERO; self.hook = Some(h); return; } } State::Hooked(id) => { - match self.mobs.iter().find(|m| m.id == id && m.alive()) { - Some(m) => { - h.position = Vec3::new( - m.position.x, - m.position.y + f64::from(m.height()) * 0.8, - m.position.z, - ) - } + // updateHookedEntity: ride the hooked mob or item at 0.8 of its height. + let hooked = self + .mobs + .iter() + .find(|m| m.id == id && m.alive()) + .map(|m| (m.position, f64::from(m.height()))) + .or_else(|| { + self.dropped + .iter() + .find(|i| i.id == id) + .map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH)) + }); + match hooked { + Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z), None => h.state = State::Flying, } self.hook = Some(h); @@ -614,6 +666,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light: packed, + + glint_uv: [0.0, 0.0], }); } } @@ -741,8 +795,8 @@ impl Game<'_> { pub(super) struct RemoteHook { id: u32, owner: u32, - position: Vec3, - previous: Vec3, + pub(super) position: Vec3, + pub(super) previous: Vec3, } impl Hook { diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 95e2e1e6e..364f89189 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -114,7 +114,7 @@ impl Game<'_> { living: Some(HurtBob { hurt_time: p.health.hurt_animation_time as f32 - partial, hurt_duration: hurt.hurt_duration, - hurt_dir: 0.0, + hurt_dir: p.health.hurt_dir, death_time: self.death_time().map(|t| t as f32 + partial), }), player: Some(( diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index d08c5f283..f4721ae26 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -311,8 +311,9 @@ impl Game<'_> { let sprites = &self.gui.atlas.sprites; let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new(); let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new(); - // ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%. - let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance); + // ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale + // of 1. + let range = 16.0 * 64.0 * self.view_scale(); for f in &self.item_frames { let entity_start = out.len(); let map = f.item.as_ref().and_then(super::maps::map_id); @@ -377,6 +378,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; @@ -456,7 +459,7 @@ impl Game<'_> { } for (id, m, light) in maps { let entity_start = out.len(); - self.map_vertices(id, m, light, out); + self.map_vertices(id, true, m, light, out); self.cull_entity_vertices(out, entity_start); } } diff --git a/crates/horizon-client/src/game/glowing.rs b/crates/horizon-client/src/game/glowing.rs new file mode 100644 index 000000000..8c78dbd77 --- /dev/null +++ b/crates/horizon-client/src/game/glowing.rs @@ -0,0 +1,53 @@ +//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities +//! appear glowing, in which team colour, and their silhouettes for the renderer's entity +//! outline (`OutlineBufferSource`). +use super::{Game, Vertex}; +use horizon_client::{mobs::Mob, scoreboard as sb}; + +/// `Entity.getTeamColor`: the team's colour, else white. +pub(super) fn team_color(team: Option<&sb::Team>) -> u32 { + team.and_then(|t| t.color.as_deref()) + .and_then(sb::team_color_rgb) + .unwrap_or(0xFF_FFFF) +} + +impl Game<'_> { + /// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile): + /// its `Glowing` tag, or shared flag 6 as a server sent it. + pub(super) fn entity_glowing(&self, id: u32) -> bool { + self.remote_glowing(id).unwrap_or_else(|| { + self.persist_raw(id) + .and_then(|t| horizon_client::save::number(t.get("Glowing"))) + .is_some_and(|b| b != 0) + }) + } + + /// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline + /// colour (`getTeamColor`; another player's team goes by name). + pub(super) fn mob_outline(&self, m: &Mob) -> Option { + if !m.is_currently_glowing() { + return None; + } + let team = if &*m.kind.id == "minecraft:player" { + let names = self.remote_player_names(); + let name = names.into_iter().find(|(id, _)| *id == m.id); + name.and_then(|(_, name)| self.score.board.players_team(&name)) + } else { + self.mob_team(m.id) + }; + Some(team_color(team)) + } + + /// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures' + /// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw). + pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) { + let color = horizon_client::mobs::render::abgr(rgb); + self.glowing_vertices + .borrow_mut() + .extend(vertices.iter().map(|v| Vertex { + color, + normal: [0; 4], + ..*v + })); + } +} diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index d91d3b979..60869952f 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -3,7 +3,7 @@ //! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing //! (`LivingEntity.SwingState`) and the first-person equip heights //! (`FirstPersonHandsAndItems`) with the item swap ticker they read. -use super::{Game, Mining, Screen}; +use super::{Game, Mining, Screen, minecarts::CartKind}; use horizon_client::{ Result, attack_strength::HeldStack, @@ -289,6 +289,64 @@ impl Game<'_> { // Only alive mobs are hit, so a picked living entity is alive. Some(true) } + /// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an + /// entity that is a `MenuProvider` (a chest or hopper minecart, a chest + /// boat), or a block whose `showAsInteractableInSpectatorMode` holds. + pub(super) fn spectator_crosshair(&self) -> bool { + let cart = self.cart_hit().map(|(i, d)| { + let kind = &self.carts[i].kind; + (d, matches!(kind, CartKind::Chest | CartKind::Hopper)) + }); + let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest)); + let others = [ + self.mob_hit().map(|h| h.1), + self.armor_stand_hit().map(|h| h.1), + self.cushion_hit().map(|h| h.1), + self.painting_hit().map(|h| h.1), + self.frame_hit().map(|h| h.1), + self.dragon_hit().map(|h| h.1), + ]; + let nearest = cart + .into_iter() + .chain(boat) + .chain(others.into_iter().flatten().map(|d| (d, false))) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((distance, menu)) = nearest + && self.before_block(distance) + { + return menu; + } + let Some(hit) = self.pick() else { + return false; + }; + let name = &*self + .res + .facts() + .get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2])) + .name; + let name = name.trim_start_matches("minecraft:"); + match name { + // BlockBehaviour.getMenuProvider and `instanceof Portal`. + "crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil" + | "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table" + | "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper" + | "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal" + | "end_portal" | "end_gateway" => true, + // ChestBlock.getMenuProvider: none while a redstone conductor + // sits on top of either half (a sitting cat aside). + "chest" | "trapped_chest" => { + let key = (hit.pos[0], hit.pos[1], hit.pos[2]); + let blocked = |p: (i32, i32, i32)| { + let above = self.world.block(p.0, p.1 + 1, p.2); + self.res.behaviours().facts(above).flags + & horizon_world::level::data::state_flags::CONDUCTOR + != 0 + }; + !blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o)) + } + _ => name.ends_with("shulker_box"), + } + } /// `Attributes.ATTACK_SPEED` with the main-hand item's modifier. pub(super) fn main_hand_attack_speed(&self) -> f64 { horizon_client::attack_strength::BASE_ATTACK_SPEED diff --git a/crates/horizon-client/src/game/initial_spawn.rs b/crates/horizon-client/src/game/initial_spawn.rs index 3f7a071bb..c1149b26a 100644 --- a/crates/horizon-client/src/game/initial_spawn.rs +++ b/crates/horizon-client/src/game/initial_spawn.rs @@ -20,7 +20,8 @@ use horizon_core::{ }; use horizon_world::{ block_collision::BlockCollisionRegistry, collision_context::CollisionContext, - heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates, + heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId, + worldgen::terrain::TerrainBlockStates, }; /// The loaded world as the spawn searches query it. @@ -121,33 +122,102 @@ impl Game<'_> { } Ok(Some([ox * 16 + 8, height, oz * 16 + 8])) } + /// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place + /// in the spawn chunk, a chest with the spawn bonus loot and a torch on + /// each side that can hold one, on the first free column of a shuffled + /// walk over the chunk. Skipped while the chunk is not loaded. + pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> { + use horizon_client::containers::BlockEntity; + let (cx, cz) = (origin[0] >> 4, origin[2] >> 4); + let registry = &self.pack.assets.registry; + let (Some(chest), Some(torch)) = ( + registry.default_state("minecraft:chest"), + registry.default_state("minecraft:torch"), + ) else { + return Ok(()); + }; + if !self.streaming.is_lit(cx, cz) { + return Ok(()); + } + // `level.getRandom()`. Util.toShuffledList of the chunk's x and z. + let random = &mut self.world.random; + let mut shuffled = |min: i32| -> [i32; 16] { + let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32); + for end in (2..=16usize).rev() { + let other = random.next_int_bound(end as i32).unwrap_or(0) as usize; + values.swap(end - 1, other); + } + values + }; + let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16)); + for x in xs { + for z in zs { + let y = self + .spawn_columns() + .height(HeightmapType::MotionBlockingNoLeaves, x, z); + if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) { + continue; + } + self.set_block_clients([x, y, z], chest)?; + let seed = self.world.random.next_long(); + self.world + .block_entities + .insert((x, y, z), BlockEntity::Chest(Vec::new())); + self.load_block_entity_nbt( + [x, y, z], + &format!( + "{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}" + ), + ); + for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] { + let at = [x + dx, y, z + dz]; + let survives = self.with_level(|b, level, ticks| { + b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch) + }); + if survives { + self.set_block_clients(at, torch)?; + } + } + return Ok(()); + } + } + Ok(()) + } + /// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates. + fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> { + self.with_level(|b, level, ticks| { + b.set_block( + level, + ticks, + BlockPos::new(pos[0], pos[1], pos[2]), + state, + UPDATE_CLIENTS, + ); + }); + self.process_level_events() + } + /// The block at `pos` has no collision shape. + fn collision_empty(&self, pos: [i32; 3]) -> bool { + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + self.collision + .collision_shape_with_context( + &mut scene, + self.world.block(pos[0], pos[1], pos[2]), + BlockPos::new(pos[0], pos[1], pos[2]), + &CollisionContext::empty(false), + ) + .is_ok_and(|shape| shape.is_empty()) + } /// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a /// player who has no position yet. None while a candidate's chunk loads. pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result> { let mut finder = match self.player_spawn { Some(finder) => finder, - None if self.level.game_mode == 2 => { - // Adventure mode: the suggestion itself. - let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0); - finder.next_candidate(); - finder - } - None => { - let mut radius = self.ui.rules.int("respawn_radius").max(0); - let to_border = self - .border - .distance(f64::from(suggestion[0]), f64::from(suggestion[2])) - .floor() as i32; - if to_border < radius { - radius = to_border; - } - if to_border <= 1 { - radius = 1; - } - PlayerSpawnFinder::new(suggestion, radius, |n| { - entity_random().next_int_bound(n).unwrap_or(0) - }) - } + None => self.spawn_finder(suggestion), }; let found = loop { let Some(candidate) = finder.next_candidate() else { @@ -176,6 +246,58 @@ impl Game<'_> { self.player_spawn = found.is_none().then_some(finder); Ok(found) } + /// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut + /// by the world border; the suggestion itself in adventure mode. + fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder { + if self.level.game_mode == 2 { + let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0); + finder.next_candidate(); + return finder; + } + let mut radius = self.ui.rules.int("respawn_radius").max(0); + let to_border = self + .border + .distance(f64::from(suggestion[0]), f64::from(suggestion[2])) + .floor() as i32; + if to_border < radius { + radius = to_border; + } + if to_border <= 1 { + radius = 1; + } + PlayerSpawnFinder::new(suggestion, radius, |n| { + entity_random().next_int_bound(n).unwrap_or(0) + }) + } + /// ServerPlayer.adjustSpawnLocation for a server's new player: where + /// PlayerSpawnFinder.findSpawn puts their feet around the world spawn + /// (`spawn`, the feet there), so that players new to a world don't all + /// stand in one spot. Vanilla waits for each candidate's chunk; a server + /// tick can't, so a candidate in a chunk not loaded yet is passed over, + /// and `spawn` stays when the search never reaches a loaded column. + pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] { + let suggestion = spawn.map(|v| v.floor() as i32); + let mut finder = self.spawn_finder(suggestion); + while let Some(candidate) = finder.next_candidate() { + let (x, z) = match candidate { + SpawnCandidate::Column(x, z) => (x, z), + SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]), + }; + if !self.streaming.is_lit(x >> 4, z >> 4) { + continue; + } + let feet = match candidate { + SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z) + .filter(|&pos| self.player_fits(pos)) + .map(bottom_center), + SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)), + }; + if let Some(feet) = feet { + return [feet.x, feet.y, feet.z]; + } + } + spawn + } /// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the /// block's bottom centre touches no block or liquid. fn player_fits(&self, pos: [i32; 3]) -> bool { diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index dd1c04e23..df40ff377 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -52,6 +52,8 @@ struct Flags { /// server follows while it runs (IntegratedServer.tickServer). render_distance: AtomicI32, simulation_distance: AtomicI32, + /// `MinecraftServer.isCurrentlySaving`: the server is autosaving. + saving: AtomicBool, } /// Work the window hands its server thread (`MinecraftServer.execute`). @@ -69,6 +71,10 @@ impl Integrated { pub fn smoothed_tick_ms(&self) -> f32 { f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed)) } + /// `MinecraftServer.isCurrentlySaving`. + pub fn is_saving(&self) -> bool { + self.flags.saving.load(Ordering::Relaxed) + } /// `IntegratedServer.tickServer` skips the world while the game is paused. pub fn set_paused(&self, paused: bool) { self.flags.paused.store(paused, Ordering::Relaxed); @@ -148,6 +154,12 @@ pub(super) struct Serving { /// is out of its place: applied after the tick. pending: Option, } +impl Serving { + /// The window reads this as `MinecraftServer.isCurrentlySaving`. + pub(super) fn set_saving(&self, saving: bool) { + self.flags.saving.store(saving, Ordering::Relaxed); + } +} enum Publish { Open(TcpListener, LanSettings), Close, @@ -408,6 +420,8 @@ impl Game<'_> { s.flags.published.store(0, Ordering::Relaxed); } if let Some(mut host) = self.lan.take() { + // PlayerList.removeAll: the owner and every guest leave the game. + host.award_leave_game_to_all(); for g in &host.hub.guests { self.save_guest(&host, g); } diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index b6b7e2fb7..efb83e5db 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -129,6 +129,7 @@ pub(super) struct Host<'a> { banners: &'a horizon_client::banner::Banners, sprites: &'a HashMap, skins: &'a RefCell, + profiles: &'a RefCell, } impl SpecialHost for Host<'_> { fn banner_texture(&self, pattern: &str) -> Option { @@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> { fn player_skin(&self, profile: &Value) -> Option { // SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation // accepts a texture property without a signature, unlike the tab list. - let url = horizon_client::skins::profile_skin_url(profile)?; - let location = - horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?); - let _ = self - .skins - .borrow_mut() - .state(horizon_client::skins::Kind::Skin, &url); - self.sprites.contains_key(&location).then_some(location) + // A `Dynamic` profile (a name or id only) resolves through the + // services (`PlayerSkinRenderCache.getOrDefault`). + Some(horizon_client::skins::head_skin( + profile, + &mut self.profiles.borrow_mut(), + &mut self.skins.borrow_mut(), + |location| self.sprites.contains_key(location), + )) } } @@ -194,6 +195,7 @@ impl Game<'_> { banners: &self.res.stations().banners, sprites: &self.gui.atlas.sprites, skins: &self.skin_downloads, + profiles: &self.skin_profiles, } } /// The client level as item models read it: worked out once a frame (a @@ -296,8 +298,32 @@ impl Game<'_> { x, y, self.started.elapsed().as_secs_f32(), + self.settings.file.double("glintSpeed") as f32, + self.settings.file.double("glintStrength") as f32, ); } + /// The `GLINT_TEXTURING` inputs the atlas and options provide. + pub(super) fn glint(&self) -> Option { + self.gui + .atlas + .sprites + .get("misc/enchanted_glint_item") + .copied() + .map(|_| item_render::Glint { + speed: self.settings.file.double("glintSpeed") as f32, + alpha: self.settings.file.double("glintStrength") as f32, + millis: self.started.elapsed().as_secs_f32() * 1000.0, + scale: 0.5, + }) + } + /// The glint scroll phase shared by world items and armour + /// (`item_render::Glint::scroll_ticks`). + pub(super) fn glint_ticks(&self) -> f32 { + item_render::Glint::scroll_ticks( + self.started.elapsed().as_secs_f32() * 1000.0, + self.settings.file.double("glintSpeed") as f32, + ) + } /// Submit a resolved item under `pose` (camera-relative) at `light` /// (packed lightmap coordinates). pub(super) fn submit_item_world( @@ -311,6 +337,7 @@ impl Game<'_> { out, light, lights: Some(diffuse_lights(self.dim == Dim::Nether)), + glint: self.glint(), }; state.submit( &self.pack.item_renderer, @@ -332,6 +359,7 @@ impl Game<'_> { out, light, lights: None, + glint: self.glint(), }; state.submit( &self.pack.item_renderer, @@ -405,7 +433,12 @@ impl Game<'_> { ) { self.submit_item_world(&state, copy, vertex_light(packed), out); } - self.cull_entity_vertices(out, entity_start); + if self.entity_glowing(item.id) { + let rgb = super::glowing::team_color(self.mob_team(item.id)); + self.mark_outline(&out[entity_start..], rgb); + } else { + self.cull_entity_vertices(out, entity_start); + } } } } diff --git a/crates/horizon-client/src/game/jukebox.rs b/crates/horizon-client/src/game/jukebox.rs index c3ed75ebb..87f91895e 100644 --- a/crates/horizon-client/src/game/jukebox.rs +++ b/crates/horizon-client/src/game/jukebox.rs @@ -79,6 +79,7 @@ impl Game<'_> { if !self.creative { self.consume_held(); } + self.stats.custom("play_record", 1); self.world.block_entities.insert( (pos[0], pos[1], pos[2]), BlockEntity::Jukebox(Jukebox { @@ -90,6 +91,8 @@ impl Game<'_> { self.set_has_record(pos, true); self.start_song(pos, &song); self.jukebox_changed(pos); + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, stack); true } /// Level event 1010: the song from the jukebox, and the "Now Playing" overlay. diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 5292cd31b..506c6c419 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -11,7 +11,7 @@ use horizon_client::{ chunks::ChunkEncoder, data::SyncData, entity::{EntityState, States}, - hub::{HostEvent, HostState, Hub, HubConfig, TickCtx}, + hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx}, playerdata, }, mobs::{Mob, Who, leash::Holder}, @@ -21,14 +21,28 @@ use horizon_core::geometry::Vec3; use horizon_protocol::{ crypto::offline_uuid, entity_data::{Value, serializer}, + packets::play::{self, Clientbound as C}, }; use std::{ collections::HashMap, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex}, }; /// The registries and tags are the same for every world: load them once. -static SYNC: OnceLock, String>> = OnceLock::new(); +static SYNC: Mutex, String>>> = Mutex::new(None); + +/// The registries and tags a server sends, loaded from `root` the first time. +pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result, String> { + let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner()); + sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new)) + .clone() +} + +/// Let go of what `sync_data` loaded: for a preload no world used +/// (game/world_loading.rs). +pub(super) fn forget_sync_data() { + *SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None; +} /// The settings of the open world (WorldOptionsScreen's multiplayer section). #[derive(Clone, Copy, Debug)] @@ -78,6 +92,33 @@ pub(super) struct LanHost { states: lan::entity::States, } +impl LanHost { + /// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the + /// player is saved. Once per guest, however often they are saved. + pub(super) fn award_leave_game(&mut self, conn: u32) { + if let Some(play) = self.play.get_mut(&conn) + && !play.left_game + { + play.left_game = true; + play.stats.custom("leave_game", 1); + } + } + + /// `PlayerList.removeAll`: every player who joined leaves with the world. + pub(super) fn award_leave_game_to_all(&mut self) { + let conns: Vec = self + .hub + .guests + .iter() + .filter(|g| g.placed) + .map(|g| g.conn) + .collect(); + for conn in conns { + self.award_leave_game(conn); + } + } +} + /// The player who owns an integrated server's world: a guest whose state is /// the world's own player (`players/data/.dat`), as in /// single player. @@ -148,12 +189,22 @@ fn mob_meta( m: &Mob, hub: &Hub, player_entity: Option, + game_time: i64, meta: &mut Vec<(u8, i32, Value)>, defaults: &mut Vec<(u8, i32, Value)>, ) { let kind = m.kind.id.as_str(); - if m.fire_ticks > 0 { - meta.push((0, serializer::BYTE, Value::Byte(1))); + // Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40 + // (LivingEntity.updateGlowingStatus). + let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6; + if shared != 0 { + meta.push((0, serializer::BYTE, Value::Byte(shared))); + } + // Entity.DATA_POSE: the pose the species keeps in its own fields (a + // pose back at standing goes out by the entity's own default). + let pose = horizon_client::mobs::sync::pose_id(m); + if pose != 0 { + meta.push((6, serializer::POSE, Value::Id(i32::from(pose)))); } if let Some(name) = &m.custom_name { meta.push(( @@ -178,6 +229,17 @@ fn mob_meta( meta.push((9, serializer::FLOAT, Value::Float(health))); } defaults.push((9, serializer::FLOAT, Value::Float(1.0))); + // LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the + // swirls of its effects (the ones a mob has are visible, not ambient). + meta.extend( + hub.effect_meta( + (m.extra.effects.iter()) + .filter(|e| !m.extra.hidden_effects.contains(&e.0)) + .map(|e| (e.0, false)), + ), + ); + defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new()))); + defaults.push((11, serializer::BOOLEAN, Value::Bool(false))); // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what // draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised // weapon). @@ -223,6 +285,27 @@ fn mob_meta( } defaults.push((16, serializer::BYTE, Value::Byte(0))); } + // SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default). + "minecraft:snow_golem" => { + if e.sheared { + meta.push((16, serializer::BYTE, Value::Byte(0))); + } + defaults.push((16, serializer::BYTE, Value::Byte(16))); + } + // MushroomCow.DATA_TYPE: 0 red, 1 brown. + "minecraft:mooshroom" => { + if e.variant == 1 { + meta.push((17, serializer::INT, Value::Int(1))); + } + defaults.push((17, serializer::INT, Value::Int(0))); + } + // Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge). + "minecraft:piglin" => { + if e.scratch[0] > 0 { + meta.push((19, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((19, serializer::BOOLEAN, Value::Bool(false))); + } // Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared. "minecraft:sheep" => { let value = (e.color & 15) | if e.sheared { 16 } else { 0 }; @@ -337,6 +420,14 @@ fn mob_meta( } defaults.push((17, serializer::BOOLEAN, Value::Bool(false))); } + // PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear + // rears up; the client animates it. + "minecraft:polar_bear" => { + if e.flag { + meta.push((18, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((18, serializer::BOOLEAN, Value::Bool(false))); + } // SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose. "minecraft:evoker" | "minecraft:illusioner" => { if e.variant2 != 0 { @@ -344,6 +435,41 @@ fn mob_meta( } defaults.push((17, serializer::BYTE, Value::Byte(0))); } + // Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace. + "minecraft:warden" => { + let anger = e.warden.as_ref().map_or(0, |w| w.client_anger); + if anger != 0 { + meta.push((16, serializer::INT, Value::Int(anger))); + } + defaults.push((16, serializer::INT, Value::Int(0))); + } + // Sniffer.DATA_STATE (after AgeableMob's two). + "minecraft:sniffer" => { + if e.variant != 0 { + meta.push(( + 18, + serializer::SNIFFER_STATE, + Value::Id(i32::from(e.variant)), + )); + } + defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0))); + } + // Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16 + // for none). + "minecraft:shulker" => { + if e.variant3 != 0 { + meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3)))); + } + defaults.push((16, serializer::DIRECTION, Value::Id(0))); + if e.counter != 0 { + meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8))); + } + defaults.push((17, serializer::BYTE, Value::Byte(0))); + if e.color != 16 { + meta.push((18, serializer::BYTE, Value::Byte(e.color as i8))); + } + defaults.push((18, serializer::BYTE, Value::Byte(16))); + } // Enderman.DATA_CARRY_STATE. "minecraft:enderman" => { if let Some(state) = e.carried { @@ -356,6 +482,38 @@ fn mob_meta( } _ => {} } + // Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags). + if horizon_client::mobs::camel::is_camel(m.species()) { + if e.flag2 { + meta.push((19, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((19, serializer::BOOLEAN, Value::Bool(false))); + let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time); + if tick != 0 { + meta.push((20, serializer::LONG, Value::Long(tick))); + } + defaults.push((20, serializer::LONG, Value::Long(0))); + } +} + +/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full +/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after +/// AgeableMob's two and DATA_BOOST_TIME). +fn mob_freeze_meta( + m: &Mob, + meta: &mut Vec<(u8, i32, Value)>, + defaults: &mut Vec<(u8, i32, Value)>, +) { + if m.ticks_frozen > 0 { + meta.push((7, serializer::INT, Value::Int(m.ticks_frozen))); + } + defaults.push((7, serializer::INT, Value::Int(0))); + if m.kind.id == "minecraft:strider" { + if m.extra.flag { + meta.push((19, serializer::BOOLEAN, Value::Bool(true))); + } + defaults.push((19, serializer::BOOLEAN, Value::Bool(false))); + } } /// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand, @@ -366,13 +524,16 @@ fn mob_equipment( out: &mut Vec<(u8, Option)>, ) { let e = &m.extra; + // A horse's armour, a llama's carpet, a harness: Horizon keeps the body + // slot's item (6) in `head`. + let body = horizon_client::mobs::drops::head_is_body(m.species()); let slots = [ (0u8, &e.main_hand), (1, &e.off_hand), (2, &e.feet), (3, &e.legs), (4, &e.chest), - (5, &e.head), + (if body { 6 } else { 5 }, &e.head), ]; for (slot, item) in slots { if let Some(item) = item { @@ -562,10 +723,7 @@ impl Game<'_> { config: HubConfig, host: Option, ) -> Result<()> { - let root = self.options.resources.clone(); - let sync = SYNC - .get_or_init(|| SyncData::load(&root).map(Arc::new)) - .clone()?; + let sync = sync_data(&self.options.resources)?; let biome_names: Vec = self .res .biomes() @@ -654,6 +812,7 @@ impl Game<'_> { let Some(mut host) = self.lan.take() else { return; }; + host.award_leave_game_to_all(); for g in &host.hub.guests { self.save_guest(&host, g); } @@ -665,6 +824,7 @@ impl Game<'_> { self.audio.capture = None; self.lan_events.clear(); self.lan_critical_hits.clear(); + self.lan_cushion_breaks.clear(); self.streaming.extra.clear(); if announce { self.system_message(Text::translate( @@ -835,6 +995,16 @@ impl Game<'_> { living_flags: living_flags(self.using) | if p.auto_spin_attack_ticks > 0 { 4 } else { 0 }, bed: self.sleeping, + body: BodyFlags { + gliding: p.fall_flying, + swimming: p.swimming, + crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming, + crouching: p.pose == horizon_client::pose::Pose::Crouching, + spin_attack: p.auto_spin_attack_ticks > 0, + on_fire: p.fire_ticks > 0, + invisible: self.potions.effects.get("invisibility").is_some(), + glowing: self.potions.effects.get("glowing").is_some(), + }, }) } @@ -850,7 +1020,7 @@ impl Game<'_> { out.clear(); for m in self.mobs.iter().filter(|m| !m.removed) { // The lead's holder: the player the mob sees (a guest, else the - // host) or another mob. A fence knot isn't an entity here. + // host), another mob or a fence knot (game/lan_entities.rs). let player_entity = mob_players .get(&m.id) .and_then(|conn| hub.guest(*conn)) @@ -859,7 +1029,7 @@ impl Game<'_> { let leash = m.extra.leash.and_then(|h| match h { Holder::Player => player_entity, Holder::Mob(id) => Some(id as i32), - Holder::Knot(_) => None, + Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32), }); let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str()); e.position = [m.position.x, m.position.y, m.position.z]; @@ -867,7 +1037,15 @@ impl Game<'_> { e.on_ground = m.on_ground; e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z]; e.needs_sync = m.needs_sync; - mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults); + mob_meta( + m, + hub, + player_entity, + self.world.game_time, + &mut e.meta, + &mut e.defaults, + ); + mob_freeze_meta(m, &mut e.meta, &mut e.defaults); mob_equipment(m, hub, &mut e.equipment); self.mob_attributes(m, &mut e.attributes); e.leash = leash; @@ -878,6 +1056,10 @@ impl Game<'_> { e.position = [d.position.x, d.position.y, d.position.z]; e.on_ground = d.on_ground; e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z]; + // Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag). + if self.entity_glowing(d.id) { + e.meta.push((0, serializer::BYTE, Value::Byte(0x40))); + } e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack))); } for o in &self.orbs { @@ -944,6 +1126,9 @@ impl Game<'_> { for m in &mut self.mobs { m.needs_sync = false; } + for p in &mut self.creatures.projectiles { + p.needs_sync = false; + } // `runBlockEvents`' broadcast precedes the tick's block changes // (`ChunkHolder.broadcastChanges` in `tick_level` below): the // guest re-runs a piston's `triggerEvent` while its level still @@ -952,6 +1137,16 @@ impl Game<'_> { host.hub.block_event(pos, b0, b1, state); } let entities = states.as_slice(); + // An entity that left the level since the last tick (a snowball + // that hit) is dropped by the tracker below: its event goes out + // first, as `Level.broadcastEntityEvent` precedes the discard. + let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events) + .into_iter() + .partition(|(id, ..)| !entities.iter().any(|e| e.id == *id)); + self.lan_entity_events = later; + for (id, event, at) in gone { + host.hub.entity_event(id, event, at); + } let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { let clock_rate = self.network_clock_rate(); @@ -974,6 +1169,8 @@ impl Game<'_> { self.ui.rules.bool("player_movement_check"), self.ui.rules.bool("elytra_movement_check"), ); + host.hub + .set_immediate_respawn(self.ui.rules.bool("immediate_respawn")); // A server with several levels runs a pass for each // (`tick_server`): the network is read once, before the first. let pass = self.level_pass; @@ -1044,6 +1241,9 @@ impl Game<'_> { } } self.lan_crafting_left(&mut host, &mut guest); + if guest.placed { + host.award_leave_game(guest.conn); + } self.save_guest(&host, &guest); host.models.remove(&guest.conn); host.play.remove(&guest.conn); @@ -1094,8 +1294,11 @@ 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. + // A blast from a guest's attack, and a mace's wind burst. + self.flush_lan_explosions(); let _ = self.flush_wind_bursts(); + // Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes). + self.flush_lan_particles(); } fn lan_guest_joined( @@ -1128,8 +1331,17 @@ impl Game<'_> { g.game_mode = default_mode; } if !known { - g.position = spawn; - g.previous_position = spawn; + // ServerPlayer.adjustSpawnLocation: a player new to the world + // stands where PlayerSpawnFinder puts them, not all on one spot + // (it reads the loaded level, which holds the spawn when it is + // the overworld). + let feet = if self.dim == super::Dim::Overworld { + self.server_player_spawn(spawn) + } else { + spawn + }; + g.position = feet; + g.previous_position = feet; g.health = 20.0; g.food = 20; g.dimension = super::Dim::Overworld.id().to_owned(); @@ -1188,8 +1400,9 @@ impl Game<'_> { horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => { // The acting guest does not predict the egg's teleport // particles, bone meal's growth, or anvil result sounds - // (these levelEvent calls name no excluded player). - let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) { + // (these levelEvent calls name no excluded player), nor + // the digger's own crack and hit sound (2019, 2020). + let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) { None } else { self.lan_acting @@ -1224,8 +1437,9 @@ impl Game<'_> { self.lan_entity_events.push((id, event, at)); } } - /// `ServerPlayer.crit`: this window's emitter, and an animation for the - /// attacker's client and observers once the hub is back in place. + /// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's + /// emitter, and an animation for the attacker's client and observers once + /// the hub is back in place. pub(super) fn critical_hit( &mut self, attacker: Option, @@ -1233,21 +1447,50 @@ impl Game<'_> { feet: [f64; 3], width: f64, height: f64, + magic: bool, ) { use horizon_client::particles::{ParticleOptions, ParticleType}; - self.particles.tracking_emitter( - ParticleOptions::simple(ParticleType::Crit), - feet, - width, - height, - ); + let particle = if magic { + ParticleType::EnchantedHit + } else { + ParticleType::Crit + }; + self.particles + .tracking_emitter(ParticleOptions::simple(particle), feet, width, height); if self.audio.capture.is_some() { - self.lan_critical_hits.push((attacker, id)); + self.lan_critical_hits.push((attacker, id, magic)); } } /// Send the tick's level events, sounds and mob animations. fn lan_flush_effects(&mut self, host: &mut LanHost) { let critical_hits = std::mem::take(&mut self.lan_critical_hits); + for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) { + if let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:block") + { + host.hub.broadcast_near( + at, + 32.0, + &C::LevelParticles(play::LevelParticles { + particle: horizon_protocol::entity_data::Particle { + id, + data: Some(horizon_protocol::entity_data::ParticleData::State( + state.0 as i32, + )), + }, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.25, 0.0625, 0.25], + max_speed: [0.05; 3], + count: 10, + randomization: 0, + }), + None, + ); + } + } for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) { if kind == 2001 { host.broken @@ -1320,13 +1563,13 @@ impl Game<'_> { seen.insert(m.id, (m.hurt_time, dead, swing)); } host.mob_state.insert(self.dim, seen); - for (attacker, target) in critical_hits { + for (attacker, target, magic) in critical_hits { let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| { host.hub.guest(conn).map(|g| g.entity_id) }) else { continue; }; - host.hub.critical_hit(attacker, target); + host.hub.critical_hit(attacker, target, magic); } } @@ -1409,6 +1652,14 @@ impl Game<'_> { .each_ref() .map(|s| s.as_ref().map(|s| s.item.clone())); m.extra.crouching = g.sneaking; + m.extra.avatar_pose = g.pose(); + m.extra.sprinting = g.sprinting; + // LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects. + let visible = || g.effects.list.iter().filter(|e| e.visible); + m.extra.effect_particles = visible() + .filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient)) + .collect(); + m.extra.effect_ambient = visible().all(|e| e.ambient); // Lying in its bed (LivingEntity.getBedOrientation). m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b)); // LivingEntity.updateSwingTime: the arm swing lan_swings started. @@ -1470,7 +1721,12 @@ impl Game<'_> { out, ); self.held_item_geometry(m, camera, alpha, packed, true, out); - if !m.glowing { + // LivingEntity.isCurrentlyGlowing: the guest's Glowing effect. + let guest = host.hub.guest(*conn); + if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) { + let team = guest.and_then(|g| self.score.board.players_team(&g.name)); + self.mark_outline(&out[entity_start..], super::glowing::team_color(team)); + } else { self.cull_entity_vertices(out, entity_start); } } diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 43bb573dc..dde9b590d 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -74,7 +74,12 @@ pub(super) struct GuestPlay { potions: super::potions::BodyPotions, /// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw. pub(super) stats: super::stats::Stats, + /// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the + /// way out, the statistic is awarded once (`LanHost::award_leave_game`). + pub(super) left_game: bool, last_inventory: u64, + /// `ServerPlayer.levitationStartPos` and `levitationStartTime`. + levitation: Option<([f64; 3], u64)>, /// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs). pub(super) portal: horizon_client::nether_portal::PortalState, /// `ServerPlayer.wonGame`: the End poem is showing, the player waits for @@ -158,6 +163,7 @@ pub(super) struct GuestSwap { host_ender: Option<(i32, i32, i32)>, adv_loaded: bool, last_inventory: u64, + levitation: Option<([f64; 3], u64)>, death_cause: Option, /// The guest's own damage record, in place while they act. combat: super::combat::CombatTracker, @@ -165,6 +171,8 @@ pub(super) struct GuestSwap { acting: (Option, Option, Option), /// The packets queued for that guest. packets: Vec, + /// The entity events queued for that guest. + entity_events: Vec, /// The host's count of swings started, kept while the guest acts. swings: u32, /// How many projectiles there were: those added since are the guest's. @@ -265,8 +273,10 @@ impl Game<'_> { } let mut play = host.play.remove(&conn).unwrap_or_default(); if fresh { - // A new body (joined, respawned, changed level): nothing on it yet. + // A new body (joined, respawned, changed level): nothing on it yet, + // its effects' modifiers and armor protection included. play.equipped = None; + play.potions = Default::default(); play.sculk = Default::default(); } let g = &mut host.hub.guests[i]; @@ -382,6 +392,7 @@ impl Game<'_> { 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 levitation = std::mem::replace(&mut self.adv.levitation, play.levitation); let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take()); let acting = ( self.lan_acting.replace(conn), @@ -389,6 +400,7 @@ impl Game<'_> { self.lan_acting_uuid.replace(g.uuid), ); let packets = std::mem::take(&mut self.guest_packets); + let entity_events = std::mem::take(&mut self.guest_entity_events); let swings = self.swings_started; let projectiles = self.creatures.projectiles.len(); let modifiers = std::mem::replace( @@ -402,6 +414,12 @@ impl Game<'_> { self.apply_equipment(); play.equipped = Some(equipped); } + // The player's effects are on the new body before anything it does + // (the movement that brought it, a landing): vanilla keeps the same + // entity through a level change, with its attributes and effects. + if fresh { + self.apply_effect_status(); + } self.update_totem_ready(); Some(GuestSwap { conn, @@ -446,9 +464,11 @@ impl Game<'_> { host_ender, adv_loaded, last_inventory, + levitation, death_cause, acting, packets, + entity_events, swings, projectiles, modifiers, @@ -504,10 +524,12 @@ impl Game<'_> { host_ender, adv_loaded, last_inventory, + levitation, death_cause, combat, acting, packets, + entity_events, swings, projectiles, modifiers, @@ -529,6 +551,17 @@ impl Game<'_> { for packet in std::mem::replace(&mut self.guest_packets, packets) { host.hub.send_to(conn, &packet); } + // ServerPlayer.connection.send: the guest's own entity events. + let entity_id = host.hub.guests[i].entity_id; + for event in std::mem::replace(&mut self.guest_entity_events, entity_events) { + host.hub.send_to( + conn, + &C::EntityEvent(play::EntityEvent { + id: entity_id, + event, + }), + ); + } // ServerItemCooldowns.onCooldownStarted: the guest's own cooldown. for (group, duration) in std::mem::take(&mut self.guest_cooldowns) { host.hub.send_to( @@ -614,6 +647,7 @@ impl Game<'_> { 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); + play.levitation = std::mem::replace(&mut self.adv.levitation, levitation); let stat_events = std::mem::take(&mut play.stats.events); let name = g.name.clone(); self.potions.swap_player(&mut g.effects, &mut play.potions); @@ -657,6 +691,13 @@ impl Game<'_> { g.sprinting = false; } m.sprinting = false; + // ServerPlayer.indicateDamage: the hit's direction to the player. + if let Some(yaw) = m.health.indicated_dir.take() { + g.send(&C::HurtAnimation(play::HurtAnimation { + id: g.entity_id, + yaw, + })); + } let teleported = std::mem::take(&mut m.teleported); let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum(); g.motion = Some(Box::new(m)); @@ -858,13 +899,17 @@ impl Game<'_> { } /// The guest's open container is closing (`AbstractContainerMenu.removed`): - /// it no longer counts among the openers of the lid. + /// it no longer counts among the openers of the lid, and the lid's close + /// sound is played for everyone around (`ChestBlockEntity.onClose`, a + /// `playSound` with no player), the guest included, once the last opener + /// has left. pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) { if let Some(g) = host.hub.guest(conn) && let Some(pos) = g.menu.as_ref().and_then(|m| m.pos) && !g.spectator() + && self.container_openers_changed(pos, false) { - self.container_openers_changed(pos, false); + self.container_edge_sound(pos, false); } } @@ -958,6 +1003,20 @@ impl Game<'_> { // The pause between two blocks is the client's (MultiPlayerGameMode's // destroyDelay); the server digs from the START it receives. play.destroy_delay = 0; + // ServerPlayerGameMode.handleBlockBreakAction: a survival START + // attacks the block at once, so a tap released in the same tick + // still plays a note. The dig is marked begun for tick_mining. + let pos = a.pos; + self.as_guest(host, conn, |g| { + if g.may_build() && !g.creative { + let _ = g.attack_block(pos); + g.mining = Some(Mining { + pos, + progress: 0.0, + ticks: 0, + }); + } + }); } 1 => {} 3 => { @@ -1200,6 +1259,7 @@ impl Game<'_> { } S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name), S::SignUpdate(p) => self.lan_sign_update(host, conn, p), + S::SetCommandBlock(p) => self.lan_set_command_block(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); @@ -1216,6 +1276,12 @@ impl Game<'_> { { self.guest_carried_back(host, gi); } + S::SelectBundleItem(p) => { + if !spectator && let Ok(slot) = usize::try_from(p.slot) { + host.hub + .select_bundle_item(conn, slot, p.index, &mut self.world); + } + } S::ContainerClick(p) => { let rules = ItemRules(self.res.items()); let slots = host.hub.guests[gi] @@ -1518,9 +1584,13 @@ impl Game<'_> { 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) - && strike.stops_sprint() { - self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback()); + self.as_guest(host, conn, |g| { + g.pvp_exhaustion(); + if strike.stops_sprint() { + g.stop_sprint_after_extra_knockback(); + } + }); } return; } @@ -1825,10 +1895,6 @@ impl Game<'_> { }; xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR); (g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total); - // A new body: nothing given to the old one carries over. - if let Some(play) = host.play.get_mut(&conn) { - play.potions = Default::default(); - } let clock_rate = self.network_clock_rate(); let ctx = TickCtx { world: &mut self.world, @@ -1910,21 +1976,8 @@ impl Game<'_> { continue; } let play = host.play.entry(conn).or_default(); - // Player.tick's ItemCooldowns.tick, once a tick for each player - // (the shield an axe disabled, a thrown pearl). - play.shield_cooldown = play.shield_cooldown.saturating_sub(1); + // ServerGamePacketListenerImpl.tick's dropSpamThrottler. play.creative_drops = play.creative_drops.saturating_sub(1); - // ServerItemCooldowns.onCooldownEnded: a finished entry is told to - // the guest (a duration of 0 removes it). - for group in play.cooldowns.tick() { - host.hub.send_to( - conn, - &C::Cooldown(play::Cooldown { - group: group.to_string(), - duration: 0, - }), - ); - } let digging = play.digging; let using = play.using.is_some(); let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100; @@ -1961,6 +2014,24 @@ impl Game<'_> { play.digging = None; } } + // Player.tick's ItemCooldowns.tick, once a tick for each player + // (the shield an axe disabled, a thrown pearl), after + // LivingEntity.tick's use: an entry a finished use starts has 20 + // ticks left when the next tick's packets are handled, as the + // guest's own client counts. + let play = host.play.entry(conn).or_default(); + play.shield_cooldown = play.shield_cooldown.saturating_sub(1); + // ServerItemCooldowns.onCooldownEnded: a finished entry is told to + // the guest (a duration of 0 removes it). + for group in play.cooldowns.tick() { + host.hub.send_to( + conn, + &C::Cooldown(play::Cooldown { + group: group.to_string(), + duration: 0, + }), + ); + } // Player.tick: a different main-hand item resets the attack // strength, and detectEquipmentUpdates gives the guest its modifiers. let g = &host.hub.guests[gi]; @@ -2182,7 +2253,8 @@ impl Game<'_> { } let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height()); let at = g.position; - let sneaking = g.sneaking; + let (sneaking, sleeping) = (g.sneaking, g.sleeping); + let mut soul = None; let events = self.as_guest(host, conn, |game| { let (rules, motion) = (game.world.health_rules, game.motion); let health = &motion.health; @@ -2250,8 +2322,10 @@ impl Game<'_> { // Entity.applyEffectsFromBlocks along those moves: magma, cactus, // lit campfires and grown sweet berry bushes hurt (#1305). w.player.shift_key_down = sneaking; + let _ = w.player.set_sleeping(sleeping); w.player.freeze_immune = freeze_immune; let _ = w.server_block_damage(start); + let _ = w.update_soul_speed_block(); let _ = w.tick_environment_damage(); let _ = w.apply_fire_contact(); let _ = w.server_hazards_tick(in_wall); @@ -2265,6 +2339,8 @@ impl Game<'_> { game.cauldron_extinguish(*pos); } } + // Entity.baseTick: rain puts out a burning player. + game.rain_extinguish_player(); // WitherRoseBlock.entityInside along those moves. for event in &events { if matches!(event, MotionEvent::InsideWitherRose) { @@ -2279,6 +2355,9 @@ impl Game<'_> { // LivingEntity.baseTick: outside the world border, then tickEffects. game.border_damage(); game.tick_mob_effects(); + // Soul Speed's effects (LivingEntity.baseTick's location_changed + // and tick effects); its particle goes to the players around. + soul = game.tick_soul_speed(); let Some(player) = game.player.as_mut() else { return (Vec::new(), None); }; @@ -2300,17 +2379,65 @@ impl Game<'_> { // ServerEntity.sendChanges sends it as it is after this travel. let marked = std::mem::take(&mut player.health.sync_velocity); let velocity = marked.then_some(player.velocity); + // Player.causeFallDamage: a fall of 2 blocks or more counts. + for event in &events { + if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event + { + game.stats.custom("fall_one_cm", *cm); + } + } game.record_combat(&events); + game.wear_armor(&events); (events, velocity) }); let Some((events, marked)) = events else { return; }; + if let Some((at, velocity)) = soul { + super::lan_particles::send_soul_particle(host, at, velocity); + } self.as_guest(host, conn, |game| game.tick_player_sculk_events()); for event in events { match event { MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at), + // LivingEntity.checkFallDamage: the crumbs reach every player. + MotionEvent::Fall(horizon_client::landing::FallEvent::Particles { + state, + position, + count, + speed, + }) => self.landing_dust(Some(host), state, position, count, speed), MotionEvent::Fall(event) => self.landing_block_updates(&event), + // LivingEntity.baseTick's entity event 67, to every client + // around, the guest's own included; this window draws it too. + MotionEvent::Drown => { + if let Some(g) = host.hub.guest(conn) { + host.hub + .entity_event(g.entity_id, super::combat::DROWN_EVENT, at); + } + self.drown_particles(at, [0.0; 3]); + } + // HoneyBlock.maybeDoSlideEffects: the sound reaches the + // others (Player.playSound excludes the player, whose client + // plays it), the entity event 53 everyone around. + MotionEvent::HoneySlide { + sound, particles, .. + } => { + let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position)) + else { + continue; + }; + if sound { + let name = "minecraft:block.honey_block.slide"; + let source = horizon_client::settings::sound_source(name) as i32; + self.audio.play_unshared(name, Some(at), 1.0, 1.0); + host.hub.sound(name, at, 1.0, 1.0, source, Some(conn)); + } + if particles { + self.honey_slide_particles(at); + host.hub.entity_event(id, 53, at); + } + } _ => {} } } @@ -2394,9 +2521,13 @@ impl Game<'_> { } if (next / 10) % 2 == 0 && !creative - && let Some(elytra) = game.armor[1].as_mut() + && let Some(stack) = game.armor[1].clone() { - elytra.damage += 1; + // `ItemStack.hurtAndBreak`: Unbreaking can save the wear. + let loss = game.durability_loss(&stack, 1); + if let Some(elytra) = game.armor[1].as_mut() { + elytra.damage += loss; + } } let ctx = game.player_context(None); game.post_game_event("minecraft:elytra_glide", at, ctx); @@ -2621,8 +2752,7 @@ impl Game<'_> { /// with Mending and gives the rest as points. Everyone near hears it. fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) { let g = &host.hub.guests[gi]; - // Player's standing and crouching boxes: 1.8 and 1.5 high. - let height = if g.sneaking { 1.5 } else { 1.8 }; + let height = f64::from(g.dimensions().height); let (conn, id, at) = (g.conn, g.entity_id, g.position); let unable = g.spectator() || g.dead || g.health <= 0.0; let play = host.play.entry(conn).or_default(); @@ -2659,7 +2789,13 @@ impl Game<'_> { .random .next_int_bound(touching.len() as i32) .unwrap_or(0) as usize; - let orb = self.orbs.remove(touching[pick]); + // A merged orb gives its value once per count and goes at zero. + let orb = self.orbs[touching[pick]].clone(); + if orb.count <= 1 { + self.orbs.remove(touching[pick]); + } else { + self.orbs[touching[pick]].count -= 1; + } play.xp_delay = 2; host.hub.take_item(orb.id as i32, id, 1, at); // ClientPacketListener.handleTakeItemEntity's sound, at the orb. diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index db6235c4d..461eaf5e1 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -127,9 +127,18 @@ impl Game<'_> { } let dropped = std::mem::take(&mut menu.dropped); let equipped = std::mem::take(&mut menu.equipped); + let brewed = std::mem::take(&mut menu.brewed); menu.drops.clear(); menu.crafted.clear(); store_player(menu, &mut host.hub.guests[gi]); + if !brewed.is_empty() { + let conn = host.hub.guests[gi].conn; + self.as_guest(host, conn, |g| { + for stack in &brewed { + g.trigger_brewed_potion(stack); + } + }); + } if !equipped.is_empty() { let conn = host.hub.guests[gi].conn; self.as_guest(host, conn, |g| { @@ -576,16 +585,18 @@ impl Game<'_> { /// A guest left: what their crafting grids and workstation held goes back into their /// inventory before it is saved (dropped where they stood if full). pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) { - let play = host.play.remove(&g.conn); + // The guest's play state stays: their statistics are awarded and saved + // after this (`HostEvent::Left`), which removes it. + let play = host.play.get_mut(&g.conn); let mut grid: Vec = g.craft_grid.iter_mut().filter_map(Option::take).collect(); g.craft_result = None; if let Some(play) = play { - if let Some((_, _, menu, _)) = play.crafting.table { + if let Some((_, _, menu, _)) = play.crafting.table.take() { grid.extend((1..10).filter_map(|s| menu.slot(s).cloned())); } // A workstation's inputs too. - if let Some(station) = &play.station { - grid.extend(super::lan_stations::station_inputs(station)); + if let Some(station) = play.station.take() { + grid.extend(super::lan_stations::station_inputs(&station)); } } if grid.is_empty() { diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 3c5b11988..a1d330928 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -225,6 +225,16 @@ impl Game<'_> { } out.push(e); } + for (p, &id) in &self.knot_ids { + // LeashFenceKnotEntity.getAddEntityPacket: the fence's block. + out.push(state( + id, + "minecraft:leash_knot", + p.map(f64::from), + 0.0, + false, + )); + } for p in &self.paintings { let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false); // Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its @@ -270,6 +280,24 @@ impl Game<'_> { /// Gives the entities Horizon keeps without an id one (`Entity.id`, from /// the same counter as the rest) before they are first tracked. pub(super) fn lan_assign_ids(&mut self) { + // LeashFenceKnotEntity: one per fence post with a lead on it + // (`notifyLeasheeRemoved` discards it once the last lead is gone). + let knots: Vec<[i32; 3]> = self + .mobs + .iter() + .filter(|m| !m.removed) + .filter_map(|m| match m.extra.leash { + Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p), + _ => None, + }) + .collect(); + self.knot_ids.retain(|p, _| knots.contains(p)); + for p in knots { + self.knot_ids.entry(p).or_insert_with(|| { + self.next_entity += 1; + self.next_entity + }); + } let next = &mut self.next_entity; let mut give = |id: &mut u32| { if *id == 0 { @@ -304,6 +332,9 @@ impl Game<'_> { None => 0, }; let at = |p: horizon_core::geometry::Vec3| [p.x, p.y, p.z]; + for f in &self.fangs.list { + out.push(state(f.id, "minecraft:evoker_fangs", f.pos, f.yaw, false)); + } // LightningBolt: each client draws and flashes its own. for b in self.bolts.iter().filter(|b| b.id != 0) { out.push(state(b.id, "minecraft:lightning_bolt", b.at, 0.0, false)); @@ -339,6 +370,7 @@ impl Game<'_> { let mut e = state(p.id, kind, at(p.position), p.yaw, false); e.pitch = p.pitch; e.velocity = at(p.velocity); + e.needs_sync = p.needs_sync; // Projectile.getAddEntityPacket: the owner's id. e.data = match p.guest.and_then(|conn| hub.guest(conn)) { Some(g) => g.entity_id, @@ -354,6 +386,20 @@ impl Game<'_> { a.is_some_and(|a| a.in_ground) || p.stuck, )); } + // ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on + // AbstractArrow's flags. + K::Trident => { + let a = p.arrow.as_deref(); + e.meta.extend(arrow_meta( + false, + a.is_some_and(|a| a.no_physics), + 0, + a.is_some_and(|a| a.in_ground) || p.stuck, + )); + if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) { + e.meta.push((12, serializer::BOOLEAN, Value::Bool(true))); + } + } // WitherSkull.DATA_DANGEROUS. K::WitherSkull { dangerous: true } => { e.meta.push((8, serializer::BOOLEAN, Value::Bool(true))); @@ -452,7 +498,7 @@ impl Game<'_> { } out.push(e); } - // The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS, + // The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS, // DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the // client draws them from these (`tick_remote_clouds`). let breath = self.network_particle( @@ -470,10 +516,14 @@ impl Game<'_> { for b in self.breath_clouds.iter().filter(|b| b.id != 0) { let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false); e.meta.push((8, serializer::FLOAT, Value::Float(b.radius))); - if b.age < 20 { + if b.waiting() { e.meta.push((9, serializer::BOOLEAN, Value::Bool(true))); } - if let Some(p) = &breath { + let particle = match &b.potion { + Some(p) => self.network_particle(&p.particle(), None), + None => breath.clone(), + }; + if let Some(p) = &particle { e.meta .push((10, serializer::PARTICLE, Value::Particle(p.clone()))); } diff --git a/crates/horizon-client/src/game/lan_mobs.rs b/crates/horizon-client/src/game/lan_mobs.rs index 4f53cec60..49eedbe55 100644 --- a/crates/horizon-client/src/game/lan_mobs.rs +++ b/crates/horizon-client/src/game/lan_mobs.rs @@ -5,7 +5,7 @@ //! (hits, pushes, fire, its projectiles) runs with that guest swapped in. use super::{Game, lan::LanHost}; use horizon_client::{ - mobs::{Mob, MobEvent, PlayerView, Who}, + mobs::{Mob, MobEvent, PlayerView, Species, Who}, projectiles::{Hit, Projectile}, }; use horizon_core::geometry::{Aabb, Vec3}; @@ -28,6 +28,22 @@ pub(super) fn player_for( ) -> Option { let targeting = m.ai.target == Some(Who::Player) || m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100; + // A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they + // are in play, whoever is nearer. Not while it targets or was just hurt by + // a player: then `Who::Player` is that attacker, whoever it is, and the + // owner goals ignore it until the wolf lets go. + if !targeting + && matches!(m.species(), Species::Wolf | Species::Cat) + && m.extra.tame + && let Some(owner) = m.extra.owner + { + if host.is_some_and(|v| v.alive && v.uuid == owner) { + return None; + } + if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) { + return Some(*conn); + } + } choose(m.position, targeting, host, guests, bound) } @@ -175,9 +191,9 @@ impl Game<'_> { .iter() .filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator()) .map(|g| { - let [x, y, z] = g.position; - let height = if g.sneaking { 1.5 } else { 1.8 }; - ([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3]) + let b = g.bounds(); + let (lo, hi) = (b.min_position(), b.max_position()); + ([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]) }) .collect() } @@ -319,7 +335,7 @@ impl Game<'_> { uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid), position: g.position, eye: [x, y + g.eye_height(), z], - height: if g.sneaking { 1.5 } else { 1.8 }, + height: g.dimensions().height, creative: g.creative(), spectator: g.spectator(), alive: !g.dead, @@ -336,14 +352,9 @@ impl Game<'_> { } } -/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching). +/// A guest's bounding box, by its pose (`Avatar.POSES`). pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb { - let [x, y, z] = g.position; - let height = if g.sneaking { 1.5 } else { 1.8 }; - Aabb::new( - Vec3::new(x - 0.3, y, z - 0.3), - Vec3::new(x + 0.3, y + height, z + 0.3), - ) + g.bounds() } #[cfg(test)] @@ -363,6 +374,7 @@ mod tests { off_hand: None, head: None, armor_cover: 0.0, + invisible: false, spectator: false, gold_armor: false, hurt_by: None, @@ -370,9 +382,11 @@ mod tests { velocity: Vec3::ZERO, yaw: 0.0, hero: false, + effects: Vec::new(), uuid: [0; 4], allies: Default::default(), remote: false, + passenger: false, } } diff --git a/crates/horizon-client/src/game/lan_particles.rs b/crates/horizon-client/src/game/lan_particles.rs index c41fdf1ad..aa5981e0b 100644 --- a/crates/horizon-client/src/game/lan_particles.rs +++ b/crates/horizon-client/src/game/lan_particles.rs @@ -1,7 +1,8 @@ //! Particles a server sends its players and its clients draw: //! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which //! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`). -use super::Game; +use super::{Game, lan::LanHost}; +use horizon_client::lan::hub::Hub; use horizon_client::particles::{ParticleOptions, ParticleType}; use horizon_core::{geometry::Vec3, random::RandomSource}; use horizon_protocol::{ @@ -24,6 +25,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] { [c[0], c[1], c[2]] } +/// `ServerLevel.sendParticles` of one soul particle moving by `velocity` +/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks. +pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) { + let Some(id) = host + .hub + .registry_id("minecraft:particle_type", "minecraft:soul") + else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle: Particle { id, data: None }, + override_limiter: false, + always_show: false, + pos: at, + dist: velocity.map(|v| v as f32), + max_speed: [1.0; 3], + count: 0, + randomization: 0, + }); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } +} + impl Game<'_> { /// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point. /// Keep the local event's rendering; connected players receive one at rest. @@ -52,6 +82,66 @@ impl Game<'_> { } } + /// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the + /// landing block's crumbs: drawn in this window, when it has one, and + /// sent to each player within 32 blocks. `host` is the hub of a guest's + /// turn, which is out of `self.lan` meanwhile. + pub(super) fn landing_dust( + &mut self, + host: Option<&LanHost>, + state: BlockStateId, + at: Vec3, + count: i32, + speed: f64, + ) { + let at = [at.x, at.y, at.z]; + if self.serving.is_none() && self.options.dedicated.is_none() { + let block = [ + at[0].floor() as i32, + (at[1] - 0.2).floor() as i32, + at[2].floor() as i32, + ]; + if let Some((sprite, tint)) = self.particle_sprite(state, block) { + self.particles.send_particles( + &ParticleOptions::block(ParticleType::Block, sprite, tint), + at, + [0.0; 3], + speed, + count as u32, + ); + } + } + let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else { + return; + }; + let Some(particle) = Self::hub_particle( + hub, + &ParticleOptions::simple(ParticleType::Block), + Some(state), + ) else { + return; + }; + let packet = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: [0.0; 3], + max_speed: [speed as f32; 3], + count, + randomization: 0, + }); + for g in hub.guests.iter().filter(|g| hub.here(g)) { + // ServerLevel compares the player's block centre, strictly within 32. + let distance: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if distance < 32.0 * 32.0 { + g.send(&packet); + } + } + } + /// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's /// network id and the data it carries (`state` for the block particles). /// None while not serving, or for a particle whose data isn't sent from @@ -60,13 +150,19 @@ impl Game<'_> { &self, options: &ParticleOptions, state: Option, + ) -> Option { + Self::hub_particle(&self.lan.as_ref()?.hub, options, state) + } + + /// `network_particle` for a hub that is out of `self.lan` meanwhile (a + /// guest's turn). + fn hub_particle( + hub: &Hub, + options: &ParticleOptions, + state: Option, ) -> Option { let name = format!("minecraft:{}", options.ty.name()); - let id = self - .lan - .as_ref()? - .hub - .registry_id("minecraft:particle_type", &name)?; + let id = hub.registry_id("minecraft:particle_type", &name)?; use ParticleType as T; let c = options.color; let data = match options.ty { @@ -151,14 +247,10 @@ impl Game<'_> { return; }; let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from)); - // ClientLevel.addParticle: the camera's 32 blocks unless the packet - // (`force`) or the type overrides the limiter. + // ClientLevel.addParticle: the camera's 32 blocks and the Particles + // option, unless the packet or the type overrides the limiter. let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| { - if p.override_limiter { - particles.create(&options, at, v); - } else { - particles.add(&options, at, v); - } + particles.add_with(&options, at, v, p.override_limiter, p.always_show); }; if p.count == 0 { add( @@ -222,11 +314,87 @@ impl Game<'_> { } } + /// `ServerLevel.sendParticles` for the players a server serves: one + /// `ClientboundLevelParticlesPacket` to each player in the level whose + /// block is within 32 blocks of `at`. The window's own player is not + /// sent it (the caller draws it in this window). While the host holds + /// the players (a guest's turn through `as_guest`, a host's hit on a + /// guest) the packet waits for the turn to end so that it reaches every + /// player, the guest too. + pub(super) fn lan_send_particles( + &mut self, + options: &ParticleOptions, + at: [f64; 3], + spread: [f64; 3], + speed: f64, + count: u32, + ) { + if self.lan.is_none() { + // The server is running (its sounds are captured for the guests). + if self.audio.capture.is_some() { + self.creatures + .lan_particles + .push((options.clone(), at, spread, speed, count)); + } + return; + } + let Some(particle) = self.network_particle(options, None) else { + return; + }; + let Some(host) = self.lan.as_ref() else { + return; + }; + let body = C::LevelParticles(play::LevelParticles { + particle, + override_limiter: false, + always_show: false, + pos: at, + dist: spread.map(|d| d as f32), + max_speed: [speed as f32; 3], + count: count as i32, + randomization: 0, + }) + .encode(); + for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) { + // BlockPos.closerToCenterThan(at, 32): from the centre of the + // player's block. + let d: f64 = (0..3) + .map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2)) + .sum(); + if d < 32.0 * 32.0 { + g.sender.send(body.clone()); + } + } + } + + /// The particles guests' turns put off. + pub(super) fn flush_lan_particles(&mut self) { + for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles) + { + self.lan_send_particles(&options, at, spread, speed, count); + } + } + /// `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 /// 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 emitter = if radius < 2.0 { + pub(super) fn lan_explode( + &mut self, + center: [f64; 3], + small: bool, + radius: f32, + block_count: usize, + ) { + // In a guest's turn (their hit on an end crystal or a minecart) the + // host holds the players (`as_guest`): the packet 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 + .lan_explosions + .push((center, small, radius, block_count)); + return; + } + let emitter = if small { "minecraft:explosion" } else { "minecraft:explosion_emitter" @@ -239,6 +407,15 @@ impl Game<'_> { } } + /// The explosions guests' turns put off. + pub(super) fn flush_lan_explosions(&mut self) { + for (center, small, radius, block_count) in + std::mem::take(&mut self.creatures.lan_explosions) + { + self.lan_explode(center, small, radius, block_count); + } + } + /// 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 diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 8e756b7be..937fa28f4 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -37,6 +37,8 @@ pub(super) struct Strike { amount: f32, scale: f32, crit: bool, + /// `Player.attack`'s `magicBoost > 0`: the enchantments added damage. + magic: bool, from: [f64; 3], sweep: Option<[f64; 3]>, /// `causeExtraKnockback`: its strength and direction (the attacker's @@ -84,9 +86,11 @@ impl Game<'_> { }; if let Some(strike) = self.pvp_strike() && self.pvp_land(&mut host, Victim::Guest(conn), &strike) - && strike.stops_sprint() { - self.stop_sprint_after_extra_knockback(); + self.pvp_exhaustion(); + if strike.stops_sprint() { + self.stop_sprint_after_extra_knockback(); + } } self.lan = Some(host); true @@ -104,27 +108,33 @@ impl Game<'_> { .map(|g| Victim::Guest(g.conn)) } - /// The attacker's side of `Player.attack` (the host, or a guest swapped - /// in): the damage, the charge reset, the exhaustion and the swing. - pub(super) fn pvp_strike(&mut self) -> Option { - let p = self.player.as_ref()?; - let from = [p.position.x, p.position.y, p.position.z]; - let (amount, scale, crit) = self.melee_damage("minecraft:player"); - let sweep = self.sweep_attack_position(scale, crit); - let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale); - let (xd, zd) = self.extra_knockback_direction(); - self.attack_ticks = 0; + /// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker + /// (the host, or a guest swapped in) only once the hit hurt the victim. + pub(super) fn pvp_exhaustion(&mut self) { if let Some(p) = self.player.as_mut() && !self.creative { p.cause_food_exhaustion(0.1); } + } + + /// The attacker's side of `Player.attack` (the host, or a guest swapped + /// in): the damage, the charge reset and the swing. + pub(super) fn pvp_strike(&mut self) -> Option { + let p = self.player.as_ref()?; + let from = [p.position.x, p.position.y, p.position.z]; + let (amount, scale, crit, magic) = self.melee_damage("minecraft:player"); + let sweep = self.sweep_attack_position(scale, crit); + let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale); + let (xd, zd) = self.extra_knockback_direction(); + self.attack_ticks = 0; self.swing_arm(); Some(Strike { attacker: self.lan_acting, amount, scale, crit, + magic, from, sweep, knockback: (strength, xd, zd), @@ -257,7 +267,31 @@ impl Game<'_> { } else { Default::default() }; - if hurt && s.crit { + // ServerPlayer.awardKillScore: the blow that kills a player. + if hurt { + let died = match victim { + // A totem that fired is applied on the next tick_totem, so the + // victim is still at zero health but is not dying. + Victim::Host => self + .player + .as_ref() + .is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used), + Victim::Guest(conn) => host + .hub + .guest(conn) + .and_then(|g| g.motion.as_deref()) + .is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used), + }; + if died { + match s.attacker { + Some(conn) => { + self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1)); + } + None => self.stats.custom("player_kills", 1), + } + } + } + if hurt && (s.crit || s.magic) { let target = match victim { Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)), Victim::Guest(conn) => host @@ -267,13 +301,17 @@ impl Game<'_> { }; if let Some((id, p)) = target { let d = p.dimensions; - self.critical_hit( - s.attacker, - id, + let (feet, width, height) = ( [p.position.x, p.position.y, p.position.z], f64::from(d.width), f64::from(d.height), ); + if s.crit { + self.critical_hit(s.attacker, id, feet, width, height, false); + } + if s.magic { + self.critical_hit(s.attacker, id, feet, width, height, true); + } } } // `Enchantment.doPostAttack`: thorns hurt the attacking player. diff --git a/crates/horizon-client/src/game/lan_screen.rs b/crates/horizon-client/src/game/lan_screen.rs index 260cc76c3..055d1ea1f 100644 --- a/crates/horizon-client/src/game/lan_screen.rs +++ b/crates/horizon-client/src/game/lan_screen.rs @@ -5,14 +5,10 @@ //! forcing the game mode. Its difficulty lock asks first (PopupScreen), and //! Restrictions opens RestrictionsScreen. //! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen -use super::{ - Game, Screen, - game_rules_screen::{Exit, RulesScreen}, - integrated::ServerOptions, - lan::LanSettings, -}; +use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings}; use horizon_client::{ font::Font, + game_rules_screen::{Exit, RulesScreen}, hud::{HudBatch, rgba}, text::{Text, named_color}, ui::{self, EditBox}, diff --git a/crates/horizon-client/src/game/lan_trading.rs b/crates/horizon-client/src/game/lan_trading.rs index 7beeeba2f..0177a24b1 100644 --- a/crates/horizon-client/src/game/lan_trading.rs +++ b/crates/horizon-client/src/game/lan_trading.rs @@ -277,7 +277,12 @@ impl Game<'_> { g.carried = menu.carried.take(); } self.register_exploration_maps(made); - gm.merchant.session = self.merchant_apply(session, gm.merchant.mob); + let (session, traded) = self.merchant_apply(session, gm.merchant.mob); + gm.merchant.session = session; + if !traded.is_empty() { + // The guest's own statistic and advancements. + self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded)); + } self.drop_guest_items(host, gi, dropped); self.lan_send_offers(host, conn, &mut gm, false); self.lan_send_merchant_content(host, conn, &mut gm); diff --git a/crates/horizon-client/src/game/leads.rs b/crates/horizon-client/src/game/leads.rs index 8867157ee..877609d57 100644 --- a/crates/horizon-client/src/game/leads.rs +++ b/crates/horizon-client/src/game/leads.rs @@ -104,6 +104,9 @@ impl Game<'_> { }; let m = &mut self.mobs[i]; if let Some(h) = holder { + if h == Holder::Player { + m.extra.leash_player = Some(player_uuid); + } m.extra.leash = Some(h); m.extra.leash_pending = None; } else if m.age > 100 { @@ -201,7 +204,9 @@ impl Game<'_> { if self.mobs[index].extra.leash.is_some() { self.break_leash(id, true, false); } + let holder = self.acting_player_uuid(); self.mobs[index].extra.leash = Some(Holder::Player); + self.mobs[index].extra.leash_player = Some(holder); self.lead_sound("tied", at); // The lead is always used up. if let Some(stack) = self.held_mut().as_mut() { @@ -381,6 +386,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index f12aedc5b..7c6fb5598 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -1,21 +1,28 @@ //! Level events whose client handler (`LevelEventHandler`) spawns particles //! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a //! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax -//! off (3004) and scraping (3005). +//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon +//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009. use super::Game; +use horizon_client::items::ItemStack; use horizon_client::particles::{self, ParticleOptions, ParticleType}; -use horizon_core::{position::BlockPos, random::RandomSource}; +use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource}; use horizon_world::level::LevelEvent; +use horizon_world::registry::BlockStateId; impl Game<'_> { pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) { - self.particles - .block_teleportation(pos, data, [16, 8, 16], self.settings.particles); + self.particles.block_teleportation(pos, data, [16, 8, 16]); + } + /// Event 2016: `Shulker.teleportSomewhere`'s particles between the + /// shell's old and new cells. + pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) { + self.particles.block_teleportation(pos, data, [8, 8, 8]); } /// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles. pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) { self.particles - .block_teleportation(pos, data, [127, 127, 127], self.settings.particles); + .block_teleportation(pos, data, [127, 127, 127]); } /// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client /// level's random. @@ -65,24 +72,42 @@ impl Game<'_> { } else { // ParticleUtils.spawnParticleInBlock: up to the // block's shape (a full block when air). - let inside = self.world.block(at[0], at[1], at[2]); - let height = if facts.is_air(inside) { - 1.0 - } else { - facts - .get(inside) - .outline - .iter() - .map(|b| b[4]) - .fold(0.0, f64::max) - }; - (at, count, 0.5, height, true) + (at, count, 0.5, self.block_shape_top(at), true) } } None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false), None => return, }; - let options = ParticleOptions::simple(ParticleType::HappyVillager); + self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager); + } + /// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air. + fn block_shape_top(&self, at: [i32; 3]) -> f64 { + let facts = self.res.facts(); + let inside = self.world.block(at[0], at[1], at[2]); + if facts.is_air(inside) { + 1.0 + } else { + facts + .get(inside) + .outline + .iter() + .map(|b| b[4]) + .fold(0.0, f64::max) + } + } + /// `ParticleUtils.spawnParticles`: `count` slow particles spread over + /// `xz` blocks around `at` and `height` up, only above solid ground + /// unless floating ones are allowed. + fn spawn_particles( + &mut self, + at: [i32; 3], + count: i32, + xz: f64, + height: f64, + floating: bool, + ty: ParticleType, + ) { + let options = ParticleOptions::simple(ty); for _ in 0..count.max(0) { let r = self.particles.level_random(); let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02); @@ -101,6 +126,161 @@ impl Game<'_> { } } } + /// `LevelEventHandler` 2012 (a turtle egg laid on sand): + /// `ParticleUtils.spawnParticleInBlock` with happy villager sparks. + pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) { + let at = [pos.x, pos.y, pos.z]; + let height = self.block_shape_top(at); + self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager); + } + /// `LevelEventHandler` 3009 (a sniffer egg cracking on moss): + /// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`. + pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) { + let mut spawned = Vec::new(); + particles::on_block_faces_between( + self.particles.level_random(), + [pos.x, pos.y, pos.z], + 3, + 6, + |p, v| spawned.push((p, v)), + ); + let options = ParticleOptions::simple(ParticleType::EggCrack); + for (p, v) in spawned { + self.particles.add(&options, p, v); + } + } + /// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried): + /// eight puffs just above the block, drawn after the sound. + pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) { + let options = ParticleOptions::simple(ty); + for _ in 0..8 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double(); + let z = f64::from(pos.z) + r.next_double(); + self.particles + .add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke + /// puffs inside the block, drawn after the sound. + pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) { + let options = ParticleOptions::simple(ParticleType::Smoke); + for _ in 0..5 { + let r = self.particles.level_random(); + let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2; + let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2; + let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2; + self.particles.add(&options, [x, y, z], [0.0; 3]); + } + } + /// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath + /// particles thrown outwards, then the explosion sound when `data` is 1. + pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) { + for _ in 0..200 { + let r = self.particles.level_random(); + let dist = r.next_float() * 4.0; + let angle = r.next_float() * std::f32::consts::TAU; + let vx = f64::from(trig::cos(f64::from(angle)) * dist); + let vy = 0.01 + r.next_double() * 0.5; + let vz = f64::from(trig::sin(f64::from(angle)) * dist); + let options = ParticleOptions { + scale: dist, + ..ParticleOptions::simple(ParticleType::DragonBreath) + }; + self.particles.add( + &options, + [ + f64::from(pos.x) + vx * 0.1, + f64::from(pos.y) + 0.3, + f64::from(pos.z) + vz * 0.1, + ], + [vx, vy, vz], + ); + } + if data == 1 { + let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5); + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio.play( + "minecraft:entity.dragon_fireball.explode", + Some(at), + 1.0, + pitch, + ); + } + } + /// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down. + fn is_stalactite(&self, state: BlockStateId) -> bool { + self.res.facts().get(state).has_class("SpeleothemBlock") + && self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("vertical_direction")) + .is_some_and(|d| d == "down") + } + /// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at + /// the tip `pos`. The fluid above the stalactite's root picks the drip; + /// with none, the dimension's default (lava in the Nether). + pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) { + let tip_state = self.world.block(pos.x, pos.y, pos.z); + if !self.is_stalactite(tip_state) { + return; + } + // findRootBlock: up through the same stalactite, at most 11 steps. + let tip_name = self.res.facts().get(tip_state).name.clone(); + let mut root = None; + for step in 1..11 { + let y = pos.y + step; + if !(horizon_client::chunk::MIN_Y + ..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT) + .contains(&y) + { + break; + } + let state = self.world.block(pos.x, y, pos.z); + if self.res.facts().get(state).name != tip_name { + root = Some(y); + break; + } + if !self.is_stalactite(state) { + break; + } + } + let Some(root_y) = root else { + return; + }; + let above = self.world.block(pos.x, root_y + 1, pos.z); + let above_facts = self.res.facts().get(above); + let nether = self.dim == super::Dim::Nether; + let fluid = if &*above_facts.name == "minecraft:mud" && !nether { + horizon_client::blocks::Fluid::Water + } else { + above_facts.fluid + }; + let ty = match fluid { + horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater, + horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava, + horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater, + }; + let offset = self + .pack + .context + .tables + .offset(tip_state, pos.x, pos.z) + .unwrap_or([0.0; 3]); + // The tip shape starts 5/16 up; the particle sits a pixel below that. + self.particles.add( + &ParticleOptions::simple(ty), + [ + f64::from(pos.x) + 0.5 + offset[0], + f64::from(pos.y) + 5.0 / 16.0 - 0.0625, + f64::from(pos.z) + 0.5 + offset[2], + ], + [0.0; 3], + ); + } /// `ServerLevel.globalLevelEvent`: heard by every player on the server /// (an end portal opening, the dragon's death roar): here, and sent to /// every guest whatever their dimension. @@ -134,6 +314,92 @@ impl Game<'_> { // Each guest plays it from the global packet. self.audio.play_unshared(sound, at, volume, 1.0); } + /// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for + /// `play_sound`) and one crumb on the face being mined; nothing for air. + pub(super) fn breaking_block_effects( + &mut self, + pos: [i32; 3], + face: [i32; 3], + play_sound: bool, + ) { + let state = self.world.block(pos[0], pos[1], pos[2]); + if self.res.facts().is_air(state) { + return; + } + if play_sound { + let sounds = self.res.facts().get(state).sounds.clone(); + // Each guest plays it from the level event itself. + self.audio.play_unshared( + &sounds.hit, + Some(pos.map(|v| f64::from(v) + 0.5)), + (sounds.volume + 1.0) / 8.0, + sounds.pitch * 0.5, + ); + } + self.crack_particle(pos, face, state); + } + /// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007 + /// with `InstantEffect`): eight splash potion crumbs, and a hundred + /// spell particles in the potion's colour `data`, flung out in a disc. + pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) { + let ty = if instant { + ParticleType::InstantEffect + } else { + ParticleType::Effect + }; + // Vec3.atBottomCenterOf. + let at = [ + f64::from(pos.x) + 0.5, + f64::from(pos.y), + f64::from(pos.z) + 0.5, + ]; + let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1)); + let r = self.particles.level_random(); + let crumbs: Vec<[f64; 3]> = (0..8) + .map(|_| { + [ + r.next_gaussian() * 0.15, + r.next_double() * 0.2, + r.next_gaussian() * 0.15, + ] + }) + .collect(); + for v in crumbs { + self.particles.add(&crumb, at, v); + } + let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0; + let color = [channel(16), channel(8), channel(0)]; + for _ in 0..100 { + let r = self.particles.level_random(); + let dist = r.next_double() * 4.0; + let angle = r.next_double() * std::f64::consts::PI * 2.0; + let velocity = [ + angle.cos() * dist, + 0.01 + r.next_double() * 0.5, + angle.sin() * dist, + ]; + let brightness = 0.75 + r.next_float() * 0.25; + let options = ParticleOptions { + color: [ + color[0] * brightness, + color[1] * brightness, + color[2] * brightness, + 1.0, + ], + scale: dist as f32, + ..ParticleOptions::simple(ty) + }; + self.particles.add( + &options, + [ + at[0] + velocity[0] * 0.1, + at[1] + 0.3, + at[2] + velocity[2] * 0.1, + ], + velocity, + ); + } + } /// Queue and play level event `id` at `pos` (`Level.levelEvent`). pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) { self.level_events.push(LevelEvent::LevelEvent { diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 8d2e0a4a2..ab316c4c6 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -66,6 +66,8 @@ pub(super) struct Parked { tnts: Vec, hoppers: HashMap<(i32, i32, i32), machines::HopperState>, mobs: Vec, + /// The entity ids of the level's fence knots (`knot_ids`). + knot_ids: HashMap<[i32; 3], u32>, projectiles: Vec, dropped: Vec, orbs: Vec, @@ -75,7 +77,7 @@ pub(super) struct Parked { lan_events: Vec<(i32, [i32; 3], i32, Option)>, lan_block_events: Vec<([i32; 3], i32, i32, u32)>, lan_entity_events: Vec<(i32, i8, [f64; 3])>, - lan_critical_hits: Vec<(Option, i32)>, + lan_critical_hits: Vec<(Option, i32, bool)>, /// Only the per-level parts of these are used (`swap_level`). anims: BlockEntityAnims, raiding: Raiding, @@ -109,6 +111,23 @@ pub(super) struct Levels { parked: HashMap, } +impl Levels { + /// The chunks of each level not in place (capability `world` lends + /// them to server mods for a call). + #[cfg_attr(not(feature = "mods"), allow(dead_code))] + pub(super) fn parked_chunks( + &mut self, + ) -> impl Iterator { + self.parked + .iter_mut() + .map(|(d, p)| (*d, &mut p.world.chunks)) + } + /// The levels that are not in place, by dimension. + pub(super) fn parked_dims(&self) -> Vec { + self.parked.keys().copied().collect() + } +} + impl Game<'_> { /// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`). fn open_level(&self, dim: Dim) -> Result { @@ -153,6 +172,7 @@ impl Game<'_> { tnts: Vec::new(), hoppers: HashMap::new(), mobs: Vec::new(), + knot_ids: HashMap::new(), projectiles: Vec::new(), dropped: Vec::new(), orbs: Vec::new(), @@ -179,6 +199,7 @@ impl Game<'_> { let mut locator = self.end.locator(); swap(&mut locator, &mut p.locator); self.end.set_locator(locator); + self.share_spawn_conditions(); swap(&mut self.save, &mut p.save); swap(&mut self.block_ticks, &mut p.block_ticks); swap(&mut self.tick_chunks, &mut p.tick_chunks); @@ -205,6 +226,7 @@ impl Game<'_> { swap(&mut self.tnts, &mut p.tnts); swap(&mut self.hoppers, &mut p.hoppers); swap(&mut self.mobs, &mut p.mobs); + swap(&mut self.knot_ids, &mut p.knot_ids); swap(&mut self.creatures.projectiles, &mut p.projectiles); swap(&mut self.dropped, &mut p.dropped); swap(&mut self.orbs, &mut p.orbs); @@ -225,6 +247,7 @@ impl Game<'_> { swap(&mut a.pots, &mut b.pots); swap(&mut a.gateways, &mut b.gateways); swap(&mut a.gateway_servers, &mut b.gateway_servers); + swap(&mut a.conduit_servers, &mut b.conduit_servers); self.raiding.swap_level(&mut p.raiding); self.sculk.swap_level(&mut p.sculk); self.creaking.swap_level(&mut p.creaking); @@ -313,6 +336,8 @@ impl Game<'_> { }; self.update_dedicated_streaming(); self.update_world(); + // What server mods set in this level since its last tick. + self.apply_mod_block_writes(); result = if paused { self.tick_lan(true); Ok(()) @@ -383,7 +408,8 @@ impl Game<'_> { } *next += self.clock.tick_duration(); if *next > now { - // A `/pregen` task keeps the workers fed until the next tick. + // A `/pregen` task or force-loaded chunks keep the workers fed + // until the next tick. self.pump_pregen(*next); let now = Instant::now(); if *next > now { diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 297f372c5..921244e6d 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -16,7 +16,7 @@ pub(super) struct Bolt { flashes: i32, seed: i64, visual_only: bool, - /// Mooshrooms switch colour once per bolt. + /// Mooshrooms and copper golems change once per bolt. converted: Vec, } impl Game<'_> { @@ -74,14 +74,74 @@ impl Game<'_> { self.spawn_bolt(at, trap); } } - /// `findLightningTargetAround`: a living entity under open sky in the - /// column (3 blocks around), else the top of the column. + /// `ServerLevel.findLightningRod`: `PoiManager.findClosest` over the + /// lightning rod points of interest within 128 blocks of `center` whose + /// block is the top (`WORLD_SURFACE`) of its column; the strike lands one + /// above it. Horizon keeps no point-of-interest index, so this scans the + /// loaded chunks the way `/locate poi` does. + fn find_lightning_rod(&self, center: [i32; 3]) -> Option<[i32; 3]> { + const RANGE: i32 = 128; + let facts = self.res.facts(); + let mut is_rod: std::collections::HashMap = std::collections::HashMap::new(); + let mut best: Option<(i64, [i32; 3])> = None; + let (cx0, cz0) = ((center[0] - RANGE) >> 4, (center[2] - RANGE) >> 4); + let (cx1, cz1) = ((center[0] + RANGE) >> 4, (center[2] + RANGE) >> 4); + for cx in cx0..=cx1 { + for cz in cz0..=cz1 { + let Some(chunk) = self.world.chunks.get(&(cx, cz)) else { + continue; + }; + for (i, section) in chunk.sections.iter().enumerate() { + let mut rod = |state: u32| { + *is_rod.entry(state).or_insert_with(|| { + facts + .get(horizon_world::registry::BlockStateId(state)) + .name + .ends_with("lightning_rod") + }) + }; + if !section.blocks.maybe_has(&mut rod) { + continue; + } + let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32; + for j in 0..4096 { + if !rod(section.get(j)) { + continue; + } + let p = [ + cx * 16 + (j & 15) as i32, + base_y + (j >> 8) as i32, + cz * 16 + ((j >> 4) & 15) as i32, + ]; + let d = [p[0] - center[0], p[1] - center[1], p[2] - center[2]] + .map(|d| i64::from(d) * i64::from(d)) + .iter() + .sum::(); + if d > i64::from(RANGE * RANGE) || best.is_some_and(|(b, _)| d >= b) { + continue; + } + let surface = self.world.world_surface(p[0], p[2], |s| facts.is_air(s)); + if p[1] == surface - 1 { + best = Some((d, p)); + } + } + } + } + } + best.map(|(_, p)| [p[0], p[1] + 1, p[2]]) + } + /// `findLightningTargetAround`: the closest lightning rod, else a living + /// entity under open sky in the column (3 blocks around), else the top of + /// the column. fn lightning_target(&mut self, x: i32, z: i32) -> Option<[i32; 3]> { if !self.world.loaded(x, z) { return None; } let top = self.with_level(|b, level, _| b.motion_blocking_top(level, BlockPos::new(x, 0, z))); + if let Some(rod) = self.find_lightning_rod([x, top.y, z]) { + return Some(rod); + } let sees_sky = |g: &Self, p: [i32; 3]| g.world.light(p[0], p[1], p[2]) >> 4 == 15; let mut candidates: Vec<[i32; 3]> = self .mobs @@ -218,6 +278,13 @@ impl Game<'_> { } self.strike_rod(b.at); } + // LightningBolt.tick runs clearCopperOnLightningStrike for + // every server bolt, a skeleton trap's visual one included; + // a client's copy of a server bolt (spawn_remote_bolt) only + // flashes. + if self.remote.is_none() { + self.bolt_clear_copper(b.at); + } } b.life -= 1; if b.life < 0 { @@ -274,6 +341,16 @@ impl Game<'_> { self.level_event_at(3002, p, axis); } } + /// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck. + fn bolt_clear_copper(&mut self, at: [f64; 3]) { + let p = BlockPos::new( + at[0].floor() as i32, + (at[1] - 1.0e-6).floor() as i32, + at[2].floor() as i32, + ); + self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p)); + let _ = self.process_level_events(); + } /// `thunderHit` for entities within 3 blocks (up to 9 above). fn thunder_hit(&mut self, b: &mut Bolt) { let at = b.at; @@ -333,6 +410,17 @@ impl Game<'_> { continue; } Species::Creeper => m.extra.charged = true, + // CopperGolem.thunderHit (after Entity.thunderHit's fire and + // damage): once per bolt, an oxidized golem goes back one step + // and its weathering timer (or wax, -2) is reset to -1. + Species::CopperGolem if !b.converted.contains(&m.id) => { + b.converted.push(m.id); + if m.extra.variant > 0 { + m.extra.vector[0] = -1.0; + m.extra.variant = + horizon_client::mobs::copper_golem::weather_previous(m.extra.variant); + } + } _ => {} } // Entity.thunderHit: burning (8 seconds) and 5 lightning damage. diff --git a/crates/horizon-client/src/game/llamas.rs b/crates/horizon-client/src/game/llamas.rs index 1ce518f31..080343952 100644 --- a/crates/horizon-client/src/game/llamas.rs +++ b/crates/horizon-client/src/game/llamas.rs @@ -25,11 +25,13 @@ impl Game<'_> { }; let m = &mut self.mobs[index]; let mut used = false; + let mut loved = false; if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 { m.extra.in_love = 600; m.extra.love_player = true; used = true; + loved = true; } if m.health < m.attrs.max_health { m.health = (m.health + heal).min(m.attrs.max_health); @@ -57,6 +59,10 @@ impl Game<'_> { if !used { return false; } + // Animal.setInLove: entity event 18. + if loved { + self.mob_event_particles(self.mobs[index].id, 18); + } self.llama_sound(index, "entity.llama.eat", 1.0); self.consume_held(); true diff --git a/crates/horizon-client/src/game/mace.rs b/crates/horizon-client/src/game/mace.rs index 8ad5e76c3..34c968980 100644 --- a/crates/horizon-client/src/game/mace.rs +++ b/crates/horizon-client/src/game/mace.rs @@ -24,7 +24,7 @@ const KNOCKBACK_POWER: f32 = 0.7; /// `Entity.isAlliedTo` between the player (`player` its UUID) and a mob /// without teams: `TamableAnimal.considersEntityAsAlly` (a pet it tamed) and /// `Allay.considersEntityAsAlly` (an allay that likes it). -fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { +pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool { let tamable = matches!( m.species(), Species::Wolf diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 8742962e9..78dbfe66b 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -159,8 +159,10 @@ impl Containers<'_> { }); } let entity = self.world.block_entities.get(&pos)?; - // A beehive keeps bees, not items: it is no `Container`. - if matches!(entity, BlockEntity::Beehive(_)) { + // A beehive keeps bees, not items, and a campfire cooks its own: neither + // is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops + // in front of them and a hopper leaves them alone. + if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) { return None; } let kind = match (entity, name) { @@ -624,46 +626,8 @@ impl Game<'_> { /// `HopperBlockEntity.suckInItems`: from the container above, or item entities. fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool { let above = (pos.0, pos.1 + 1, pos.2); - let c = self.containers(); - if let Some(source) = c.view(above, false) { - if let Kind::Composter(level) = source.kind { - if level != 8 { - return false; - } - // tryTakeInItemFromSlot on the OutputContainer: removeItem calls - // its setChanged, which empties the composter, and so does the - // second setChanged after the move or, when the hopper has no - // room, the setItem putting the bone meal back into the detached - // container (whose bone meal is lost with it). - let bone_meal = c.bone_meal.clone(); - let at = BlockPos::new(above.0, above.1, above.2); - self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); - let moved = self.add_item(None, hopper, bone_meal, None).is_none(); - self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); - self.world.modified.insert((above.0 >> 4, above.2 >> 4)); - return moved; - } - let source_hopper = (source.kind == Kind::Hopper).then_some(above); - for slot in source.slots_for_face(Some(Direction::Down)) { - let c = self.containers(); - let Some(stack) = c.get(&source, slot) else { - continue; - }; - if !c.can_take(&source, slot, stack, Direction::Down) { - continue; - } - let Some(one) = self.take_one(&source, slot) else { - continue; - }; - match self.add_item(source_hopper, hopper, one, None) { - None => { - self.view_changed(&source); - return true; - } - Some(rest) => self.put_back(&source, slot, rest), - } - } - return false; + if let Some(took) = self.suck_from_container(above, hopper) { + return took; } let above_state = self.world.block(above.0, above.1, above.2); let blocked = self.res.facts().get(above_state).full_collision @@ -693,6 +657,56 @@ impl Game<'_> { { return false; } + self.suck_item_entities(hopper, min, max) + } + /// The container half of `HopperBlockEntity.suckInItems`: one item from the + /// container at `above`, through its bottom face. `None` when there is no + /// container there. + fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option { + let c = self.containers(); + let source = c.view(above, false)?; + if let Kind::Composter(level) = source.kind { + if level != 8 { + return Some(false); + } + // tryTakeInItemFromSlot on the OutputContainer: removeItem calls + // its setChanged, which empties the composter, and so does the + // second setChanged after the move or, when the hopper has no + // room, the setItem putting the bone meal back into the detached + // container (whose bone meal is lost with it). + let bone_meal = c.bone_meal.clone(); + let at = BlockPos::new(above.0, above.1, above.2); + self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); + let moved = self.add_item(None, hopper, bone_meal, None).is_none(); + self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at)); + self.world.modified.insert((above.0 >> 4, above.2 >> 4)); + return Some(moved); + } + let source_hopper = (source.kind == Kind::Hopper).then_some(above); + for slot in source.slots_for_face(Some(Direction::Down)) { + let c = self.containers(); + let Some(stack) = c.get(&source, slot) else { + continue; + }; + if !c.can_take(&source, slot, stack, Direction::Down) { + continue; + } + let Some(one) = self.take_one(&source, slot) else { + continue; + }; + match self.add_item(source_hopper, hopper, one, None) { + None => { + self.view_changed(&source); + return Some(true); + } + Some(rest) => self.put_back(&source, slot, rest), + } + } + Some(false) + } + /// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`): + /// adds the item entities that meet the box, until one is taken whole. + fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool { let mut i = 0; while i < self.dropped.len() { let b = self.dropped[i].bounds(); @@ -717,6 +731,66 @@ impl Game<'_> { } false } + /// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled + /// hopper minecart: one item from the container above, else the item + /// entities over it or touching it, each tick without a cooldown. + pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) { + // The cart's slots are edited as a container under the placeholder key + // its menu uses; when that menu is open the placeholder is the live copy. + let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32); + let open = self.world.block_entities.contains_key(&key); + if !open { + let items = BlockEntity::Chest(std::mem::take(&mut cart.items)); + self.world.block_entities.insert(key, items); + } + let size = self + .world + .block_entities + .get(&key) + .map_or(0, |e| e.slots().len()); + let view = View { + parts: vec![(key, size)], + kind: Kind::Generic, + }; + let p = cart.position; + // getLevelY is the cart's y + 0.5, so the container is the block at + // y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16 + // above the cart. + let above = ( + p.x.floor() as i32, + (p.y + 1.5).floor() as i32, + p.z.floor() as i32, + ); + let took = match self.suck_from_container(above, &view) { + Some(took) => took, + None => self.suck_item_entities( + &view, + [p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5], + [p.x + 0.5, p.y + 2.0, p.z + 0.5], + ), + }; + if !took { + // The cart's own box, inflated by 0.25 sideways. + let b = cart.bounds(); + let (lo, hi) = (b.min_position(), b.max_position()); + self.suck_item_entities( + &view, + [lo.x - 0.25, lo.y, lo.z - 0.25], + [hi.x + 0.25, hi.y, hi.z + 0.25], + ); + } + // The cart's own items stay the one source of truth: a menu copy that + // is still around (open, or left by a guest's close) gets the pulled + // items back into `cart.items`, so reopening never restores a stale copy. + let slots = if open { + self.world.block_entities.get(&key).cloned() + } else { + self.world.block_entities.remove(&key) + }; + if let Some(BlockEntity::Chest(items)) = slots { + cart.items = items; + } + } // ---- dispensers and droppers ---- diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index 6da65c592..1617eab56 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -324,6 +324,13 @@ const DECORATION_TYPES: [(&str, &str); 24] = [ ("mineshaft", "map/decorations/mineshaft"), ("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"), ]; +/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers. +fn shown_on_frame(sprite: &str) -> bool { + !matches!( + decoration_type(sprite), + Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits") + ) +} /// 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> { @@ -1114,9 +1121,12 @@ impl Game<'_> { } /// `MapRenderer.render`: the map texture and its decorations under `m` /// (map pixels 0..128, y down), drawn at depth offsets toward the viewer. + /// `show_only_frame` (a map in an item frame) leaves out the decorations that + /// do not show on frames. pub(super) fn map_vertices( &mut self, id: i32, + show_only_frame: bool, m: glam::Mat4, light: u32, out: &mut Vec, @@ -1133,6 +1143,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; @@ -1158,14 +1170,18 @@ impl Game<'_> { .get(&id) .map(|m| m.decorations.clone()) .unwrap_or_default(); - for (i, d) in decorations.iter().enumerate() { + let mut count = 0; + for d in &decorations { + if show_only_frame && !shown_on_frame(d.sprite) { + continue; + } let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else { continue; }; let dm = glam::Mat4::from_translation(glam::Vec3::new( f32::from(d.x) / 2.0 + 64.0, f32::from(d.y) / 2.0 + 64.0, - -0.02 - i as f32 * 0.001, + -0.02 - count as f32 * 0.001, )) * glam::Mat4::from_rotation_z( (f32::from(d.rot) * 360.0 / 16.0).to_radians(), ) * glam::Mat4::from_scale(glam::Vec3::splat(4.0)) @@ -1180,6 +1196,8 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } }; @@ -1187,6 +1205,7 @@ impl Game<'_> { [p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)], [[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]], ); + count += 1; } } /// `renderTwoHandedMap`: map `id` held in both hands, in view space (the @@ -1251,11 +1270,13 @@ impl Game<'_> { layer: 0, color: 0xFFFF_FFFF, light, + + glint_uv: [0.0, 0.0], }); } } if has_data { - self.map_vertices(id, m, light, out); + self.map_vertices(id, false, m, light, out); } } } diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 072059e49..9dead141c 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -9,7 +9,7 @@ use horizon_client::{ blocks::Fluid, collisions::SceneCollisions, containers::BlockEntity, - items::ItemStack, + items::{ItemStack, Items}, mesher::Vertex, mobs::{Part, anim::Poses}, particles::{ParticleOptions, ParticleType}, @@ -73,7 +73,7 @@ pub(super) struct Minecart { pub yaw: f32, pub previous_yaw: f32, pub flipped: bool, - on_ground: bool, + pub(super) on_ground: bool, horizontal_collision: bool, fall_distance: f64, pub hurt_time: i32, @@ -90,6 +90,8 @@ pub(super) struct Minecart { /// Chest minecarts: the loot table rolled into `items` on load, saved /// again while the items are still that roll. pub loot: Option, + /// `Entity.firstTick`: not ticked yet (saved as `HasTicked`). + pub first_tick: bool, } pub(super) const WIDTH: f64 = 0.98; pub(super) const HEIGHT: f64 = 0.7; @@ -186,6 +188,7 @@ impl Minecart { fuse: -1, enabled: true, loot: None, + first_tick: true, } } pub fn bounds(&self) -> Aabb { @@ -198,6 +201,17 @@ impl Minecart { fn horizontal(&self) -> f64 { self.velocity.x.hypot(self.velocity.z) } + /// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`. + fn container_signal(slots: &[Option], items: &Items) -> i32 { + let mut total = 0.0f32; + for s in slots.iter().flatten() { + let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64); + total += s.count as f32 / max as f32; + } + total /= slots.len().max(1) as f32; + // Mth.lerpDiscrete(total, 0, 15) + (total * 14.0).floor() as i32 + i32::from(total > 0.0) + } } pub(super) struct CartModels { pub models: HashMap, @@ -536,6 +550,7 @@ impl Game<'_> { } /// `AbstractMinecart.tick` for one cart; returns false once it is gone. fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool { + c.first_tick = false; c.previous = c.position; c.previous_yaw = c.yaw; c.hurt_time = (c.hurt_time - 1).max(0); @@ -543,7 +558,6 @@ impl Game<'_> { // applyGravity c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 }; let pos = self.rail_block(c.position); - let fall_speed = c.velocity; if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) { self.move_along_track(c, pos, &rail, intent); if &*rail.name == "minecraft:activator_rail" { @@ -585,22 +599,26 @@ impl Game<'_> { } } CartKind::Tnt => { - let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z; + // MinecartTNT.tick: the fuse and the collision check are separate. if c.fuse > 0 { c.fuse -= 1; let at = [c.position.x, c.position.y + 0.5, c.position.z]; self.particles .add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]); - } else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) { + } else if c.fuse == 0 { + // explode(horizontalDistanceSqr()): a rising cart's + // vertical speed adds nothing. let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z; - let speed = if c.fuse == 0 && c.velocity.y > 0.0 { - c.velocity.y * c.velocity.y - } else { - speed - }; self.explode_cart(c, speed); return false; } + if c.horizontal_collision { + let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z; + if speed >= f64::from(0.01_f32) { + self.explode_cart(c, speed); + return false; + } + } } _ => {} } @@ -644,51 +662,9 @@ impl Game<'_> { // The cart is the explosion's source: it is gone before the blast // looks for entities, so it can't hit itself. self.carts.retain(|x| x.id != c.id); - let _ = self.server_explode(at, power, BlockInteraction::Destroy, false); - } - /// `MinecartHopper.suckInItems`: items above, into the five slots. - fn hopper_cart_pickup(&mut self, c: &mut Minecart) { - if !c.enabled { - return; - } - let p = c.position; - let zone = Aabb::new( - Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5), - Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5), - ); - let mut i = 0; - while i < self.dropped.len() { - let e = &self.dropped[i]; - if e.pickup_delay > 0 || !zone.contains(e.position) { - i += 1; - continue; - } - let mut stack = e.stack.clone(); - for slot in c.items.iter_mut() { - match slot { - None => { - *slot = Some(stack.clone()); - stack.count = 0; - } - Some(s) if s.same_item_same_components(&stack) => { - let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack); - let moved = stack.count.min(max.saturating_sub(s.count)); - s.count += moved; - stack.count -= moved; - } - _ => {} - } - if stack.count == 0 { - break; - } - } - if stack.count == 0 { - self.dropped.remove(i); - } else { - self.dropped[i].stack = stack; - i += 1; - } - } + let _ = self.with_hive_emptying_blast(|g| { + g.server_explode(at, power, BlockInteraction::Destroy, false) + }); } /// `AbstractMinecart.push` from each player walking into a cart /// (`LivingEntity.pushEntities`): the window's player and every server @@ -713,12 +689,7 @@ impl Game<'_> { 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)); + pushers.push((Vec3::new(x, y, z), g.bounds())); } } for (feet, bb) in pushers { @@ -827,8 +798,8 @@ impl Game<'_> { } let mut c = self.carts[i].clone(); let alive = self.tick_cart(&mut c, [0.0; 2]); - if c.kind == CartKind::Hopper { - self.hopper_cart_pickup(&mut c); + if c.kind == CartKind::Hopper && c.enabled { + self.hopper_cart_suck(&mut c); } let Some(i) = self.carts.iter().position(|c| c.id == id) else { continue; @@ -946,7 +917,7 @@ impl Game<'_> { } true } - /// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top. + /// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top. pub(super) fn dismount_cart(&mut self, i: usize) { self.riding = None; let c = &self.carts[i]; @@ -956,17 +927,25 @@ impl Game<'_> { c.position.z.floor() as i32, ); let mut target = None; + // DismountHelper.offsetsForDirection of the cart's facing + // (Direction.fromYRot of its yaw): right, left, back right, back left, + // forward right, forward left, back, forward. + let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize]; + let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32; + let (fx, fz) = step(facing); + let (rx, rz) = (-fz, fx); + let offsets = [ + (rx, rz), + (-rx, -rz), + (rx - fx, rz - fz), + (-rx - fx, -rz - fz), + (fx + rx, fz + rz), + (fx - rx, fz - rz), + (-fx, -fz), + (fx, fz), + ]; 'search: for dy in [0, 1, -1] { - for (dx, dz) in [ - (1, 0), - (-1, 0), - (0, 1), - (0, -1), - (1, 1), - (-1, -1), - (1, -1), - (-1, 1), - ] { + for (dx, dz) in offsets { let (x, y, z) = (bx + dx, by + dy, bz + dz); let below = self.res.facts().get(self.world.block(x, y - 1, z)); let here = self.res.facts().get(self.world.block(x, y, z)); @@ -1050,6 +1029,22 @@ impl Game<'_> { } best } + /// Each cart's kind and comparator signal, for the detector rail + /// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open + /// counts the items in that menu. + pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> { + self.carts + .iter() + .map(|c| { + let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); + let slots = match self.world.block_entities.get(&key) { + Some(BlockEntity::Chest(open)) => open, + _ => &c.items, + }; + (c.kind, Minecart::container_signal(slots, self.res.items())) + }) + .collect() + } /// `interact`: ride, open, fuel or ignite the cart under the crosshair. pub(super) fn interact_cart(&mut self) -> bool { let Some((i, d)) = self.cart_hit() else { @@ -1067,10 +1062,8 @@ impl Game<'_> { } self.riding = Some(self.carts[i].id); } + // AbstractMinecartContainer.interact: no sneak check. CartKind::Chest | CartKind::Hopper => { - if sneaking { - return false; - } let c = self.carts[i].clone(); let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32); self.world @@ -1297,6 +1290,7 @@ impl Game<'_> { layer: 0, color, light, + glint_uv: [0.0, 0.0], }); } } @@ -1340,9 +1334,8 @@ impl Game<'_> { return; }; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean - // edge, scaled by the entity distance option. - let range = - (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance); + // edge, scaled by the view scale. + let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale(); for c in &self.carts { let entity_start = out.len(); let Some(model) = self.cart_models.models.get(&c.kind) else { @@ -1509,6 +1502,10 @@ impl Minecart { map.insert(JavaString::from(k), v); }; put("FlippedRotation", Tag::Byte(i8::from(self.flipped))); + // AbstractMinecart saves firstTick as HasTicked. A cart never burns: + // Entity.move sets -getFireImmuneTicks(), 0 for all but players. + put("HasTicked", Tag::Byte(i8::from(self.first_tick))); + put("Fire", Tag::Short(0)); match self.kind { CartKind::Chest | CartKind::Hopper => { put("Items", horizon_client::save::encode_items(&self.items)); @@ -1558,6 +1555,7 @@ impl Minecart { _ => 0.0, }; c.flipped = int("FlippedRotation").unwrap_or(0) != 0; + c.first_tick = int("HasTicked").unwrap_or(0) != 0; if kind.slots() > 0 { c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots()); } @@ -1582,4 +1580,30 @@ mod tests { assert_eq!(display_offset(CartKind::CommandBlock), 6.0); assert_eq!(display_offset(CartKind::Rideable), 6.0); } + + #[test] + fn a_cart_saves_as_never_ticked_until_it_ticks() { + use horizon_world::nbt::Tag; + let generated = Tag::compound([ + ("id", Tag::String("minecraft:chest_minecart".into())), + ( + "Pos", + Tag::List( + [0.5f64, 40.5, 0.5] + .map(|v| Tag::Double(v.to_bits())) + .to_vec(), + ), + ), + ("HasTicked", Tag::Byte(1)), + ("Fire", Tag::Short(0)), + ]); + let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap(); + let saved = cart.to_nbt([0; 4], Some(&generated)); + assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1))); + assert_eq!(saved.get("Fire"), Some(&Tag::Short(0))); + cart.first_tick = false; + let saved = cart.to_nbt([0; 4], Some(&generated)); + assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0))); + assert_eq!(saved.get("Fire"), Some(&Tag::Short(0))); + } } diff --git a/crates/horizon-client/src/game/mob_blocks.rs b/crates/horizon-client/src/game/mob_blocks.rs index 083c96a66..a57accfaa 100644 --- a/crates/horizon-client/src/game/mob_blocks.rs +++ b/crates/horizon-client/src/game/mob_blocks.rs @@ -1,6 +1,7 @@ //! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the //! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like -//! dropped block items. +//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`, +//! `MushroomCowMushroomLayer`). use super::Game; use horizon_client::{ items::ItemStack, @@ -203,6 +204,8 @@ impl Game<'_> { layer: overlay, color, light, + + glint_uv: [0.0, 0.0], }); } } @@ -218,6 +221,7 @@ impl Game<'_> { ) { self.captain_banner_geometry(mob, camera, alpha, light, out); self.held_item_geometry(mob, camera, alpha, light, true, out); + self.worn_block_geometry(mob, camera, alpha, light, out); if mob.species() == Species::Enderman && let Some(state) = mob.extra.carried && let Some((sprite, tint)) = self.block_face_sprite(state) @@ -244,6 +248,76 @@ impl Game<'_> { ); } } + /// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin + /// on a snow golem's head, and the mushrooms on an adult mooshroom's back + /// and head, as baked block models. + fn worn_block_geometry( + &self, + mob: &Mob, + camera: glam::DVec3, + alpha: f64, + light: u32, + out: &mut Vec, + ) { + use glam::{Mat4, Vec3}; + let species = mob.species(); + let on_head = match species { + Species::SnowGolem => !mob.extra.sheared, + Species::Mooshroom => !mob.is_baby(), + _ => false, + }; + // The layers draw nothing on an invisible mob (the glow outline is not drawn). + if !on_head || horizon_client::mobs::trader::is_invisible(mob) { + return; + } + let registry = &self.pack.assets.registry; + let block = match species { + Species::SnowGolem => "minecraft:carved_pumpkin", + _ if mob.extra.variant == 1 => "minecraft:brown_mushroom", + _ => "minecraft:red_mushroom", + }; + let Some(state) = registry.default_state(block) else { + return; + }; + let alpha = alpha as f32; + let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head") + else { + return; + }; + let unit = Mat4::from_translation(Vec3::splat(-0.5)); + let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0)); + let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z)); + let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians()); + let transforms = if species == Species::SnowGolem { + vec![ + head * at(0.0, -0.34375, 0.0) + * spin(180.0) + * Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625)) + * unit, + ] + } else { + let base = render::base_transform(mob, alpha, camera); + vec![ + base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit, + base * at(0.2, -0.35, 0.5) + * spin(42.0) + * at(0.1, 0.0, -0.6) + * spin(-48.0) + * flip + * unit, + head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit, + ] + }; + // The hurt tint reaches these blocks as it does the body. + let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0); + for t in transforms { + let start = out.len(); + self.block_model_vertices(state, 0, t, light, true, out); + for vertex in &mut out[start..] { + vertex.layer = overlay; + } + } + } /// `ItemInHandLayer`: both hands through the item model pipeline; `lit` /// applies the level's diffuse lighting (off for GUI previews, which /// light the projected quads themselves). diff --git a/crates/horizon-client/src/game/mod_browser.rs b/crates/horizon-client/src/game/mod_browser.rs new file mode 100644 index 000000000..645415ccb --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser.rs @@ -0,0 +1,485 @@ +//! Capability `ui`'s panel beside container screens (WIT `interface +//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as +//! the game's own item slots in pages right of the screen's window, with a +//! search box under them. The mod says what the panel holds; the game pages +//! it and tells the mod what the player clicked, typed, and which of the +//! mod's keys went down over an item. With no mod showing a panel, each +//! hook here returns after one check. +use super::Game; +#[cfg(feature = "mods")] +use { + super::Screen, + horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, + ui::EditBox, + }, + horizon_mods::{BrowserClick, BrowserEvent, BrowserStack}, +}; + +#[cfg(not(feature = "mods"))] +impl Game<'_> { + pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {} + pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool { + false + } + pub(super) fn mod_browser_typing(&self) -> bool { + false + } + pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {} + pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {} + pub(super) fn mod_browser_closed(&mut self) {} +} + +/// One slot of the panel, in GUI pixels. +#[cfg(feature = "mods")] +const SLOT: f32 = 18.0; +/// The most columns the panel takes, and the fewest it shows with. +#[cfg(feature = "mods")] +const COLUMNS: (usize, usize) = (12, 2); +/// The gap between the window and the panel, and the screen's edges. +#[cfg(feature = "mods")] +const MARGIN: f32 = 8.0; + +/// The panel's state in the game. +#[cfg(feature = "mods")] +#[derive(Default)] +pub(super) struct Browser { + /// The items' owner and version they were made from. + of: Option<(usize, u64)>, + /// The mod's items as stacks; `None` for an item the game doesn't have. + stacks: Vec>, + page: usize, + search: Option, + /// The search text the mod last heard. + sent: String, + /// `set-recipes`' recipes, shown instead of the items. + pub(super) recipes: Option, +} + +/// Where the panel's parts are this frame. +#[cfg(feature = "mods")] +pub(super) struct Layout { + /// The panel's whole area, for clicks that hit none of its parts. + pub(super) area: [f32; 4], + /// The grid's top left corner, its columns and rows. + pub(super) grid: (f32, f32), + pub(super) columns: usize, + pub(super) rows: usize, + /// The page arrows' row. + pub(super) arrows_y: f32, + pub(super) search: [f32; 4], +} +#[cfg(feature = "mods")] +impl Layout { + fn per_page(&self) -> usize { + self.columns * self.rows + } + pub(super) fn width(&self) -> f32 { + self.columns as f32 * SLOT + } + fn slot(&self, n: usize) -> (f32, f32) { + ( + self.grid.0 + (n % self.columns) as f32 * SLOT, + self.grid.1 + (n / self.columns) as f32 * SLOT, + ) + } + /// The page arrows: backward, then forward. + pub(super) fn arrows(&self) -> [(f32, f32); 2] { + [ + (self.grid.0, self.arrows_y), + (self.grid.0 + self.width() - 12.0, self.arrows_y), + ] + } +} + +#[cfg(feature = "mods")] +pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool { + p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h +} + +#[cfg(feature = "mods")] +impl Game<'_> { + fn browser_batch(&self) -> Option { + let r = self.renderer.as_ref()?; + Some(HudBatch::new([r.config.width, r.config.height])) + } + /// The mod showing a panel, with its items made into stacks once per + /// `set-items`; none while no mod shows one or no container screen is up. + fn browser_sync(&mut self) -> Option { + if self.screen != Screen::Inventory || self.title.is_some() { + return None; + } + let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else { + self.mods.browser.of = None; + self.mods.browser.stacks.clear(); + return None; + }; + if self.mods.browser.of != Some((owner, version)) { + let items = self.res.items(); + let stacks = self + .mods + .mods + .browser_items() + .map_or_else(Vec::new, |(_, _, list)| { + list.iter().map(|s| browser_stack(items, s)).collect() + }); + self.mods.browser.stacks = stacks; + // The same title again (the mod updating its buttons) keeps the page. + let old = self.mods.browser.recipes.take(); + self.mods.browser.recipes = self + .mods + .mods + .browser_recipes(owner) + .map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old)); + self.mods.browser.of = Some((owner, version)); + } + Some(owner) + } + /// The panel's parts: right of the window, from the top of the screen to + /// the search box at its foot. None when the screen has no room for it. + fn browser_layout(&self, batch: &HudBatch) -> Option { + let image = self.container_image(batch); + let left = (image.left + image.width) as f32 + MARGIN; + let right = batch.width - MARGIN; + let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0); + if columns < COLUMNS.1 { + return None; + } + let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor(); + let arrows_y = MARGIN; + let top = arrows_y + 17.0 + 4.0; + let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0]; + let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize; + (rows > 0).then_some(Layout { + area: [left, 0.0, right - left, batch.height], + grid: (x, top), + columns, + rows, + arrows_y, + search, + }) + } + fn browser_pages(&self, layout: &Layout) -> usize { + self.mods + .browser + .stacks + .len() + .div_ceil(layout.per_page()) + .max(1) + } + /// The stack under the pointer on the panel, an item's or a recipe's, + /// as the mod sent it and as a stack. + fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> { + if self.mods.browser.recipes.is_some() { + if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) { + return Some((sent, stack)); + } + let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?; + return Some((sent, stack)); + } + let (index, stack) = self.browser_hovered(layout)?; + let (_, _, items) = self.mods.mods.browser_items()?; + Some((items.get(index)?.clone(), stack)) + } + /// The panel's item under the pointer: its index and stack. + fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> { + if self.mods.browser.recipes.is_some() { + return None; + } + let first = self.mods.browser.page * layout.per_page(); + (0..layout.per_page()).find_map(|n| { + let (x, y) = layout.slot(n); + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let stack = self.mods.browser.stacks.get(first + n)?.clone()?; + Some((first + n, stack)) + }) + } + fn browser_search(&mut self, layout: &Layout) -> &mut EditBox { + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit.focused = false; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + search + } + + /// The items' page arrows and grid. + fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) { + let pages = self.browser_pages(layout); + self.mods.browser.page = self.mods.browser.page.min(pages - 1); + let page = self.mods.browser.page; + let cursor = self.cursor; + if pages > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let shown = [page > 0, page + 1 < pages]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, shown)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(shown)) + { + if !shown { + continue; + } + let hover = inside(cursor, [x, y, 12.0, 17.0]); + let suffix = if hover { "_highlighted" } else { "" }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + let first = page * layout.per_page(); + for n in 0..layout.per_page() { + let Some(stack) = self.mods.browser.stacks.get(first + n) else { + break; + }; + let Some(stack) = stack.clone() else { + continue; + }; + let (x, y) = layout.slot(n); + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + let rect = [x + 1.0, y + 1.0, 16.0, 16.0]; + if inside(cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &name, + rect, + true, + horizon_client::widgets::Kind::Button, + ); + } + } + /// Draws the panel over the open container screen, then the carried + /// stack or the tooltip again so they stay on top of it. + pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) { + if self.browser_sync().is_none() { + return; + } + let Some(layout) = self.browser_layout(batch) else { + return; + }; + let cursor = self.cursor; + if self.mods.browser.recipes.is_some() { + self.draw_browser_recipes(batch, &layout); + } else { + self.draw_browser_items(batch, &layout); + } + let hint = self.translate("gui.recipebook.search_hint", "Search..."); + let search = self.browser_search(&layout); + // The hint shows in an empty box without focus, as the recipe book's. + search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint); + let mut search = search.clone(); + search.draw(batch, &self.gui.atlas, None); + self.mods.browser.search = Some(search); + if self.carried.is_some() { + self.draw_carried(batch); + } else if let Some(stack) = self.browser_pointed_stack(&layout) { + let lines = self.stack_tooltip(&stack.1); + self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1); + } else { + self.draw_slot_tooltip(batch); + } + } + + /// A mouse press on a container screen: true when the panel took it + /// (its search box, page arrows, items or empty space; a click there + /// never throws the carried stack). + pub(super) fn mod_browser_press(&mut self, right: bool) -> bool { + let Some(owner) = self.browser_sync() else { + return false; + }; + let Some(batch) = self.browser_batch() else { + return false; + }; + let Some(layout) = self.browser_layout(&batch) else { + return false; + }; + let cursor = self.cursor; + let shift = self.has_shift_down(); + let atlas = &self.gui.atlas; + let search = self.mods.browser.search.get_or_insert_with(|| { + let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0); + edit.max_length = 50; + edit + }); + [search.x, search.y, search.width, search.height] = layout.search; + if inside(cursor, layout.search) { + search.focused = true; + search.click(atlas, cursor.0, shift, false); + search.reset_blink(); + return true; + } + search.focused = false; + if self.mods.browser.recipes.is_some() { + return self.browser_recipes_press(&layout, owner, right) + || inside(cursor, layout.area); + } + let pages = self.browser_pages(&layout); + let page = self.mods.browser.page; + let [back, forward] = layout.arrows(); + let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) { + Some(page - 1) + } else if pages > 1 + && page + 1 < pages + && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) + { + Some(page + 1) + } else { + None + }; + if let Some(to) = step { + self.mods.browser.page = to; + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + return true; + } + if let Some((index, _)) = self.browser_hovered(&layout) { + let event = BrowserEvent::Clicked(BrowserClick { + index: index as u32, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + inside(cursor, layout.area) + } + + /// Whether keys go to the panel's search box. + pub(super) fn mod_browser_typing(&self) -> bool { + self.screen == Screen::Inventory + && self.mods.browser.of.is_some() + && self.mods.browser.search.as_ref().is_some_and(|s| s.focused) + } + /// A key press while the search box has focus. + pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) { + use winit::keyboard::PhysicalKey; + let shortcut = self.edit_shortcut(event); + let ctrl = self.has_control_down(); + let shift = self.has_shift_down(); + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if let Some(shortcut) = shortcut { + search.shortcut(shortcut, &mut self.clipboard); + } else { + let handled = match event.physical_key { + PhysicalKey::Code(code) => search.key(code, ctrl, shift), + _ => false, + }; + if !handled + && !ctrl + && let Some(text) = &event.text + { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + search.insert_text(&text); + } + } + search.reset_blink(); + self.browser_searched(); + } + /// Text typed (or one backspace) into the search box (`horizon-ctl type`). + pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) { + let Some(search) = self.mods.browser.search.as_mut() else { + return; + }; + if backspace { + search.delete_chars(-1); + } else if let Some(text) = text { + search.insert_text(text); + } + self.browser_searched(); + } + /// The mod hears the search text when it changed. + fn browser_searched(&mut self) { + let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search) + else { + return; + }; + if search.value != self.mods.browser.sent { + let value = search.value.clone(); + self.mods.browser.sent.clone_from(&value); + self.mods.browser.page = 0; + self.mods + .mods + .browser_event(owner, BrowserEvent::Search(value)); + } + } + /// The container screen closed: the search box lets go of the keys. + pub(super) fn mod_browser_closed(&mut self) { + if let Some(search) = self.mods.browser.search.as_mut() { + search.focused = false; + } + } + /// The item under the pointer on a container screen, for the mods' keys + /// (`browser`'s `key` event): the panel's, else a slot's. + pub(super) fn mod_browser_pointed(&mut self) -> Option { + let batch = self.browser_batch()?; + let stack = match self + .browser_sync() + .and_then(|_| self.browser_layout(&batch)) + { + // The panel's stacks as the mod sent them. + Some(layout) if inside(self.cursor, layout.area) => { + return Some(self.browser_pointed_stack(&layout)?.0); + } + _ => { + let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?; + self.slot_view(slot)? + } + }; + Some(BrowserStack { + item: stack.item.to_string(), + count: stack.count, + components: super::mod_recipes::components(&stack), + }) + } +} + +/// A mod's item as a stack: an item the game has, its components read as +/// `/give` reads them (ones it can't read are left out). +#[cfg(feature = "mods")] +pub(super) fn browser_stack( + items: &horizon_client::items::Items, + s: &BrowserStack, +) -> Option { + items.get(&s.item)?; + let (damage, extra) = if s.components.is_empty() { + (0, None) + } else { + horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }) + }; + Some(ItemStack { + item: s.item.as_str().into(), + count: s.count.clamp(1, 99), + damage, + extra, + }) +} diff --git a/crates/horizon-client/src/game/mod_browser_recipes.rs b/crates/horizon-client/src/game/mod_browser_recipes.rs new file mode 100644 index 000000000..557ae9029 --- /dev/null +++ b/crates/horizon-client/src/game/mod_browser_recipes.rs @@ -0,0 +1,523 @@ +//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item +//! panel's place, laid out by the game with its own slots, arrows and +//! buttons, paged to the panel's height, under a title and a back button. +//! The mod hears clicks on their slots, their action buttons, the back +//! button and page turns. +use super::{ + Game, + mod_browser::{Layout, browser_stack, inside}, +}; +use horizon_client::{ + hud::{HudBatch, rgba}, + items::ItemStack, +}; +use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage}; +use std::time::Instant; + +const SLOT: f32 = 18.0; +/// The title row's height, and the gap under each recipe. +const TITLE: f32 = 18.0; +/// A tab's step: an 18 px slot and a gap. +const TAB: f32 = 20.0; +const GAP: f32 = 6.0; + +/// One recipe as the game draws it. +pub(super) struct Shown { + layout: BrowserLayout, + width: usize, + height: usize, + /// Each slot's stacks (the result last), as the mod sent them and as + /// stacks; a stack the game doesn't have is left out of the cycle. + slots: Vec>, + note: String, + action: Option, +} + +/// The recipes a panel shows, and its page. +pub(super) struct Pages { + title: String, + /// The tabs' icons and the selected one. + tabs: Vec<(BrowserStack, Option)>, + tab: usize, + recipes: Vec, + page: usize, + since: Instant, +} + +impl Pages { + pub(super) fn new( + items: &horizon_client::items::Items, + shown: &RecipePage, + old: Option, + ) -> Self { + let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize); + let kept = old.filter(|o| o.title == *title && o.tab == tab); + let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> { + stacks + .iter() + .filter_map(|s| Some((s.clone(), browser_stack(items, s)?))) + .collect() + }; + let recipes = list + .iter() + .map(|r| { + let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect(); + slots.push(cycle(&r.results)); + Shown { + layout: r.layout, + width: r.width as usize, + height: r.height as usize, + slots, + note: r.note.clone(), + action: r.action, + } + }) + .collect(); + Self { + title: title.clone(), + tabs: shown + .tabs + .iter() + .map(|t| (t.clone(), browser_stack(items, t))) + .collect(), + tab, + recipes, + page: kept.as_ref().map_or(0, |o| o.page), + since: kept.map_or_else(Instant::now, |o| o.since), + } + } +} + +impl Shown { + /// The inputs' block: its size and each input slot's place in it. + fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) { + let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect(); + match self.layout { + BrowserLayout::Grid => { + let (w, h) = (self.width.max(1), self.height.max(1)); + let at = (0..w * h) + .map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT)) + .collect(); + ((w as f32 * SLOT, h as f32 * SLOT), at) + } + // The input over the flame. + BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]), + BrowserLayout::Single => ((SLOT, SLOT), row(1)), + BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)), + BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)), + } + } + /// The height of its slots, arrow and button, `room` wide. + fn block(&self, room: f32) -> f32 { + let h = self.inputs().0.1; + let (_, _, action) = self.places(room); + match self.action { + Some(_) => h.max(action.1 + 12.0), + None => h, + } + } + fn height(&self, room: f32) -> f32 { + let note = if self.note.is_empty() { 0.0 } else { 11.0 }; + self.block(room) + note + GAP + } + /// Every slot's place from the recipe's corner (the result last), the + /// arrow's and the action button's: beside the result, or under it + /// when the panel is `room` wide and that doesn't fit. + fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) { + let ((w, h), mut at) = self.inputs(); + // The mod's slots beyond the layout's are not shown. + at.truncate(self.slots.len() - 1); + let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor()); + let result = (w + 32.0, ((h - SLOT) / 2.0).floor()); + let action = if result.0 + SLOT + 16.0 <= room { + (result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor()) + } else { + (result.0 + 3.0, result.1 + SLOT + 2.0) + }; + while at.len() < self.slots.len() - 1 { + at.push((f32::NAN, f32::NAN)); + } + at.push(result); + (at, arrow, action) + } +} + +/// The tabs' places, in rows under the title row. +fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> { + let per_row = ((layout.width() / TAB).floor() as usize).max(1); + (0..pages.tabs.len()) + .map(|i| { + ( + layout.grid.0 + (i % per_row) as f32 * TAB, + layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB, + ) + }) + .collect() +} +/// Where the recipes start: under the title row and the tabs. +fn recipes_top(pages: &Pages, layout: &Layout) -> f32 { + let tabs = tab_places(pages, layout); + tabs.last() + .map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0) +} + +impl Game<'_> { + /// The first recipe of each page, filling the panel from the tabs to + /// the search box. + fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec { + let room = layout.search[1] - 4.0 - recipes_top(pages, layout); + let mut starts = vec![0]; + let mut used = 0.0; + for (i, r) in pages.recipes.iter().enumerate() { + let h = r.height(layout.width()); + if used > 0.0 && used + h > room { + starts.push(i); + used = 0.0; + } + used += h; + } + starts + } + /// The recipes on the open page, each with its corner. + fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> { + let Some(pages) = &self.mods.browser.recipes else { + return Vec::new(); + }; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len()); + let mut y = recipes_top(pages, layout); + (starts[page]..end) + .map(|i| { + let at = (layout.grid.0, y); + y += pages.recipes[i].height(layout.width()); + (i, at) + }) + .collect() + } + fn back_button(&self, layout: &Layout) -> ([f32; 4], String) { + let label = self.translate("gui.back", "Back"); + let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0; + ([layout.grid.0, layout.grid.1, w, 14.0], label) + } + /// What a slot of a shown recipe holds now: its stacks cycle once a second. + fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let stacks = pages.recipes.get(recipe)?.slots.get(slot)?; + let n = pages.since.elapsed().as_secs() as usize; + stacks.get(n % stacks.len().max(1)) + } + /// The tab under the pointer: its index and icon. + pub(super) fn recipe_tab_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + let places = tab_places(pages, layout); + places.iter().enumerate().find_map(|(i, &(x, y))| { + if !inside(self.cursor, [x, y, SLOT, SLOT]) { + return None; + } + let (sent, stack) = pages.tabs.get(i)?; + Some((i, sent.clone(), stack.clone()?)) + }) + } + /// The recipe slot under the pointer: recipe, slot, and its stack now. + pub(super) fn recipe_slot_hovered( + &self, + layout: &Layout, + ) -> Option<(usize, usize, BrowserStack, ItemStack)> { + let pages = self.mods.browser.recipes.as_ref()?; + self.recipes_shown(layout) + .into_iter() + .find_map(|(i, (x, y))| { + let (at, _, _) = pages.recipes[i].places(layout.width()); + at.iter().enumerate().find_map(|(k, &(sx, sy))| { + if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) { + return None; + } + let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone(); + Some((i, k, mod_stack, stack)) + }) + }) + } + + /// Draws the title row, the page's recipes and the page arrows. + pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let (title, pages_n) = (pages.title.clone(), starts.len()); + let (back, label) = self.back_button(layout); + let atlas = &self.gui.atlas; + let hover = inside(cursor, back); + let sprite = if hover { + "gui/sprites/widget/button_highlighted" + } else { + "gui/sprites/widget/button" + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + back[0], + back[1], + back[2], + back[3], + u32::MAX, + ); + batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true); + horizon_client::widgets::record(&label, back, true); + let room = layout.width() - back[2] - 4.0; + // Cut to the panel with vanilla's ellipsis (CommonComponents.ELLIPSIS). + let shown = if atlas.font.fit(&title, room) >= title.chars().count() { + title + } else { + let fit = atlas.font.fit(&title, room - atlas.font.width("...")); + let kept: String = title.chars().take(fit).collect(); + format!("{}...", kept.trim_end()) + }; + batch.text( + atlas, + &shown, + back[0] + back[2] + 4.0, + back[1] + 3.0, + u32::MAX, + true, + ); + self.draw_recipe_tabs(batch, layout); + if pages_n > 1 { + let labels = [ + self.translate("gui.recipebook.previous_page", "Previous Page"), + self.translate("gui.recipebook.next_page", "Next Page"), + ]; + let visible = [page > 0, page + 1 < pages_n]; + let atlas = &self.gui.atlas; + for (((x, y), name), (label, visible)) in layout + .arrows() + .into_iter() + .zip(["page_backward", "page_forward"]) + .zip(labels.iter().zip(visible)) + { + if !visible { + continue; + } + let suffix = if inside(cursor, [x, y, 12.0, 17.0]) { + "_highlighted" + } else { + "" + }; + batch.sprite( + atlas, + &format!("gui/sprites/recipe_book/{name}{suffix}"), + x, + y, + ); + horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true); + } + let text = format!("{}/{pages_n}", page + 1); + let w = HudBatch::text_width(atlas, &text); + let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor(); + batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true); + } + for (i, (x, y)) in self.recipes_shown(layout) { + let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else { + break; + }; + let (at, arrow, action) = r.places(layout.width()); + let h = r.block(layout.width()); + let (cooking, note, button) = + (r.layout == BrowserLayout::Cooking, r.note.clone(), r.action); + let atlas = &self.gui.atlas; + for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) { + batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy); + } + if cooking { + batch.sprite( + atlas, + "gui/sprites/container/furnace/lit_progress", + x + 2.0, + y + SLOT + 2.0, + ); + } + batch.sprite( + atlas, + "gui/sprites/container/furnace/burn_progress", + x + arrow.0, + y + arrow.1, + ); + if let Some(enabled) = button { + let rect = [x + action.0, y + action.1, 12.0, 12.0]; + let sprite = match (enabled, inside(cursor, rect)) { + (false, _) => "gui/sprites/widget/button_disabled", + (true, true) => "gui/sprites/widget/button_highlighted", + (true, false) => "gui/sprites/widget/button", + }; + horizon_client::ui::nine_slice( + batch, + atlas, + sprite, + rect[0], + rect[1], + 12.0, + 12.0, + u32::MAX, + ); + let color = if enabled { + u32::MAX + } else { + rgba(160, 160, 160, 255) + }; + batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true); + horizon_client::widgets::record(&format!("+ {i}"), rect, enabled); + } + if !note.is_empty() { + batch.text(atlas, ¬e, x, y + h + 2.0, rgba(224, 224, 224, 255), true); + } + for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) { + let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else { + continue; + }; + self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0); + if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + sx + 1.0, + y + sy + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} {i}:{k}"), + [x + sx + 1.0, y + sy + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Button, + ); + } + } + } + + /// The tabs, on slots; the selected one framed. + fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) { + let Some(pages) = &self.mods.browser.recipes else { + return; + }; + let places = tab_places(pages, layout); + let tabs: Vec> = pages.tabs.iter().map(|t| t.1.clone()).collect(); + let selected = pages.tab; + for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() { + let atlas = &self.gui.atlas; + batch.sprite(atlas, "gui/sprites/container/slot", x, y); + let Some(stack) = stack else { + continue; + }; + self.draw_item(batch, &stack, x + 1.0, y + 1.0); + // The selected tab: a white frame around its slot. + if i == selected { + let white = rgba(255, 255, 255, 255); + let atlas = &self.gui.atlas; + batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white); + batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white); + batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white); + } + if inside(self.cursor, [x, y, SLOT, SLOT]) { + batch.fill( + &self.gui.atlas, + x + 1.0, + y + 1.0, + 16.0, + 16.0, + rgba(255, 255, 255, 128), + ); + } + let name = self.pack.names.display_item(&stack.item); + horizon_client::widgets::record_as( + &format!("{name} tab"), + [x + 1.0, y + 1.0, 16.0, 16.0], + true, + horizon_client::widgets::Kind::Tab(i == selected), + ); + } + } + + /// A press on the recipes: true when it hit one of their parts. + pub(super) fn browser_recipes_press( + &mut self, + layout: &Layout, + owner: usize, + right: bool, + ) -> bool { + let Some(pages) = &self.mods.browser.recipes else { + return false; + }; + let cursor = self.cursor; + let starts = Self::recipe_pages(pages, layout); + let page = pages.page.min(starts.len() - 1); + let [back_arrow, forward] = layout.arrows(); + let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) { + Some(page - 1) + } else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) { + Some(page + 1) + } else { + None + }; + if let Some(to) = turn { + if let Some(p) = self.mods.browser.recipes.as_mut() { + p.page = to; + } + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Page(starts[to] as u32)); + return true; + } + if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Tab(tab as u32)); + return true; + } + if inside(cursor, self.back_button(layout).0) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods.mods.browser_event(owner, BrowserEvent::Back); + return true; + } + if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) { + let event = BrowserEvent::SlotClicked(BrowserSlotClick { + recipe: recipe as u32, + slot: slot as u32, + stack, + right, + }); + self.mods.mods.browser_event(owner, event); + return true; + } + let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| { + let r = &pages.recipes[i]; + let (_, _, (ax, ay)) = r.places(layout.width()); + r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0]) + }); + if let Some((i, _)) = action { + if pages.recipes[i].action == Some(true) { + self.audio + .play("minecraft:ui.button.click", None, 0.25, 1.0); + self.mods + .mods + .browser_event(owner, BrowserEvent::Action(i as u32)); + } + return true; + } + false + } +} diff --git a/crates/horizon-client/src/game/mod_input.rs b/crates/horizon-client/src/game/mod_input.rs index 10faf6a81..3769b03c9 100644 --- a/crates/horizon-client/src/game/mod_input.rs +++ b/crates/horizon-client/src/game/mod_input.rs @@ -31,6 +31,9 @@ impl Game<'_> { pub(super) fn mod_minimap(&self) -> Option { None } + pub(super) fn mod_far_terrain(&self) -> Option { + None + } } /// A mod's zoom (`render.set-zoom`), clamped by the runtime. @@ -51,6 +54,13 @@ pub(super) struct ModMinimap { pub(super) round: bool, } +/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime. +#[derive(Clone, Copy)] +pub(super) struct ModFarTerrain { + pub(super) distance: u32, + pub(super) reduced: bool, +} + #[cfg(feature = "mods")] impl Game<'_> { /// The main hand's stack as `input` shows it; none when it is empty. @@ -111,6 +121,18 @@ impl Game<'_> { if repeat || !self.mods.mods.has_keys() { return; } + // Over an item on a container screen, the mods' keys are `browser` + // events (a recipe viewer's R and U). + if pressed + && self.screen == Screen::Inventory + && self.title.is_none() + && !self.mod_browser_typing() + && let Some(name) = horizon_client::keybinds::key_name(key) + && let Some(stack) = self.mod_browser_pointed() + { + self.mods.mods.browser_key(name, &stack); + return; + } if pressed && (self.screen != Screen::Playing || self.title.is_some()) { return; } @@ -178,4 +200,12 @@ impl Game<'_> { round: m.round, }) } + /// The far terrain the first mod drawing one asks for. + pub(super) fn mod_far_terrain(&self) -> Option { + let f = self.mods.mods.far_terrain()?; + Some(ModFarTerrain { + distance: f.distance, + reduced: f.reduced, + }) + } } diff --git a/crates/horizon-client/src/game/mod_recipes.rs b/crates/horizon-client/src/game/mod_recipes.rs new file mode 100644 index 000000000..b97944e39 --- /dev/null +++ b/crates/horizon-client/src/game/mod_recipes.rs @@ -0,0 +1,274 @@ +//! Capability `recipes` (docs/mods.md): every item and recipe the game +//! knows, made once for the mods that read them, and the recipe book's +//! known displays. The recipes are the window's own data: the world's in +//! single player, vanilla's on a server (a client is sent no others), while +//! the known displays follow the server's book as the recipe book does. +use super::Game; +use horizon_client::{ + crafting::Ingredient, + items::ItemStack, + recipe_book::{Category, Display}, + stations::Smithing, +}; +use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes}; +use std::{collections::HashMap, sync::Arc}; + +/// Item id -> its index in [`Recipes::items`]. +struct Index(HashMap, u32>); +impl Index { + fn stack(&self, item: &str, count: u32, components: String) -> Option { + Some(RecipeStack { + item: *self.0.get(item)?, + count, + components, + }) + } + fn of(&self, s: &ItemStack) -> Option { + self.stack(&s.item, s.count, components(s)) + } + fn any(&self, i: &Ingredient) -> Vec { + i.1.iter() + .filter_map(|id| self.stack(id, 1, String::new())) + .collect() + } +} + +/// A stack's components beyond its item's, as vanilla's item syntax writes +/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "". +pub(super) fn components(s: &ItemStack) -> String { + use horizon_world::nbt::Tag; + let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else { + return String::new(); + }; + let parts: Vec = entries + .iter() + .map(|(k, v)| { + format!( + "{}={}", + k.to_string_lossy(), + horizon_client::snbt::to_snbt(v) + ) + }) + .collect(); + format!("[{}]", parts.join(",")) +} + +fn recipe(id: String, kind: RecipeKind) -> Recipe { + Recipe { + id, + kind, + category: String::new(), + group: String::new(), + width: 0, + height: 0, + slots: Vec::new(), + results: Vec::new(), + cooking_time: 0, + experience: 0.0, + book_id: -1, + } +} + +impl Game<'_> { + /// `recipes.give`: a creative player takes the stack as from the + /// creative inventory; a server hears it as `SetCreativeModeSlot` + /// through the inventory's sync, and refuses it unless creative. + pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) { + let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else { + return; + }; + if !self.creative { + return; + } + let (damage, extra) = horizon_client::item_data::command_components(components) + .map_or((0, None), |tag| { + horizon_client::item_data::decode_components(Some(&tag)) + }); + let _ = self.give(ItemStack { + item: item.into(), + // One stack at most, as the creative inventory hands out. + count: count.min(max), + damage, + extra, + }); + } + /// Capability `recipes`'s tables and known displays for this tick's + /// snapshot; nothing while no running mod reads them. + pub(super) fn mod_recipes(&mut self) -> (Option>, Arc<[i32]>) { + if !self.mods.mods.wants_recipes() { + return (None, Arc::from([])); + } + if self.mods.recipes.is_none() { + self.mods.recipes = Some(Arc::new(self.make_mod_recipes())); + } + let (version, known) = self.recipe_book_displays(); + if self.mods.known_recipes.0 != Some(version) { + self.mods.known_recipes = (Some(version), known.into()); + } + (self.mods.recipes.clone(), self.mods.known_recipes.1.clone()) + } + + fn make_mod_recipes(&mut self) -> Recipes { + let names = &self.pack.names; + let items = self.res.items(); + let mut tags: HashMap<&str, Vec> = HashMap::new(); + for (tag, members) in &items.item_tags { + for m in members { + tags.entry(m).or_default().push(tag.clone()); + } + } + let table: Vec = items + .registry_order + .iter() + .map(|id| RecipeItem { + id: id.to_string(), + name: names.display_item(id), + tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| { + t.sort(); + t + }), + }) + .collect(); + let index = Index( + items + .registry_order + .iter() + .enumerate() + .map(|(i, id)| (id.clone(), i as u32)) + .collect(), + ); + let mut out = Vec::new(); + let cooking: HashMap = self + .res + .recipes() + .cooking + .iter() + .map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience))) + .collect(); + for e in self.recipe_book_entries() { + let furnace = |c: Category| match c.name().split('_').next() { + Some("blast") => RecipeKind::Blasting, + Some("smoker") => RecipeKind::Smoking, + _ => RecipeKind::Smelting, + }; + let kind = match &e.display { + Display::Shaped { .. } => RecipeKind::CraftingShaped, + Display::Shapeless(_) => RecipeKind::CraftingShapeless, + Display::Furnace(_) => furnace(e.category), + }; + let mut r = recipe(e.id.clone(), kind); + r.category = e.category.name().to_owned(); + r.group = e.group.clone().unwrap_or_default(); + r.book_id = e.display_id; + r.slots = match &e.display { + Display::Shaped { + width, + height, + cells, + } => { + (r.width, r.height) = (*width as u32, *height as u32); + cells + .iter() + .map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i))) + .collect() + } + Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(), + Display::Furnace(i) => vec![index.any(i)], + }; + // Requirements shown as other stacks (an arrow's potions): the + // requirement's index counts the filled slots only. + let filled: Vec = (0..r.slots.len()) + .filter( + |&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()), + ) + .collect(); + for (req, stacks) in &e.stacks { + if let Some(&k) = filled.get(*req) { + r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect(); + } + } + r.results = e.results().iter().filter_map(|s| index.of(s)).collect(); + if let Some(&(time, xp)) = cooking.get(&e.id) { + (r.cooking_time, r.experience) = (time, xp); + } + out.push(r); + } + for c in &self.res.recipes().cooking { + if c.kind == "minecraft:campfire_cooking" { + let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire); + r.group = c.group.clone(); + r.slots = vec![index.any(&c.ingredient)]; + r.results = index.of(&c.result).into_iter().collect(); + (r.cooking_time, r.experience) = (c.time, c.experience); + out.push(r); + } + } + let stations = self.res.stations(); + for s in &stations.recipes.stonecutting { + let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting); + r.slots = vec![index.any(&s.input)]; + r.results = index.of(&s.result).into_iter().collect(); + out.push(r); + } + for s in &stations.recipes.smithing { + let (id, template, base, addition, results) = match s { + Smithing::Transform { + id, + template, + base, + addition, + result, + } => ( + id, + template, + base, + addition, + index.stack(result, 1, String::new()).into_iter().collect(), + ), + Smithing::Trim { + id, + template, + base, + addition, + .. + } => (id, template, base, addition, index.any(base)), + }; + let mut r = recipe(id.clone(), RecipeKind::Smithing); + r.slots = vec![index.any(template), index.any(base), index.any(addition)]; + r.results = results; + out.push(r); + } + for b in &stations.brewing.recipes { + let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec { + let mut item_ids: Vec<&String> = p.items.iter().collect(); + item_ids.sort(); + let mut potions: Vec<&String> = p.potions.iter().flatten().collect(); + potions.sort(); + item_ids + .into_iter() + .flat_map(|item| { + let one = |c: String| index.stack(item, 1, c); + if potions.is_empty() { + one(String::new()).into_iter().collect::>() + } else { + potions + .iter() + .filter_map(|p| { + one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]")) + }) + .collect() + } + }) + .collect() + }; + let mut r = recipe(b.id.clone(), RecipeKind::Brewing); + r.slots = vec![potion(&b.input), potion(&b.reagent)]; + r.results = index.of(&b.output).into_iter().collect(); + out.push(r); + } + Recipes { + items: table, + recipes: out, + } + } +} diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index d6e38dbbf..52dc46fe0 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -63,6 +63,14 @@ pub(super) struct ModsState { /// What players did this tick, for server mods at its end. #[cfg(feature = "mods")] pub(super) events: Vec, + /// A running server mod has capability `world`: its calls get the + /// levels' chunks to read. + #[cfg(feature = "mods")] + pub(super) world: bool, + /// Blocks server mods set, by dimension, made before that level's next + /// tick. + #[cfg(feature = "mods")] + pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>, /// `command.run` lines for this window's own world, run on the next tick: /// run at once, their chat lines would reach the mods while their output /// is still being shown, and a mod answering its own lines would recurse. @@ -81,6 +89,15 @@ pub(super) struct ModsState { /// The HUD font's advances for `render.text-width`, made once. #[cfg(feature = "mods")] font_widths: std::sync::Arc<[f32]>, + /// Capability `recipes`'s tables, made once a mod reads them. + #[cfg(feature = "mods")] + pub(super) recipes: Option>, + /// The recipe book's known displays, and the book's version they are of. + #[cfg(feature = "mods")] + pub(super) known_recipes: (Option, std::sync::Arc<[i32]>), + /// Capability `ui`'s item panel beside container screens. + #[cfg(feature = "mods")] + pub(super) browser: super::mod_browser::Browser, /// Extras that are official mods now, being installed. #[cfg(feature = "mods")] pub(super) moved: super::moved_extras::MovedState, @@ -293,6 +310,8 @@ impl Game<'_> { self.mods.mods.set_bindings(|id| chosen.keys_of(id)); // Entries were replaced wholesale: a screen's owner index is stale. self.mods.screen = None; + // Content mods may have changed the items: remade on the next read. + self.mods.recipes = None; self.list_mods(); self.sync_mod_commands(); self.show_mod_output(); @@ -309,6 +328,7 @@ impl Game<'_> { /// The Mods page's rows, from what loaded. fn list_mods(&mut self) { self.mods.server = self.mods.mods.runs_server(); + self.mods.world = self.mods.mods.wants_world(); let dev = &self.mods.dev; self.settings.mods.installed = self .mods @@ -351,7 +371,10 @@ impl Game<'_> { } /// The chat's tree after the running mods changed. fn sync_mod_commands(&mut self) { - let mut names = self.mods.mods.commands().peekable(); + // In-frame, the window runs server mods too: their names, free text + // here (the server parses their arguments). + let server = self.mods.mods.server_commands().map(|c| c.name.as_str()); + let mut names = self.mods.mods.commands().chain(server).peekable(); self.mods.tree = names .peek() .is_some() @@ -440,6 +463,7 @@ impl Game<'_> { .map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1)) .collect(); } + let (recipes, known_recipes) = self.mod_recipes(); Snapshot { player, clock, @@ -451,6 +475,10 @@ impl Game<'_> { font_widths: self.mods.font_widths.clone(), players, view_hit: self.mod_view_hit(), + recipes, + known_recipes, + placing: self.mod_recipe_screen(), + creative: self.creative, } } /// The first block along the player's view within @@ -673,8 +701,10 @@ impl Game<'_> { } Text::literal(line).with_color(text::RED) } - server - @ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => { + server @ (Output::Tell { .. } + | Output::Broadcast { .. } + | Output::Reply { .. } + | Output::SetBlocks { .. }) => { self.route_server_output(host.as_deref_mut(), server); continue; } @@ -686,6 +716,18 @@ impl Game<'_> { self.mods.shake.add(strength, seconds); continue; } + Output::PlaceRecipe { book_id, all } => { + self.mod_place_recipe(book_id, all); + continue; + } + Output::Give { + item, + count, + components, + } => { + self.mod_give(&item, count, &components); + continue; + } Output::CloseScreen { owner } => { if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) { self.close_mod_screen(false); diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index fa4bbc209..617d86aaa 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -126,6 +126,19 @@ impl Game<'_> { if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding { return false; } + // AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no + // seat, inventory or feeding to give. Camels seat two and check their + // passengers themselves. + let id = m.id; + if !matches!(species, Species::Camel | Species::CamelHusk) + && self + .mobs + .iter() + .any(|p| p.extra.mount == Some(id) && !p.removed && p.alive()) + { + return false; + } + let m = &self.mobs[index]; // AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory. if !m.is_baby() && m.extra.tame @@ -215,10 +228,12 @@ impl Game<'_> { return false; } let mut used = false; + let mut loved = false; if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 { m.extra.in_love = 600; m.extra.love_player = true; used = true; + loved = true; } if m.health < m.attrs.max_health && heal > 0.0 { m.health = (m.health + heal).min(m.attrs.max_health); @@ -238,6 +253,7 @@ impl Game<'_> { // eating(): the mouth opens and the horse eats. m.anim.v[2] = 1.0; let at = m.center(); + let id = m.id; let sound = match m.species() { Species::Camel => "minecraft:entity.camel.eat", Species::CamelHusk => "minecraft:entity.camel_husk.eat", @@ -247,6 +263,10 @@ impl Game<'_> { }; let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0; self.audio.play(sound, Some(at), 1.0, pitch); + // Animal.setInLove: entity event 18. + if loved { + self.mob_event_particles(id, 18); + } self.consume_held(); true } @@ -452,14 +472,16 @@ impl Game<'_> { m.extra.owner = owner; m.persistent = true; // AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true). - self.particles_around_mob( - mob, - "heart", - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(mob, 7); } } - MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(), + MobEvent::Buck { mob } => { + if self.riding == Some(mob) { + self.dismount_mount(); + } + // RunAroundLikeCrazyGoal.tick: entity event 6, smoke. + self.mob_event_particles(mob, 6); + } _ => {} } } diff --git a/crates/horizon-client/src/game/moving_pistons.rs b/crates/horizon-client/src/game/moving_pistons.rs index 17247850e..e1bdf89d0 100644 --- a/crates/horizon-client/src/game/moving_pistons.rs +++ b/crates/horizon-client/src/game/moving_pistons.rs @@ -14,7 +14,9 @@ impl Game<'_> { out: &mut Vec, ) { let partial = alpha as f32; - for &(p, piston) in &self.world.moving_pistons { + let mut states = self.world.moving_pistons.clone(); + states.extend(self.world.moving_piston_fallback.iter().copied()); + for (p, piston) in states { let Some((block, base)) = self.moving_piston_blocks(piston, partial) else { continue; }; diff --git a/crates/horizon-client/src/game/music.rs b/crates/horizon-client/src/game/music.rs index 6f8b5e7ae..97ca3763e 100644 --- a/crates/horizon-client/src/game/music.rs +++ b/crates/horizon-client/src/game/music.rs @@ -3,7 +3,7 @@ use super::Game; use horizon_client::{ blocks::Fluid, - music::{BackgroundMusic, Music, Situation}, + music::{BackgroundMusic, Music, MusicFrequency, Situation}, sound_engine::TickContext, }; @@ -11,6 +11,11 @@ impl Game<'_> { /// `Minecraft.tick` -> `MusicManager.tick`, once per client tick. pub(super) fn tick_music(&mut self) { let (music, volume) = self.situational_music(); + // Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs. + let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency")); + if frequency != self.music.frequency { + self.music.set_frequency(frequency, music.as_ref()); + } // LevelLoadingScreen: no new track before the player is in the world. let loading = self.title.is_none() && self.player.is_none() && self.win.is_none(); self.music @@ -91,7 +96,7 @@ impl Game<'_> { }) } /// `isEyeInFluid(WATER)`: the eye inside the water of its block column. - fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { + pub(super) fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool { let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32); let f = self.res.facts().get(self.world.block(bx, by, bz)); if f.fluid != Fluid::Water { diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 9f7561e53..3ecd2d245 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -338,7 +338,9 @@ impl Game<'_> { let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025); // Text space: x right, y down (scale 0.025, -0.025). let at = |x: f32, y: f32| o + r * x - u * y; - let chars: Vec<(char, u32)> = text + // Component.getVisualOrderText. + let shown = horizon_client::bidi::visual(text); + let chars: Vec<(char, u32)> = shown .spans .iter() .flat_map(|s| { @@ -377,6 +379,7 @@ impl Game<'_> { layer, color, light, + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 0035f5082..5afa72416 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -504,7 +504,8 @@ impl<'a> Game<'a> { m.position.z.floor() as i32, ], is_mob: true, - persistent: m.persistent, + persistent: m.persistent + || horizon_client::mobs::ai::requires_custom_persistence(m), }) .collect(); let players = players diff --git a/crates/horizon-client/src/game/nautili.rs b/crates/horizon-client/src/game/nautili.rs index e7f16ca18..5e384db98 100644 --- a/crates/horizon-client/src/game/nautili.rs +++ b/crates/horizon-client/src/game/nautili.rs @@ -85,13 +85,8 @@ impl Game<'_> { m.ai.target = None; } let sound = nautilus::eat_sound(m); - let kind = if tamed { "heart" } else { "smoke" }; // TamableAnimal.spawnTamingParticles (entity event 7 or 6). - self.particles_around_mob( - self.mobs[index].id, - kind, - horizon_client::particles::Burst::TAMING, - ); + self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 }); self.nautilus_sound(index, sound); if tamed { let view = self.mob_entity(&self.mobs[index]); @@ -145,6 +140,8 @@ impl Game<'_> { let sound = nautilus::eat_sound(m); self.use_nautilus_food(item); self.nautilus_sound(index, sound); + // Animal.setInLove: entity event 18. + self.mob_event_particles(self.mobs[index].id, 18); return true; } false diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index 87ba81c93..c8142284a 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -2,7 +2,9 @@ //! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`, //! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination` //! (`PortalForcer` through `horizon_world::level::portal_travel`), and -//! `LocalPlayer.handlePortalTransitionEffect`. +//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through +//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`); +//! the player's End portals are game/end.rs's. //! //! Only one dimension is loaded at a time. The player's destination is found //! once the other dimension has loaded around it. An item or mob that goes @@ -12,7 +14,7 @@ use super::{Dim, Game, Screen}; use horizon_client::Result; use horizon_client::nether_portal::PortalState; use horizon_core::{ - geometry::{Axis, Vec3}, + geometry::{Aabb, Axis, Vec3}, position::BlockPos, random::RandomSource, }; @@ -40,6 +42,32 @@ pub(super) struct Plan { offset: Vec3, width: f32, height: f32, + /// Through an End portal: `approximate` is the End's spawn point, or the + /// Overworld's shared spawn. + end: bool, +} + +impl Plan { + /// `EndPortalBlock.getPortalDestination` for an entity other than a + /// player in `from`: the End's spawn point, or out of the End the + /// Overworld's shared spawn `world_spawn`. + fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self { + let (dim, approximate) = if from == Dim::End { + (Dim::Overworld, world_spawn) + } else { + let [x, y, z] = super::end::END_SPAWN; + (Dim::End, BlockPos::new(x, y, z)) + }; + Self { + dim, + approximate, + entry_x_axis: true, + offset: Vec3::ZERO, + width, + height, + end: true, + } + } } /// An entity on its way to a dimension that isn't loaded. @@ -81,6 +109,89 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 { fn doubles(values: [f64; 3]) -> Tag { Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect()) } +/// Whether a `width` by `height` box moving from `from` to `to` meets the End +/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its +/// entity-inside shape, y 6/16..12/16 of the block. +fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool { + let half = f64::from(width) / 2.0; + let start = Aabb::new( + Vec3::new(from.x - half, from.y, from.z - half), + Vec3::new(from.x + half, from.y + f64::from(height), from.z + half), + ); + let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z)); + let shape = Aabb::new( + Vec3::new(x, y + 0.375, z), + Vec3::new(x + 1.0, y + 0.75, z + 1.0), + ); + start.collided_along_vector(to.subtract(from), &[shape]) +} +/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition +/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps +/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's +/// angles (`Relative.ROTATION`). The pitch stays within ±90. +fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] { + let (yaw, pitch) = if into_end { + // Direction.WEST.toYRot(). + (90.0, pitch) + } else { + (yaw + spawn[0], pitch + spawn[1]) + }; + [yaw, pitch.clamp(-90.0, 90.0)] +} +/// A waiting entity as `horizon_portal_arrivals.dat` keeps it. +fn arrival_tag(a: &Arrival) -> Tag { + let p = &a.plan; + Tag::compound([ + ("dimension", Tag::String(p.dim.id().into())), + ("x", Tag::Int(p.approximate.x)), + ("y", Tag::Int(p.approximate.y)), + ("z", Tag::Int(p.approximate.z)), + ("x_axis", Tag::Int(i32::from(p.entry_x_axis))), + ("right", Tag::Double(p.offset.x.to_bits())), + ("up", Tag::Double(p.offset.y.to_bits())), + ("forward", Tag::Double(p.offset.z.to_bits())), + ("width", Tag::Float(p.width.to_bits())), + ("height", Tag::Float(p.height.to_bits())), + ("end", Tag::Int(i32::from(p.end))), + ("entity", a.tag.clone()), + ]) +} +/// The waiting entity `arrival_tag` wrote. +fn arrival_from_tag(a: &Tag) -> Option { + let int = |k: &str| match a.get(k) { + Some(Tag::Int(v)) => Some(*v), + _ => None, + }; + let float = |k: &str| match a.get(k) { + Some(Tag::Float(v)) => Some(f32::from_bits(*v)), + _ => None, + }; + let double = |k: &str| match a.get(k) { + Some(Tag::Double(v)) => Some(f64::from_bits(*v)), + _ => None, + }; + let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?); + let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity")) + else { + return None; + }; + Some(Arrival { + plan: Plan { + dim, + approximate: BlockPos::new(x, y, z), + entry_x_axis: int("x_axis").unwrap_or(1) != 0, + offset: Vec3::new( + double("right").unwrap_or(0.5), + double("up").unwrap_or(0.0), + double("forward").unwrap_or(0.0), + ), + width: float("width").unwrap_or(0.25), + height: float("height").unwrap_or(0.25), + end: int("end").unwrap_or(0) != 0, + }, + tag: tag.clone(), + }) +} impl Game<'_> { /// The world border's extent. @@ -146,8 +257,54 @@ impl Game<'_> { offset, width, height, + end: false, } } + /// The plan of an item or mob going through the portal block at `entry`: + /// an End portal's fixed destination, else the Nether portal's. + fn entity_plan( + &self, + entry: BlockPos, + at: Vec3, + width: f32, + height: f32, + reset_forward: bool, + ) -> Plan { + if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) { + return self.portal_plan(entry, at, width, height, reset_forward); + } + let spawn = self.level.world_spawn.unwrap_or_else(|| { + self.spawn.map_or([0, 64, 0], |s| { + [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32] + }) + }); + Plan::end_portal( + self.dim, + BlockPos::new(spawn[0], spawn[1], spawn[2]), + width, + height, + ) + } + fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool { + &*self.res.facts().get(state).name == "minecraft:end_portal" + } + /// Where an item or mob through an End portal lands once `plan`'s level + /// has loaded around it: the bottom centre of the End's spawn point, + /// above the platform built below it (`EndPlatformFeature.createEndPlatform`); + /// out of the End, the top of the shared spawn's column + /// (`Entity.adjustSpawnLocation`). + fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 { + let p = plan.approximate; + let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5); + if plan.dim == Dim::End { + if let Err(e) = self.end_arrival(centre) { + log::error!("End platform: {e}"); + } + return centre; + } + let y = self.motion_top(p.x, p.z); + Vec3::new(centre.x, f64::from(y), centre.z) + } /// The nether portal blocks of the chunks within `radius` of `center` /// (the points of interest `PortalForcer` searches): loaded chunks as /// they are, the others as saved; chunks never generated have none. @@ -520,9 +677,10 @@ impl Game<'_> { || self.ridden_by_guest(id) || self.mobs.iter().any(|m| m.extra.mount == Some(id)) } - /// `NetherPortalBlock.entityInside` for the items, mobs and empty boats - /// and minecarts that moved this tick: the portal blocks their box swept - /// through. + /// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside` + /// for the items, mobs and empty boats and minecarts that moved this + /// tick: the portal blocks their box swept through (an End portal's + /// transition time is 0 too). pub(super) fn find_entity_portals(&mut self) { if self.remote.is_some() { return; @@ -570,7 +728,7 @@ impl Game<'_> { .set_inside(pos, ENTITY_DELAY); } } - /// The first nether portal block an entity box swept through + /// The first nether or End portal block an entity box swept through /// (`Entity.checkInsideBlocks`, whose first portal sets the entry). fn swept_portal( &self, @@ -604,11 +762,9 @@ impl Game<'_> { bounds, 1_000_000, |p, _| { - if self - .res - .behaviours() - .portal_axis(self.world.block(p.x, p.y, p.z)) - .is_some() + let state = self.world.block(p.x, p.y, p.z); + if self.res.behaviours().portal_axis(state).is_some() + || (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p)) { found = Some(p); return false; @@ -618,8 +774,8 @@ impl Game<'_> { ); found } - /// Whether a section `reach` touches has a nether portal state in its - /// palette: without one, no block there is a portal. Palette answers are + /// Whether a section `reach` touches has a nether or End portal state in + /// its palette: without one, no block there is a portal. Palette answers are /// kept per chunk across ticks (`portal_sections`); a box over many /// sections is not checked. fn may_hold_portal( @@ -656,9 +812,8 @@ impl Game<'_> { (z0..=z1).any(|cz| { chunks.get(&(cx, cz)).is_some_and(|chunk| { sections.get((cx, cz), chunk, |v| { - behaviours - .portal_axis(horizon_world::registry::BlockStateId(v)) - .is_some() + let state = horizon_world::registry::BlockStateId(v); + behaviours.portal_axis(state).is_some() || self.is_end_portal(state) }) & y_mask != 0 }) @@ -670,7 +825,7 @@ impl Game<'_> { fn send_through_portal(&mut self, id: u32, entry: BlockPos) { if let Some(i) = self.dropped.iter().position(|e| e.id == id) { let e = &self.dropped[i]; - let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false); + let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false); if !self.may_enter(plan.dim) { return; } @@ -686,7 +841,7 @@ impl Game<'_> { self.portals.dirty = true; } else if let Some(i) = self.mobs.iter().position(|m| m.id == id) { let m = &self.mobs[i]; - let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true); + let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true); if !self.may_enter(plan.dim) { return; } @@ -716,7 +871,7 @@ impl Game<'_> { } else if let Some(i) = self.boats.iter().position(|b| b.id == id) { let b = &self.boats[i]; let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32); - let plan = self.portal_plan(entry, b.position, width, height, false); + let plan = self.entity_plan(entry, b.position, width, height, false); if !self.may_enter(plan.dim) { return; } @@ -741,7 +896,7 @@ impl Game<'_> { super::minecarts::WIDTH as f32, super::minecarts::HEIGHT as f32, ); - let plan = self.portal_plan(entry, c.position, width, height, false); + let plan = self.entity_plan(entry, c.position, width, height, false); if !self.may_enter(plan.dim) { return; } @@ -754,8 +909,8 @@ impl Game<'_> { } } /// Entities waiting for the loaded dimension whose exit area is loaded: - /// their exit portal (found or built), position, turned yaw and motion, - /// and portal cooldown; they join the world with the chunk entities. + /// their exit portal (found or built) or End destination, position, + /// turned yaw and motion, and portal cooldown; they join the world with the chunk entities. pub(super) fn resolve_arrivals(&mut self) { if self.remote.is_some() { return; @@ -772,21 +927,28 @@ impl Game<'_> { } let Arrival { plan, mut tag } = self.portals.arrivals.remove(i); self.portals.dirty = true; - let Some(exit) = self.nether_exit_portal(&plan) else { - continue; - }; - let (at, rotation) = self.arrival(&plan, exit); - let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation); + let motion = horizon_client::entity_nbt::motion(&tag); let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag); + let (at, motion, [yaw, pitch]) = if plan.end { + // Relative.DELTA: the motion is kept. + let at = self.end_portal_exit(&plan); + let into_end = plan.dim == Dim::End; + let turn = + end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle); + (at, motion, turn) + } else { + let Some(exit) = self.nether_exit_portal(&plan) else { + continue; + }; + let (at, rotation) = self.arrival(&plan, exit); + (at, rotate_y(motion, -rotation), [yaw + rotation, pitch]) + }; if let Tag::Compound(c) = &mut tag { c.insert("Pos".into(), doubles([at.x, at.y, at.z])); c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z])); c.insert( "Rotation".into(), - Tag::List(vec![ - Tag::Float((yaw + rotation).to_bits()), - Tag::Float(pitch.to_bits()), - ]), + Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]), ); // setPortalCooldown before the teleport, copied by restoreFrom. c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY)); @@ -823,75 +985,117 @@ impl Game<'_> { let Some(Tag::List(list)) = root.get("arrivals") else { return; }; - for a in list { - let int = |k: &str| match a.get(k) { - Some(Tag::Int(v)) => Some(*v), - _ => None, - }; - let float = |k: &str| match a.get(k) { - Some(Tag::Float(v)) => Some(f32::from_bits(*v)), - _ => None, - }; - let double = |k: &str| match a.get(k) { - Some(Tag::Double(v)) => Some(f64::from_bits(*v)), - _ => None, - }; - let dim = match horizon_client::save::text(a.get("dimension")).as_deref() { - Some("minecraft:the_nether") => Dim::Nether, - Some(_) => Dim::Overworld, - None => continue, - }; - let (Some(x), Some(y), Some(z), Some(tag)) = - (int("x"), int("y"), int("z"), a.get("entity")) - else { - continue; - }; - self.portals.arrivals.push(Arrival { - plan: Plan { - dim, - approximate: BlockPos::new(x, y, z), - entry_x_axis: int("x_axis").unwrap_or(1) != 0, - offset: Vec3::new( - double("right").unwrap_or(0.5), - double("up").unwrap_or(0.0), - double("forward").unwrap_or(0.0), - ), - width: float("width").unwrap_or(0.25), - height: float("height").unwrap_or(0.25), - }, - tag: tag.clone(), - }); - } + self.portals + .arrivals + .extend(list.iter().filter_map(arrival_from_tag)); } /// Write the waiting entities when they changed. pub(super) fn save_portal_arrivals(&mut self) { if !self.portals.dirty || self.remote.is_some() { return; } - let list = self - .portals - .arrivals - .iter() - .map(|a| { - let p = &a.plan; - Tag::compound([ - ("dimension", Tag::String(p.dim.id().into())), - ("x", Tag::Int(p.approximate.x)), - ("y", Tag::Int(p.approximate.y)), - ("z", Tag::Int(p.approximate.z)), - ("x_axis", Tag::Int(i32::from(p.entry_x_axis))), - ("right", Tag::Double(p.offset.x.to_bits())), - ("up", Tag::Double(p.offset.y.to_bits())), - ("forward", Tag::Double(p.offset.z.to_bits())), - ("width", Tag::Float(p.width.to_bits())), - ("height", Tag::Float(p.height.to_bits())), - ("entity", a.tag.clone()), - ]) - }) - .collect(); + let list = self.portals.arrivals.iter().map(arrival_tag).collect(); let root = Tag::compound([("arrivals", Tag::List(list))]); if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() { self.portals.dirty = false; } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() { + let spawn = BlockPos::new(-7, 70, 12); + for from in [Dim::Overworld, Dim::Nether] { + let plan = Plan::end_portal(from, spawn, 0.9, 1.4); + assert_eq!(plan.dim, Dim::End); + assert_eq!(plan.approximate, BlockPos::new(100, 50, 0)); + assert!(plan.end); + } + let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25); + assert_eq!(plan.dim, Dim::Overworld); + assert_eq!(plan.approximate, spawn); + } + + #[test] + fn the_end_portal_shape_takes_falling_items_and_standing_mobs() { + let portal = BlockPos::new(0, 100, 3); + let at = |y| Vec3::new(0.5, y, 3.5); + // A dropped item falling through the slab, or in it. + assert!(touches_end_portal( + at(100.6), + at(100.56), + 0.25, + 0.25, + portal + )); + // One tick's fall across the whole slab, in it at neither end. + assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal)); + // Resting below the slab (on the floor) or above it. + assert!(!touches_end_portal( + at(100.0), + at(100.0), + 0.25, + 0.25, + portal + )); + assert!(!touches_end_portal( + at(100.8), + at(100.8), + 0.25, + 0.25, + portal + )); + // A cow standing in the portal block. + assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal)); + // Beside it. + let beside = Vec3::new(2.0, 100.0, 3.5); + assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal)); + } + + #[test] + fn end_portals_turn_entities_like_their_teleport_transition() { + // Into the End: facing west, pitch kept. + assert_eq!( + end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]), + [90.0, 20.0] + ); + // Out of it: turned by the world spawn's yaw and pitch, pitch clamped. + assert_eq!( + end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]), + [15.0, 30.0] + ); + assert_eq!( + end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]), + [0.0, 90.0] + ); + } + + #[test] + fn waiting_arrivals_keep_their_end_destination_when_saved() { + let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]); + for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] { + let arrival = Arrival { + plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4), + tag: tag.clone(), + }; + let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap(); + assert_eq!(back.plan.dim, dim); + assert_eq!(back.plan.approximate, arrival.plan.approximate); + assert!(back.plan.end); + assert_eq!(back.tag, tag); + } + // Arrivals saved before End portals were Nether portal plans. + let mut old = arrival_tag(&Arrival { + plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25), + tag, + }); + if let Tag::Compound(c) = &mut old { + c.remove(&"end".into()); + } + assert!(!arrival_from_tag(&old).unwrap().plan.end); + } +} diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 569c63083..013b94de2 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -1,14 +1,23 @@ //! Resource pack selection bound to the game: the pack screen lists, and a //! full resource reload after a change, while playing. -use super::Game; +use super::{ + Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures, +}; use horizon_client::{ + assets::Assets, + gfx::PreparedTextures, hud::HudAtlas, lang, pack_screen::{self, Compat, PackInfo, Source as PackSource}, packs::{self, Pack, Position}, + streaming::WorkerContext, text::{Style, Text}, }; -use std::path::{Path, PathBuf}; +use std::{ + collections::BTreeSet, + path::{Path, PathBuf}, + sync::Arc, +}; /// Where overlay roots are built (beside options.txt). pub fn cache_dir() -> PathBuf { @@ -34,6 +43,10 @@ pub fn open_folder(dir: &Path) { log::warn!("Horizon: could not open {}: {e}", dir.display()); } } +/// `Util.getPlatform().openUri`: the system browser on the link. +pub fn open_uri(uri: &str) { + open_folder(Path::new(uri)); +} /// The resource root to load from, given the vanilla root, the selection and /// a server's packs, which go on top (`Pack.Position.TOP`, fixed). pub fn startup_root( @@ -94,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec { infos } impl Game<'_> { - fn vanilla_root(&self) -> PathBuf { + pub(super) fn vanilla_root(&self) -> PathBuf { if self.ui.vanilla_root.as_os_str().is_empty() { self.options.resources.clone() } else { @@ -134,35 +147,36 @@ impl Game<'_> { self.ui.options.resource_error = Some(e.to_string()); } } - /// Reload from the selected packs and the server's. - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + /// The CPU half of the reload, safe on a worker (`PackReload` runs it): + /// `apply_staged_pack` finishes the GPU half and the swap. + pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - let server = &self.server_packs.active; - let root = startup_root(&vanilla, &self.settings.resource_packs, server)?; - let root = - super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods); - self.reload_resources(&root) + stage_pack( + &vanilla, + &self.settings.resource_packs, + &self.server_packs.active, + &self.settings.mods.enabled, + &self.options.dev_mods, + &self.pack.assets, + &self.res, + &self.pack.context, + self.settings.force_unicode, + self.settings.japanese_glyphs, + &super::Progress::new(), + ) } - fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> { - let render = std::sync::Arc::new(self.pack.assets.reload(root)?); - let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?; - atlas.font.uniform = self.settings.force_unicode; - atlas.font.japanese = self.settings.japanese_glyphs; - let gui = super::UiResources { - atlas, - panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)), - }; - let pack = super::PackResources::load( + /// The render-thread half: the renderer on the new pack's textures, then + /// the swap (`ReloadInstance`'s application listeners). + pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> { + let StagedPack { root, - self.pack.assets.clone(), - render, - self.res.facts(), - self.pack.context.biomes.clone(), - self.pack.context.light.clone(), - )?; + gui, + pack, + textures, + } = staged; // Keep the working GPU resources, and hand its window surface over. if let Some(renderer) = &mut self.renderer { renderer.release_surface(); @@ -170,7 +184,17 @@ impl Game<'_> { let renderer = match self .window .clone() - .map(|window| self.renderer_for(window, &pack, &gui)) + .map(|window| { + build_renderer( + window, + self.options.offscreen, + &pack, + &gui, + self.renderer.as_ref(), + Some(textures), + self.settings.horizon.api, + ) + }) .transpose() { Ok(renderer) => renderer, @@ -186,18 +210,18 @@ impl Game<'_> { } }; // Everything loaded: switch over. - self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root); - self.armor_stand_assets.reload_trims(root); + self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root); + self.armor_stand_assets.reload_trims(&root); self.random_tick_sections.clear(); self.portals.sections.clear(); - self.options.resources = root.to_path_buf(); + self.options.resources = root.clone(); let context = pack.context.clone(); self.pack = pack; self.gui = gui; // The atlas's sprites moved: the particle engine re-reads every rect // and the packs' sprite sets, or every particle draws a stale region. self.particles - .reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); + .reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent); horizon_client::ui_theme::reset_atlas(); // Villager hats come from the pack's texture metadata (shared data, swapped in place). *self @@ -206,11 +230,11 @@ impl Game<'_> { .villager_hats .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = - horizon_client::mobs::villager_hats::VillagerHats::load(root); - horizon_client::ui::load_metadata(root); - lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code())); + horizon_client::mobs::villager_hats::VillagerHats::load(&root); + horizon_client::ui::load_metadata(&root); + lang::set(lang::Language::load(&[&root], &lang::code())); self.refresh_remote_language(); - self.audio.reload(root); + self.audio.reload(&root); self.clear_lod(); self.streaming.reload_resources(&mut self.world, context); self.pending_uploads.clear(); @@ -225,4 +249,66 @@ impl Game<'_> { self.creative_screen.forget_items(); Ok(()) } + /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then + /// applied (`PackReload` runs the same two phases across frames). + pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { + let staged = self.stage_selected_packs()?; + self.apply_staged_pack(staged) + } +} + +/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a +/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// root, the models and sprites, the menu bundle, the pack's tables and its +/// CPU mip chains. +pub(super) fn stage_pack( + vanilla_root: &Path, + selected: &[String], + server: &[Pack], + mods_enabled: &BTreeSet, + dev_mods: &[PathBuf], + assets: &Arc, + res: &Resources, + context: &WorkerContext, + force_unicode: bool, + japanese: bool, + progress: &super::Progress, +) -> horizon_client::Result { + let root = startup_root(vanilla_root, selected, server)?; + let root = super::mods::content_root(&root, mods_enabled, dev_mods); + progress.set(1); + let render = Arc::new(assets.reload(&root)?); + let mut atlas = HudAtlas::build_over(&root, vanilla_root)?; + atlas.font.uniform = force_unicode; + atlas.font.japanese = japanese; + let gui = UiResources { + atlas, + panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)), + }; + progress.set(2); + let pack = PackResources::load( + &root, + assets.clone(), + render, + res.facts(), + context.biomes.clone(), + context.light.clone(), + )?; + let textures = prepare_renderer_textures(&pack); + progress.set(3); + Ok(StagedPack { + root, + gui, + pack, + textures, + }) +} + +/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes +/// it on the render thread). +pub(super) struct StagedPack { + pub root: PathBuf, + pub gui: UiResources, + pub pack: PackResources, + pub textures: PreparedTextures, } diff --git a/crates/horizon-client/src/game/paintings.rs b/crates/horizon-client/src/game/paintings.rs index 321e59f7d..d0e984c29 100644 --- a/crates/horizon-client/src/game/paintings.rs +++ b/crates/horizon-client/src/game/paintings.rs @@ -267,8 +267,7 @@ impl Game<'_> { // measured from the box's centre (HangingEntity's position). let (lo, hi) = p.bounds(); let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi)); - let range = - super::entity_culling::default_range(hi - lo, self.settings.entity_distance); + let range = super::entity_culling::default_range(hi - lo, self.view_scale()); if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) { continue; } @@ -308,6 +307,8 @@ impl Game<'_> { layer: 0, color, light: packed, + + glint_uv: [0.0, 0.0], }); } }; diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index b5e650e70..8ceea7e12 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -193,12 +193,12 @@ impl Game<'_> { if let Some(a) = p.arrow.as_mut() { a.last_state = in_block.and_then(|t| self.state_from_nbt(&t)); } - self.creatures.projectiles.push(p); + self.add_projectile(p); } } "minecraft:fireball" | "minecraft:small_fireball" => { if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) { - self.creatures.projectiles.push(p); + self.add_projectile(p); } } "minecraft:falling_block" => { @@ -290,14 +290,22 @@ impl Game<'_> { "minecraft:ender_dragon" => { let seed = self.world.random.next_long(); if self.dragon.is_none() - && let Some(d) = super::dragon::Dragon::from_nbt(&tag, seed) + && let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed) { + // EnderDragon's constructor takes its id and its + // parts' from the entity counter. + d.id = self.next_entity + 1; + self.next_entity += 9; self.dragon = Some(d); } } "minecraft:end_crystal" => { let time = self.world.random.next_int_bound(100_000).unwrap_or(0); - if let Some(c) = super::dragon::Crystal::from_nbt(&tag, time) { + if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) { + // EndCrystal's constructor takes an id from the + // entity counter. + self.next_entity += 1; + c.id = self.next_entity; self.crystals.push(c); } } @@ -357,7 +365,8 @@ impl Game<'_> { pub(super) fn load_mob(&mut self, tag: Tag, mount: Option) -> Option { self.next_entity += 1; let uuid = entity_nbt::uuid(&tag); - // Structure entities placed with finalizeEntities. + // Structure mobs that generation left to finish: saves from before + // they were finalized at placement, or a placement that could not. let mut tag = tag; let mut finalize = false; if let Tag::Compound(c) = &mut tag { @@ -449,7 +458,8 @@ impl Game<'_> { } } /// An entity of a type Horizon does not simulate, kept as its compound. - fn load_other(&mut self, tag: Tag, kind: &str) -> Option { + fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option { + strip_finalize(&mut tag); let position = entity_nbt::position(&tag)?; let rotation = match tag.get("Rotation") { Some(Tag::List(l)) => { @@ -531,6 +541,14 @@ impl Game<'_> { self.persist.uuids.remove(&id); self.persist.raw.remove(&id); } + /// The compound an entity without a Horizon entity ID was loaded with + /// (the crystals and the dragon), found again by type and position. + pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) { + self.persist + .anon + .get(&anon_key(kind, p)) + .map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw))) + } /// The compound an entity was loaded with (and commands have edited). pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> { self.persist.raw.get(&id) @@ -1014,3 +1032,9 @@ impl Game<'_> { self.save.save_entities(chunks) } } +/// Only a mob is finalized; another entity drops the request. +fn strip_finalize(tag: &mut Tag) { + if let Tag::Compound(c) = tag { + c.remove(&"horizon:finalize".into()); + } +} diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index caf49883b..2c4cd1571 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -218,11 +218,20 @@ impl Game<'_> { _ => None, }) }); - // A living mount at the default scale and camera distance. + // Camera.setup: the living mount's scale and camera distance + // attributes (the happy ghast's is 8, the default 4). let mount = self .riding .and_then(|id| self.mobs.iter().find(|m| m.id == id)) - .map(|_| (1.0, 4.0)); + .map(|m| { + let attribute = |name: &str, default: f64| { + m.kind.attributes.get(name).copied().unwrap_or(default) + }; + ( + attribute("minecraft:scale", 1.0) as f32, + attribute("minecraft:camera_distance", 4.0), + ) + }); // AbstractBoat.positionRider turns a passenger by the boat's // deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot // interpolates a passenger's yaw: the boat's turn is spread over the @@ -443,6 +452,7 @@ impl Game<'_> { let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8); let atlas = &self.gui.atlas; let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); + let start = out.len(); horizon_client::mobs::render::render_with( m, alpha as f32, @@ -456,6 +466,15 @@ impl Game<'_> { self.player_elytra_geometry(m, camera, alpha as f32, packed, out); self.spin_attack_geometry(m, camera, alpha as f32, packed, out); self.held_item_geometry(m, camera, alpha, packed, true, out); + // LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is + // in its team by its profile name. + if self.potions.effects.get("glowing").is_some() { + let team = match &self.remote { + Some(r) => self.score.board.players_team(&r.profile_name), + None => self.player_team(), + }; + self.mark_outline(&out[start..], super::glowing::team_color(team)); + } } /// `extractEntityInInventoryFollowsMouse`: the player in the box /// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the @@ -710,6 +729,8 @@ mod preview_tests { color: u32::MAX, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }) }; // A shield and arm can intersect: neither average depth orders both ends. diff --git a/crates/horizon-client/src/game/pickups.rs b/crates/horizon-client/src/game/pickups.rs index 1e19bcfc3..040a6f01b 100644 --- a/crates/horizon-client/src/game/pickups.rs +++ b/crates/horizon-client/src/game/pickups.rs @@ -155,6 +155,8 @@ mod tests { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, })) } diff --git a/crates/horizon-client/src/game/piglins.rs b/crates/horizon-client/src/game/piglins.rs index bcf9e6ab5..edfd9a3a0 100644 --- a/crates/horizon-client/src/game/piglins.rs +++ b/crates/horizon-client/src/game/piglins.rs @@ -4,18 +4,172 @@ use super::Game; use horizon_client::{ items::ItemStack, - mobs::{Species, Who, piglin::ADMIRE}, + mobs::{ + Mob, Species, Who, + finalize::{ + SLOT_NAMES, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments, + stored_drop_chance, + }, + piglin::{ADMIRE, ATE_RECENTLY}, + }, }; use horizon_core::{geometry::Vec3, random::RandomSource}; impl Game<'_> { - fn piglin_loved(&self, item: &str) -> bool { + fn item_in_tag(&self, tag: &str, item: &str) -> bool { self.res .items() .item_tags - .get("minecraft:piglin_loved") + .get(tag) .is_some_and(|t| t.contains(item)) } + fn piglin_loved(&self, item: &str) -> bool { + self.item_in_tag("minecraft:piglin_loved", item) + } + /// `PiglinAi.isLovedItem` or a preferred weapon (`Piglin.canReplaceCurrentItem`). + fn piglin_wanted(&self, item: &str) -> bool { + self.piglin_loved(item) || self.item_in_tag("minecraft:piglin_preferred_weapons", item) + } + /// The `SLOT_NAMES` index `getEquipmentSlotForItem` picks: armour goes + /// to its slot, anything else to the main hand. + fn piglin_slot(&self, item: &str) -> usize { + let slot = self + .res + .items() + .get(item) + .and_then(|i| i.equip_slot.as_deref()) + .filter(|s| matches!(*s, "head" | "chest" | "legs" | "feet")) + .unwrap_or("mainhand"); + SLOT_NAMES.iter().position(|n| *n == slot).unwrap_or(0) + } + /// `Piglin.canReplaceCurrentItem`: an empty slot takes anything, a wanted + /// item replaces an unwanted one and never the other way round. Vanilla's + /// armour and weapon comparison for two equal-interest items is left out + /// (the piglin keeps what it has). + fn piglin_can_replace(&self, m: &Mob, item: &str, slot: usize) -> bool { + let Some(current) = horizon_client::mobs::finalize::item(m, slot) else { + return true; + }; + self.piglin_wanted(item) && !self.piglin_wanted(¤t) + } + /// `SimpleContainer.canAddItem` on the piglin's eight slots. + fn piglin_can_add(&self, m: &Mob, stack: &ItemStack) -> bool { + m.extra.inventory.len() < 8 + || m.extra.inventory.iter().any(|s| { + s.same_item_same_components(stack) && s.count < self.piglin_max_stack(&s.item) + }) + } + fn piglin_max_stack(&self, item: &str) -> u32 { + self.res.items().get(item).map_or(64, |i| i.max_stack) + } + /// `PiglinAi.wantsToPickup` (an adult that is not hurt-shy), without + /// the baby rule, which babies skip entirely here. + fn piglin_wants(&self, m: &Mob, stack: &ItemStack) -> bool { + let item = &*stack.item; + if self.item_in_tag("minecraft:piglin_repellents", item) { + return false; + } + let holds_loved = m + .extra + .off_hand + .as_deref() + .is_some_and(|o| self.piglin_loved(o)); + if item == "minecraft:gold_ingot" { + return !holds_loved; + } + let space = self.piglin_can_add(m, stack); + if item == "minecraft:gold_nugget" { + space + } else if self.item_in_tag("minecraft:piglin_food", item) { + m.extra.ints[0] <= 0 && space + } else if !self.piglin_loved(item) { + self.piglin_can_replace(m, item, self.piglin_slot(item)) + } else { + !holds_loved && space + } + } + /// `Mob.equipItemIfPossible` for a piglin: put it in its slot when that + /// slot takes it (an armour piece that cannot replace what is worn goes + /// to an empty main hand), dropping what it replaces by drop chance. + fn piglin_equip(&mut self, index: usize, stack: &ItemStack) -> bool { + let item = &*stack.item; + let mut slot = self.piglin_slot(item); + let m = &self.mobs[index]; + let mut replace = self.piglin_can_replace(m, item, slot); + if slot > 1 && !replace { + slot = 0; + replace = m.extra.main_hand.is_none(); + } + if !replace { + return false; + } + let m = &mut self.mobs[index]; + let old = horizon_client::mobs::finalize::item(m, slot); + let chance = stored_drop_chance(m, slot); + let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance; + let old_enchantments = slot_enchantments(m, slot); + set_item(m, slot, item); + // The piglin keeps what the stack carried (Protection IV stays on). + set_slot_enchantments(m, slot, stack.enchantments().to_vec()); + // setItemSlotAndDropWhenKilled, then persistenceRequired. + set_drop_chance(m, slot, 2.0); + m.persistent = true; + let at = [m.position.x, m.position.y, m.position.z]; + if let (true, Some(old)) = (drop_old, old) { + let mut dropped = ItemStack::new(&old, 1); + dropped.set_crafting_enchantments(old_enchantments); + self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10); + } + true + } + /// `PiglinAi.putInInventory`: what does not fit is thrown at a random + /// spot nearby. + fn piglin_stash(&mut self, index: usize, mut stack: ItemStack) { + let max = self.piglin_max_stack(&stack.item); + let m = &mut self.mobs[index]; + for s in &mut m.extra.inventory { + if stack.count > 0 && s.same_item_same_components(&stack) && s.count < max { + let n = (max - s.count).min(stack.count); + s.count += n; + stack.count -= n; + } + } + while stack.count > 0 && m.extra.inventory.len() < 8 { + let n = stack.count.min(max); + m.extra.inventory.push(ItemStack { + count: n, + ..stack.clone() + }); + stack.count -= n; + } + if stack.count == 0 { + return; + } + let from = [m.position.x, m.position.y + 1.3, m.position.z]; + let r = &mut self.world.random; + let to = [ + from[0] + f64::from(r.next_float() * 8.0 - 4.0), + from[1], + from[2] + f64::from(r.next_float() * 8.0 - 4.0), + ]; + self.piglin_throw(from, to, stack); + } + /// `PiglinAi.stopHoldingOffHandItem` for something that is not + /// bartering currency: wear it if it can, else keep it in the inventory. + fn piglin_keep(&mut self, index: usize, stack: ItemStack) { + // Nuggets keep their whole stack in the inventory (a hand slot here + // holds one item id only). + if &*stack.item == "minecraft:gold_nugget" || !self.piglin_equip(index, &stack) { + self.piglin_stash(index, stack); + } + } + /// `BehaviorUtils.throwItem`: toss a stack from a point toward another. + fn piglin_throw(&mut self, from: [f64; 3], to: [f64; 3], stack: ItemStack) { + let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; + let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); + let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; + self.spawn_item(stack, from, v, 40); + } /// `PiglinAi.isWearingSafeArmor`. pub(super) fn wearing_gold(&self) -> bool { let tag = self @@ -28,15 +182,13 @@ impl Game<'_> { .flatten() .any(|s| tag.is_some_and(|t| t.contains(&*s.item))) } - /// Piglins notice gold on the ground within 9 blocks, walk to it and - /// take it (`GoToWantedItem`, `Piglin.pickUpItem`). + /// Piglins notice wanted items on the ground within 9 blocks (`PiglinAi.wantsToPickup`: + /// gold and other loved items, nuggets, food, gear), walk to them and + /// take them (`GoToWantedItem`, `Piglin.pickUpItem`). pub(super) fn piglin_pickups(&mut self) { if !self.ui.rules.bool("mob_griefing") { return; } - // Whether each item is piglin-loved, kept until an item is added or - // removed (added ones are pushed at the end under a new id). - let mut loved: (Option<(usize, Option)>, Vec) = (None, Vec::new()); for i in 0..self.mobs.len() { let m = &self.mobs[i]; if m.species() != Species::Piglin @@ -44,27 +196,17 @@ impl Game<'_> { || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || m.extra.off_hand.is_some() { continue; } let pos = m.position; - let key = (self.dropped.len(), self.dropped.last().map(|e| e.id)); - if loved.0 != Some(key) { - loved.0 = Some(key); - loved.1 = self - .dropped - .iter() - .map(|e| self.piglin_loved(&e.stack.item)) - .collect(); - } let best = self .dropped .iter() .enumerate() - .filter(|&(j, _)| loved.1[j]) .map(|(j, e)| (j, e.position.distance_squared(pos))) .filter(|(_, d)| *d <= 81.0) + .filter(|&(j, _)| self.piglin_wants(m, &self.dropped[j].stack)) .min_by(|a, b| a.1.total_cmp(&b.1)); let Some((j, _)) = best else { self.mobs[i].extra.point = None; @@ -79,24 +221,38 @@ impl Game<'_> { continue; } // Nuggets come whole, anything else one at a time. - let item = e.stack.item.clone(); let (taken_item, id) = (e.clone(), self.mobs[i].id); + let item = e.stack.item.clone(); self.add_pickup( super::pickups::Taken::Item(Box::new(taken_item)), super::pickups::Collector::Mob(id), ); - let taken = if &*item == "minecraft:gold_nugget" { - self.dropped.remove(j).stack.count + let stack = if &*item == "minecraft:gold_nugget" { + self.dropped.remove(j).stack } else { let e = &mut self.dropped[j]; e.stack.count -= 1; + let one = ItemStack { + count: 1, + ..e.stack.clone() + }; if e.stack.count == 0 { self.dropped.remove(j); } - 1 + one }; - let _ = taken; - self.start_admiring(i, item); + if self.piglin_loved(&item) { + self.start_admiring(i, item); + } else if self.item_in_tag("minecraft:piglin_food", &item) + && self.mobs[i].extra.ints[0] <= 0 + { + // PiglinAi.eat: the food is gone and it will not eat for a while. + self.mobs[i].extra.ints[0] = ATE_RECENTLY; + self.mobs[i].nav_stop(); + } else { + self.mobs[i].nav_stop(); + self.piglin_keep(i, stack); + } } } fn start_admiring(&mut self, index: usize, item: std::sync::Arc) { @@ -109,14 +265,15 @@ impl Game<'_> { self.audio .play("minecraft:entity.piglin.admiring_item", Some(at), 1.0, 1.0); } - /// `PiglinAi.mobInteract`: hand a piglin something it loves. + /// `PiglinAi.mobInteract`: hand a piglin a gold ingot (only piglin + /// currency is accepted from the hand; other loved items are passed over). pub(super) fn give_piglin(&mut self, index: usize, item: &str) -> bool { let m = &self.mobs[index]; if m.species() != Species::Piglin || m.is_baby() || m.extra.using_item > 0 || m.extra.egg_time > 0 - || !self.piglin_loved(item) + || item != "minecraft:gold_ingot" { return false; } @@ -124,7 +281,8 @@ impl Game<'_> { true } /// `PiglinAi.stopHoldingOffHandItem` after admiring: gold ingots buy a - /// roll of the bartering table, thrown toward the player. + /// roll of the bartering table, thrown toward the player; any other loved + /// item it picked up is kept (worn if it can be, else stored). pub(super) fn finish_admiring(&mut self, mob: u32) { let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { return; @@ -132,9 +290,13 @@ impl Game<'_> { let Some(item) = self.mobs[i].extra.off_hand.take() else { return; }; + if &*item != "minecraft:gold_ingot" { + self.piglin_keep(i, ItemStack::new(&item, 1)); + return; + } let m = &self.mobs[i]; let from = [m.position.x, m.position.y + 1.3, m.position.z]; - let items = if &*item == "minecraft:gold_ingot" && !m.is_baby() { + let items = if !m.is_baby() { self.res.items().loot_table( "minecraft:gameplay/piglin_bartering", false, @@ -176,10 +338,7 @@ impl Game<'_> { from[2] + f64::from(r.next_float() * 8.0 - 4.0), ] }); - let d = [to[0] - from[0], to[1] - from[1], to[2] - from[2]]; - let len = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt().max(1e-6); - let v = [d[0] / len * 0.3, d[1] / len * 0.3 + 0.1, d[2] / len * 0.3]; - self.spawn_item(stack, from, v, 40); + self.piglin_throw(from, to, stack); } } /// `PiglinAi.angerNearbyPiglins`. diff --git a/crates/horizon-client/src/game/piston_push.rs b/crates/horizon-client/src/game/piston_push.rs new file mode 100644 index 000000000..6bbf1cb39 --- /dev/null +++ b/crates/horizon-client/src/game/piston_push.rs @@ -0,0 +1,573 @@ +//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for +//! the entities the game moves itself: the players and the mobs. +//! +//! The geometry is in [`horizon_world::piston_push`]; this file supplies the +//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)` +//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`), +//! then run through the block collision scan, so a pushed entity stops at a +//! wall instead of passing through it. +//! +use super::Game; +use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip}; +use horizon_core::geometry::{Aabb, Direction, Vec3, border::BorderGeometry}; +use horizon_core::position::BlockPos; +use horizon_world::{ + block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston, + piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld, +}; + +/// What one piston asks for this tick: where it is, its state, and the progress +/// `moveCollidedEntities` runs at (before the tick stores it). +pub(super) type Push = (BlockPos, MovingPiston, f32); + +/// Which entity was pushed, so its storage can be reached after the world +/// borrow ends. +#[derive(Clone, Copy, Hash, PartialEq, Eq)] +enum Target { + Player, + Guest(u32), + Mob(usize), + Item(usize), + Boat(usize), + Cart(usize), + ArmorStand(usize), +} + +/// `Entity.getStepHeight` of each body, read before the push. +struct StepHeights { + player: f32, + guests: std::collections::HashMap, +} + +/// What the push needs from one entity. +#[derive(Clone, Copy)] +struct Body { + bounds: Aabb, + position: Vec3, + on_ground: bool, + step_height: f32, + /// Standing on the piston's own block (`Entity.isSupportedBy`). + supported: bool, +} + +impl Game<'_> { + /// Push whatever this tick's moving pistons ran into, in the order the + /// pistons ticked: vanilla pushes from each block entity's own tick. + pub(super) fn push_piston_entities(&mut self, pushes: Vec) { + let mut accumulators = std::collections::HashMap::new(); + for push in pushes { + self.push_for_piston(push, &mut accumulators); + } + } + + fn push_for_piston( + &mut self, + (pos, piston, new_progress): Push, + accumulators: &mut std::collections::HashMap, + ) { + let delta_progress = f64::from(new_progress - piston.progress); + if delta_progress <= 0.0 { + return; + } + let Some(bindings) = self.collision.pistons() else { + return; + }; + let Ok(state) = bindings.collision_related_state(piston.shape_state()) else { + return; + }; + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + let Ok(shape) = self + .collision + .collision_shape(&mut scene, state, [pos.x, pos.y, pos.z]) + else { + return; + }; + if shape.is_empty() { + return; + } + let movement = piston.movement_direction(); + let boxes: Vec = shape.boxes().to_vec(); + let facts = self.res.facts(); + let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block"; + let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block"; + // `moveStuckEntities` asks the moved state's own top for its box. + let sticky_top = if honey { + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + self.collision + .collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z]) + .ok() + .and_then(|s| s.bounds()) + .map_or(0.0, |b| b.max_position().y) + } else { + 0.0 + }; + + let steps = self.push_step_heights(); + let search = shape.bounds().map(|bounds| { + piston_push::movement_search_area( + piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()), + movement, + delta_progress, + ) + }); + let targets = self.push_targets(); + for &target in &targets { + let Some(body) = self.push_body(target, pos, &steps) else { + continue; + }; + let amount = piston_push::collided_amount( + &boxes, + pos, + piston.shape_state(), + new_progress, + movement, + body.bounds, + ); + if slime && search.is_some_and(|area| area.intersects(body.bounds)) { + self.bounce(target, movement); + } + if amount > 0.0 { + let step = piston_push::step_vector(movement); + let before = body.bounds; + self.move_pushed( + target, + before, + [body.position.x, body.position.z], + body.step_height, + body.on_ground, + step.scale(amount), + movement, + accumulators, + ); + // `fixEntityWithinPistonBase`: a retracting source piston drags + // an entity that is still inside its own block. + if !piston.extending + && piston.source + && let Some(after) = self.push_body(target, pos, &steps) + && let Some(fix) = piston_push::fix_within_piston_base( + pos, + after.bounds, + movement, + delta_progress, + ) + { + let back = piston_push::step_vector(movement.opposite()); + self.move_pushed( + target, + after.bounds, + [after.position.x, after.position.z], + after.step_height, + after.on_ground, + back.scale(fix), + movement, + accumulators, + ); + } + } + } + // Vanilla selects honey passengers after all collided entities have moved. + if honey && movement.normal()[1] == 0 { + let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top); + for target in targets { + let Some(body) = self.push_body(target, pos, &steps) else { + continue; + }; + if piston_push::sticky_entity_selected( + area, + body.bounds, + horizon_world::level::data::PushReaction::PushPull, + body.on_ground, + body.supported, + body.position, + ) { + self.move_pushed( + target, + body.bounds, + [body.position.x, body.position.z], + body.step_height, + body.on_ground, + piston_push::step_vector(movement).scale(delta_progress), + movement, + accumulators, + ); + } + } + } + } + + /// `Entity.getStepHeight` for each body, read once: the attribute lookup + /// needs the mutable body, and the push only needs the number. + fn push_step_heights(&mut self) -> StepHeights { + let player = self + .player + .as_mut() + .and_then(|p| p.attribute("minecraft:step_height").ok()) + .map_or(0.6, |v| v as f32); + let mut guests = std::collections::HashMap::new(); + if let Some(host) = self.lan.as_mut() { + for g in &mut host.hub.guests { + let step = g + .motion + .as_mut() + .and_then(|m| m.attribute("minecraft:step_height").ok()) + .map_or(0.6, |v| v as f32); + guests.insert(g.conn, step); + } + } + StepHeights { player, guests } + } + + /// The entities a moving block can run into: the window's player, the LAN + /// guests in the level, then the mobs. + fn push_targets(&self) -> Vec { + let mut targets = Vec::new(); + if self.player.is_some() { + targets.push(Target::Player); + } + if let Some(host) = self.lan.as_ref() { + targets.extend( + host.hub + .guests + .iter() + .filter(|g| host.hub.here(g) && g.loaded && !g.dead) + .map(|g| Target::Guest(g.conn)), + ); + } + targets.extend((0..self.mobs.len()).map(Target::Mob)); + targets.extend((0..self.dropped.len()).map(Target::Item)); + targets.extend((0..self.boats.len()).map(Target::Boat)); + targets.extend((0..self.carts.len()).map(Target::Cart)); + targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand)); + targets + } + + /// What the push needs from one entity, or `None` when it is not pushed: + /// removed, dead, a spectator, or an armor-stand marker. + fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option { + let at = [pos.x, pos.y, pos.z]; + match target { + Target::Player => { + let p = self.player.as_ref()?; + if p.spectator { + return None; + } + Some(Body { + bounds: p.bounds(), + position: p.position, + on_ground: p.ground.on_ground, + step_height: steps.player, + supported: p.ground.supporting_block == Some(pos), + }) + } + Target::Guest(conn) => { + let host = self.lan.as_ref()?; + let g = host.hub.guests.iter().find(|g| g.conn == conn)?; + if g.dead || g.game_mode == 3 { + return None; + } + let p = g.position; + Some(Body { + bounds: Aabb::new( + Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3), + Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3), + ), + position: Vec3::new(p[0], p[1], p[2]), + on_ground: g.on_ground, + step_height: steps.guests.get(&conn).copied().unwrap_or(0.6), + supported: g + .motion + .as_ref() + .is_some_and(|m| m.ground.supporting_block == Some(pos)), + }) + } + Target::Mob(i) => { + let m = &self.mobs[i]; + if m.removed || !m.alive() { + return None; + } + Some(Body { + bounds: m.bounds(), + position: m.position, + on_ground: m.on_ground, + step_height: m.kind.step_height as f32, + supported: m.supporting == Some(at), + }) + } + Target::Item(i) => { + let entity = &self.dropped[i]; + Some(Body { + bounds: entity.bounds(), + position: entity.position, + on_ground: entity.on_ground, + step_height: 0.0, + supported: false, + }) + } + Target::Boat(i) => { + let entity = &self.boats[i]; + Some(Body { + bounds: entity.bounds(), + position: entity.position, + on_ground: entity.on_ground, + step_height: 0.0, + supported: false, + }) + } + Target::Cart(i) => { + let entity = &self.carts[i]; + Some(Body { + bounds: entity.bounds(), + position: entity.position, + on_ground: entity.on_ground, + step_height: 0.0, + supported: false, + }) + } + Target::ArmorStand(i) => { + let entity = &self.armor_stands[i]; + if entity.marker { + return None; + } + Some(Body { + bounds: entity.bounds(), + position: entity.position, + on_ground: entity.on_ground, + step_height: 0.0, + supported: false, + }) + } + } + } + + /// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the + /// per-tick piston limit, then the block and world border collision scan. + fn move_pushed( + &mut self, + target: Target, + before: Aabb, + at: [f64; 2], + step_height: f32, + on_ground: bool, + movement: Vec3, + piston_direction: Direction, + accumulators: &mut std::collections::HashMap, + ) { + let Some(limited) = self.limit_piston(target, movement, accumulators) else { + return; + }; + // `limitPistonMovement` returns zero once the tick's budget is spent. + if limited == Vec3::ZERO { + return; + } + let resolved = with_piston_noclip(piston_direction, || { + pushed_movement( + SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }, + self.border.geometry(), + at, + before, + limited, + step_height, + on_ground, + ) + .unwrap_or(Vec3::ZERO) + }); + if resolved.length_squared() <= 1.0e-7 + && limited.length_squared() - resolved.length_squared() >= 1.0e-7 + { + return; + } + self.apply_push(target, resolved); + } + + /// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks + /// per game tick, and a move touches only its first non-zero axis. + fn limit_piston( + &mut self, + target: Target, + movement: Vec3, + accumulators: &mut std::collections::HashMap, + ) -> Option { + let time = self.world.game_time; + let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::(time)).ok(); + match target { + Target::Player => { + let p = self.player.as_mut()?; + limit(&mut p.piston) + } + Target::Guest(conn) => { + let host = self.lan.as_mut()?; + let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?; + limit(&mut g.motion.as_deref_mut()?.piston) + } + Target::Mob(i) => limit(&mut self.mobs[i].piston), + _ => limit(accumulators.entry(target).or_default()), + } + } + + fn apply_push(&mut self, target: Target, movement: Vec3) { + match target { + Target::Player => { + if let Some(p) = self.player.as_mut() { + p.position = p.position.add_vector(movement); + } + } + Target::Guest(conn) => { + if let Some(host) = self.lan.as_mut() + && let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) + { + // The body and the hub's copy of its position move together: + // `leave_guest` writes the body back over the hub's. + for (slot, delta) in g.position.iter_mut().zip(movement.components()) { + *slot += delta; + } + if let Some(m) = g.motion.as_deref_mut() { + m.position = m.position.add_vector(movement); + } + } + } + Target::Mob(i) => { + let m = &mut self.mobs[i]; + m.position = m.position.add_vector(movement); + } + Target::Item(i) => { + self.dropped[i].position = self.dropped[i].position.add_vector(movement) + } + Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement), + Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement), + Target::ArmorStand(i) => { + self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement) + } + } + } + + /// `causeBounce`: a slime block sets the velocity along the movement axis. + fn bounce(&mut self, target: Target, movement: Direction) { + match target { + Target::Player => { + if let Some(p) = self.player.as_mut() { + p.velocity = piston_push::bounce_velocity(movement, p.velocity); + } + } + Target::Mob(i) => { + let m = &mut self.mobs[i]; + m.velocity = piston_push::bounce_velocity(movement, m.velocity); + } + Target::Guest(conn) => { + if let Some(host) = self.lan.as_mut() + && let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn) + && let Some(motion) = guest.motion.as_deref_mut() + { + motion.velocity = piston_push::bounce_velocity(movement, motion.velocity); + } + } + Target::Item(i) => { + self.dropped[i].velocity = + piston_push::bounce_velocity(movement, self.dropped[i].velocity) + } + Target::Boat(i) => { + self.boats[i].velocity = + piston_push::bounce_velocity(movement, self.boats[i].velocity) + } + Target::Cart(i) => { + self.carts[i].velocity = + piston_push::bounce_velocity(movement, self.carts[i].velocity) + } + Target::ArmorStand(i) => { + self.armor_stands[i].velocity = + piston_push::bounce_velocity(movement, self.armor_stands[i].velocity) + } + } + } +} + +/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement +/// it makes. The world's blocks stop it, and so does the world border: +/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when +/// the body is inside close to the border, as ordinary movement does. +fn pushed_movement( + blocks: W, + border: &BorderGeometry, + at: [f64; 2], + before: Aabb, + movement: Vec3, + step_height: f32, + on_ground: bool, +) -> horizon_client::Result +where + W: BlockCollisionWorld + EntityMovementWorld, +{ + EntityCollisions::with_border(blocks, border, at).resolve( + before, + movement, + step_height, + on_ground, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::collisions::EmptyCollisions; + + /// A border 10 wide around the origin: its wall's inner face is at x = 5. + fn border() -> BorderGeometry { + BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border") + } + + /// `Entity.collectCollidersIgnoringWorldBorder`: the border's walls stop a + /// pushed body that is inside close to the border. + #[test] + fn a_pushed_body_stops_at_the_world_border() { + // A body 0.6 wide, 0.4 blocks short of the wall, pushed east into it. + let before = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6)); + let border = border(); + let moved = pushed_movement( + EmptyCollisions, + &border, + [4.3, 0.3], + before, + Vec3::new(0.51, 0.0, 0.0), + 0.0, + false, + ) + .expect("the push resolves"); + // The wall stops the body 0.4 blocks short of the whole push. + assert!( + (moved.x - 0.4).abs() < 1.0e-9, + "the body was not stopped by the border: {moved:?}" + ); + } + + /// Away from the border, the same push moves the whole way. + #[test] + fn a_pushed_body_moves_whole_away_from_the_border() { + let before = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6)); + let border = border(); + let moved = pushed_movement( + EmptyCollisions, + &border, + [0.3, 0.3], + before, + Vec3::new(0.51, 0.0, 0.0), + 0.0, + false, + ) + .expect("the push resolves"); + assert!( + (moved.x - 0.51).abs() < 1.0e-9, + "the body did not move the whole push: {moved:?}" + ); + } +} diff --git a/crates/horizon-client/src/game/potions.rs b/crates/horizon-client/src/game/potions.rs index 13eae7d3e..c0d7f1e3c 100644 --- a/crates/horizon-client/src/game/potions.rs +++ b/crates/horizon-client/src/game/potions.rs @@ -2,7 +2,12 @@ //! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`), //! filling glass bottles (`BottleItem`), the per-tick effect application, //! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`). -use super::{Game, Screen, item_draw::owner_at}; +use super::{ + Game, Screen, + dragon::{Breath, FightPlayer, FightWho}, + item_draw::owner_at, + remote_projectiles::cloud_client_tick, +}; use horizon_assets::item_model::DisplayContext; use horizon_client::{ block_entity_render::vertex_light, @@ -12,8 +17,13 @@ use horizon_client::{ hud_layout, hud_overlays, inventory_effects, items::ItemStack, mesher::Vertex, + particles::{ParticleOptions, ParticleType}, + text::Text, +}; +use horizon_core::{ + geometry::{Aabb, Direction, Vec3}, + random::{LegacyRandom, RandomSource}, }; -use horizon_core::{geometry::Vec3, random::RandomSource}; /// A thrown splash or lingering potion in flight. #[derive(Clone, Debug)] @@ -26,9 +36,41 @@ pub(super) struct ThrownPotion { position: Vec3, velocity: Vec3, age: u32, + /// `Projectile.getOwner`'s display name: the player who threw it (none + /// for a dispenser's). + owner: Option, +} +/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`. +pub(super) const LINGERING_WAIT: i32 = 10; +/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`. +const REAPPLICATION_DELAY: i32 = 20; +/// An entity a cloud has affected (`AreaEffectCloud.victims`). +#[derive(Clone, Copy, PartialEq)] +enum Victim { + Mob(u32), + Player(FightWho), +} +/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects +/// (at the lingering item's 0.25 duration scale) and who it has affected. +pub(super) struct PotionCloud { + effects: &'static [(&'static str, i32, i32)], + /// The potion's colour (`PotionContents.getColor`). + color: u32, + /// Each victim and the tick it can be affected again. + victims: Vec<(Victim, i32)>, + /// Who its instant damage names (`indirectMagic(cloud, owner)`): the + /// thrower, else the cloud. + source: Text, +} +impl PotionCloud { + /// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour. + pub(super) fn particle(&self) -> ParticleOptions { + let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0; + ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0]) + } } /// A mob's effect ids carry the `minecraft:` namespace. -fn mob_effect_id(id: &str) -> Option<&'static str> { +pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> { use std::{collections::HashMap, sync::OnceLock}; static IDS: OnceLock> = OnceLock::new(); let id = id.trim_start_matches("minecraft:"); @@ -44,10 +86,196 @@ fn mob_effect_id(id: &str) -> Option<&'static str> { .get(id) .copied() } +/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and +/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison, +/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags +/// decide first, then the tag of mobs that ignore poison and regeneration. +fn mob_can_be_affected( + tags: &std::collections::HashMap>, + kind: &str, + effect: &str, +) -> bool { + let effect = effect.trim_start_matches("minecraft:"); + let kind = kind.trim_start_matches("minecraft:"); + let overridden = match kind { + "wither" | "wither_skeleton" => effect == "wither", + "spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison", + "parched" => effect == "weakness", + _ => false, + }; + if overridden { + return false; + } + let in_tag = |tag: &str| { + tags.get(tag) + .is_some_and(|t| t.contains(&format!("minecraft:{kind}"))) + }; + if in_tag("minecraft:immune_to_infested") { + effect != "infested" + } else if in_tag("minecraft:immune_to_oozing") { + effect != "oozing" + } else { + !in_tag("minecraft:ignores_poison_and_regen") + || (effect != "regeneration" && effect != "poison") + } +} impl Game<'_> { + /// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason + /// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer + /// dies. `bearer` is the dying mob's id, `player` whether it is a player, + /// `random` the dying mob's own (`mob.getRandom()`; the world's for the + /// player). + pub(super) fn effects_on_death( + &mut self, + effects: &[&str], + bounds: Aabb, + player: bool, + bearer: Option, + mut random: Option<&mut LegacyRandom>, + ) { + let feet = bounds.bottom_center(); + if effects.contains(&"oozing") { + // OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the + // slimes close by already fill the cramming limit. + let limit = self.ui.rules.int("max_entity_cramming"); + let mut count = 2; + if limit >= 1 { + let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0)); + let nearby = self + .mobs + .iter() + .filter(|m| { + m.species() == horizon_client::mobs::Species::Slime + && Some(m.id) != bearer + && m.bounds().intersects(near) + }) + .count() + .min(limit as usize) as i32; + count = (limit - nearby).clamp(0, count); + } + for _ in 0..count { + // spawnSlimeOffspring: a size 2 slime half a block up. + if let Some(id) = self.spawn_mob_for( + "minecraft:slime", + [feet.x, feet.y + 0.5, feet.z], + false, + true, + ) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + horizon_client::mobs::cube::set_size(m, 2); + } + } + } + if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) { + self.spawn_cobwebs_around( + [ + feet.x.floor() as i32, + feet.y.floor() as i32, + feet.z.floor() as i32, + ], + random.as_deref_mut(), + ); + } + if effects.contains(&"wind_charged") { + // A burst centred at half the height, radius 3 to 5. + let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z]; + let r = random.unwrap_or(&mut self.world.random); + let radius = 3.0 + r.next_float() * 2.0; + let _ = self.wind_burst(center, radius, 1.0, false, false); + } + } + /// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in + /// free spaces with a solid floor, picked among 15 random blocks within one + /// block of `pos`. + fn spawn_cobwebs_around(&mut self, pos: [i32; 3], mut random: Option<&mut LegacyRandom>) { + let r = random.as_deref_mut().unwrap_or(&mut self.world.random); + let wanted = 2 + r.next_int_bound(2).unwrap_or(0) as usize; + let mut chosen: Vec<[i32; 3]> = Vec::new(); + for _ in 0..15 { + let r = random.as_deref_mut().unwrap_or(&mut self.world.random); + let at = [ + pos[0] - 1 + r.next_int_bound(3).unwrap_or(0), + pos[1] - 1 + r.next_int_bound(3).unwrap_or(0), + pos[2] - 1 + r.next_int_bound(3).unwrap_or(0), + ]; + let facts = self.res.facts(); + let here = facts.get(self.world.block(at[0], at[1], at[2])); + let below = facts.get(self.world.block(at[0], at[1] - 1, at[2])); + if !chosen.contains(&at) + && here.replaceable + && below.sturdy_full & (1 << Direction::Up as u8) != 0 + { + chosen.push(at); + if chosen.len() >= wanted { + break; + } + } + } + for at in chosen { + if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() { + self.level_event_at(3018, at, 0); + } + } + } + /// `InfestedMobEffect.onMobHurt` for the player: from half its height up, + /// along where it looks. + pub(super) fn player_infested(&mut self) { + if let Some(p) = self.player.as_ref() { + let at = [ + p.position.x, + p.position.y + f64::from(p.dimensions.height) / 2.0, + p.position.z, + ]; + let look = super::view_vector(self.yaw, self.pitch); + self.infested_hurt(at, look, None); + } + } + /// A living mob's own random (`Entity.random`), else the world's. + fn bearer_random(&mut self, bearer: Option) -> &mut LegacyRandom { + match bearer.and_then(|id| self.mobs.iter().position(|m| m.id == id)) { + Some(i) => &mut self.mobs[i].random, + None => &mut self.world.random, + } + } + /// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at + /// `at` (half the bearer's height up), thrown along its look direction. + /// The draws are the bearer's (`mob.getRandom()`): `bearer` is the mob, + /// none for the player (the world's random). + pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3], bearer: Option) { + if self.bearer_random(bearer).next_float() > 0.1 { + return; + } + let count = 1 + self.bearer_random(bearer).next_int_bound(2).unwrap_or(0); + for _ in 0..count { + // Mth.randomBetween(-PI / 2, PI / 2), then the look direction + // scaled by (0.3, 0.45, 0.3) and turned about the y axis. + let half = std::f32::consts::FRAC_PI_2; + let angle = self.bearer_random(bearer).next_float() * (2.0 * half) - half; + let (sin, cos) = angle.sin_cos(); + let (x, z) = (look[0] * 0.3, look[2] * 0.3); + let velocity = Vec3::new( + f64::from(x as f32 * cos + z as f32 * sin), + look[1] * 0.3 * 1.5, + f64::from(-x as f32 * sin + z as f32 * cos), + ); + if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.velocity = velocity; + } + self.audio + .play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0); + } + } /// A splash potion, lingering potion or experience bottle in flight (the /// player's throws and dispensers both end up here). - pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) { + pub(super) fn launch_thrown( + &mut self, + stack: ItemStack, + position: Vec3, + velocity: Vec3, + owner: Option, + ) { self.potions.thrown.push(ThrownPotion { id: 0, stack, @@ -55,6 +283,7 @@ impl Game<'_> { position, velocity, age: 0, + owner, }); } } @@ -135,6 +364,7 @@ impl PotionState { position: at, velocity, age: 0, + owner: None, }); } /// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows. @@ -237,6 +467,7 @@ impl Game<'_> { }, Vec3::new(eye[0], eye[1] - 0.1, eye[2]), v, + Some(self.player_display_text()), ); self.swing_arm(); self.consume_held(); @@ -276,7 +507,8 @@ impl Game<'_> { } else if let Some(rest) = self.give(filled) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40); + // `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// Drinking a potion while right-click is held; true when handled. @@ -299,6 +531,7 @@ impl Game<'_> { return true; } self.using = None; + self.send_use_item_complete(); self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player); if ominous { self.drink_ominous_bottle(slot, stack); @@ -376,6 +609,7 @@ impl Game<'_> { self.tick_mob_effects(); if running { self.tick_thrown_potions(); + self.tick_potion_clouds(); } } /// `LivingEntity.tickEffects` (server side) for `self.player`, the local @@ -386,13 +620,37 @@ impl Game<'_> { return; }; if player.health.dead_or_dying() { - // LivingEntity.die → removeAllEffects. + // LivingEntity.die → removeAllEffects. Vanilla runs the effects + // that act on death (`onMobRemoved`) when the dead player is + // removed, up to 20 ticks later; here it is as the effects go. + let w = f64::from(player.dimensions.width) / 2.0; + let bounds = Aabb::new( + Vec3::new( + player.position.x - w, + player.position.y, + player.position.z - w, + ), + Vec3::new( + player.position.x + w, + player.position.y + f64::from(player.dimensions.height), + player.position.z + w, + ), + ); + let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect(); self.potions.effects.clear(); + if !ids.is_empty() { + self.effects_on_death(&ids, bounds, true, None, None); + } } // The omens act from inside the effect tick (BadOmenMobEffect, // RaidOmenMobEffect). + let absorption_empty = self + .player + .as_ref() + .is_some_and(|p| p.health.absorption() <= 0.0); let mut effects = std::mem::take(&mut self.potions.effects); - let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e)); + let (actions, raid_omen_added) = + effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty); self.potions.effects = effects; if raid_omen_added { self.raid_omen_added(); @@ -400,8 +658,28 @@ impl Game<'_> { for action in actions { self.apply_effect_action(action); } + self.turtle_helmet_tick(); self.apply_effect_status(); } + /// `Player.tick` -> `turtleHelmetTick`: with a turtle helmet on the head + /// and the eyes out of the water, Water Breathing for 200 ticks (no + /// particles, an icon). + fn turtle_helmet_tick(&mut self) { + let Some(p) = self.player.as_ref() else { + return; + }; + let eye_y = p.position.y + f64::from(p.dimensions.eye_height); + if !p.health.dead_or_dying() + && self.armor[0] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:turtle_helmet") + && !self.eye_in_water(p.position.x, eye_y, p.position.z) + { + let mut effect = EffectInstance::new("water_breathing", 200, 0); + effect.visible = false; + self.potions.effects.add(effect); + } + } /// A client's own effects (`LivingEntity.tickEffects` on the client, /// `MobEffectInstance.tickClient`): they count down, and the server /// removes them. Their modifiers and flags move the predicted player. @@ -513,29 +791,176 @@ impl Game<'_> { let state = self.world .block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); - if self.res.facts().get(state).full_collision - || self.mobs.iter().any(|m| { - m.alive() - && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 - && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 - && p.y >= m.position.y - && p.y <= m.position.y + m.kind.height - }) - { - hit = Some(p); + let mob = self.mobs.iter().find(|m| { + m.alive() + && (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125 + && (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125 + && p.y >= m.position.y + && p.y <= m.position.y + m.kind.height + }); + if self.res.facts().get(state).full_collision || mob.is_some() { + hit = Some((p, mob.map(|m| m.position))); break; } } - if let Some(p) = hit { - landed.push((t.stack.clone(), p)); + if let Some((p, mob)) = hit { + landed.push((t.stack.clone(), p, mob, t.owner.clone())); return false; } t.position = next; t.age < 1200 && t.position.y > -128.0 }); self.potions.thrown = thrown; - for (stack, at) in landed { - self.splash(&stack, at); + for (stack, at, mob, owner) in landed { + self.splash(&stack, at, mob, owner); + } + } + /// `AreaEffectCloud.tick` for the lingering potions' clouds: the client + /// tick's particles, then the server tick. + fn tick_potion_clouds(&mut self) { + if self.breath_clouds.iter().all(|c| c.potion.is_none()) { + return; + } + let players: Vec = self + .fight_players() + .into_iter() + .filter(|p| p.alive) + .collect(); + let mut clouds = std::mem::take(&mut self.breath_clouds); + clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players)); + self.breath_clouds.append(&mut clouds); + } + /// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its + /// wait it shrinks, and every 5 ticks it affects the living entities in + /// its box and radius that were not affected in the last 20 ticks, each + /// taking some of its radius. False once it is gone. + fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool { + c.age += 1; + let Some(potion) = c.potion.as_mut() else { + return true; + }; + if c.age - LINGERING_WAIT >= c.duration { + return false; + } + let waiting = c.age < LINGERING_WAIT; + cloud_client_tick( + &mut self.particles, + c.at, + c.radius, + waiting, + &potion.particle(), + ); + if waiting { + return true; + } + // setRadiusPerTick(-radius / duration). + c.radius += -3.0 / 600.0; + if c.radius < 0.5 { + return false; + } + if c.age % 5 != 0 { + return true; + } + potion.victims.retain(|(_, until)| c.age < *until); + let r = f64::from(c.radius); + let area = Aabb::new( + Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r), + Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r), + ); + let mut inside = Vec::new(); + for p in players.iter().filter(|p| p.bounds.intersects(area)) { + inside.push((Victim::Player(p.who), p.position.x, p.position.z)); + } + let effects = potion.effects; + let tags = &self.res.stations().enchantments.entity_tags; + // Only mobs that at least one of the cloud's effects can affect are + // taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so + // the others neither become victims nor use up its radius. + for m in self.mobs.iter().filter(|m| { + m.alive() + && m.bounds().intersects(area) + && effects + .iter() + .any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id)) + }) { + inside.push((Victim::Mob(m.id), m.position.x, m.position.z)); + } + for (victim, x, z) in inside { + let (dx, dz) = (x - c.at[0], z - c.at[2]); + if potion.victims.iter().any(|(v, _)| *v == victim) + || dx * dx + dz * dz > f64::from(c.radius * c.radius) + { + continue; + } + potion.victims.push((victim, c.age + REAPPLICATION_DELAY)); + self.cloud_affect(victim, effects, c.at, &potion.source); + // setRadiusOnUse(-0.5). + c.radius -= 0.5; + if c.radius < 0.5 { + return false; + } + } + true + } + /// What a cloud puts on one entity: instant effects at half strength + /// (`applyInstantaneousEffect` with 0.5), the others for their duration + /// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`). + fn cloud_affect( + &mut self, + victim: Victim, + effects: &'static [(&'static str, i32, i32)], + at: [f64; 3], + source: &Text, + ) { + let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1); + match victim { + Victim::Player(who) => self.as_fight_player(who, |g| { + for (id, duration, amp) in effects { + let Some(e) = brewing::effect(id) else { + continue; + }; + if e.instant { + if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) { + g.apply_indirect_effect_action(a, source); + } + } else { + g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp)); + } + } + }), + Victim::Mob(mob) => { + let Some(i) = self.mobs.iter().position(|m| m.id == mob) else { + return; + }; + // Undead invert healing and harming. + let tags = &self.res.stations().enchantments.entity_tags; + let inverted = tags + .get("minecraft:inverted_healing_and_harm") + .is_some_and(|t| t.contains(&self.mobs[i].kind.id)); + for (id, duration, amp) in effects { + let m = &mut self.mobs[i]; + match *id { + "instant_damage" | "instant_health" => { + let amount = + |base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32; + if (*id == "instant_damage") != inverted { + m.hurt(amount(6), Some(at), &mut self.world.random); + } else { + m.health = (m.health + amount(4)).min(m.kind.max_health); + } + } + _ => { + // `LivingEntity.addEffect` refuses what the mob + // cannot be affected by. + if mob_can_be_affected(tags, &m.kind.id, id) + && let Some(name) = mob_effect_id(id) + { + m.add_effect(name, scaled(*duration), *amp); + } + } + } + } + } } } /// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which @@ -637,12 +1062,24 @@ impl Game<'_> { self.submit_item_world(&state, pose, vertex_light(light), out); } } + /// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a + /// potion or a cloud): indirectMagic, not plain magic. + fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) { + let a = match a { + EffectAction::Hurt("minecraft:magic", amount) => { + self.note_direct_entity("minecraft:indirect_magic", source.clone()); + EffectAction::Hurt("minecraft:indirect_magic", amount) + } + a => a, + }; + self.apply_effect_action(a); + } /// A splash potion's effects on the player in place, by their distance. fn splash_on_player( &mut self, effects: &'static [(&'static str, i32, i32)], at: Vec3, - scale: f64, + source: &Text, ) { if let Some(p) = self.player.as_ref() { let center = Vec3::new( @@ -658,10 +1095,10 @@ impl Game<'_> { let instant = brewing::effect(id).is_some_and(|e| e.instant); if instant { if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) { - self.apply_effect_action(a); + self.apply_indirect_effect_action(a, source); } } else { - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(e) = brewing::effect(id) { @@ -672,69 +1109,67 @@ impl Game<'_> { } } } - /// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash). - fn splash(&mut self, stack: &ItemStack, at: Vec3) { + /// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion` + /// for a lingering potion; `mob` is where it hit a mob. + fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option, owner: Option) { + let block = [at.x, at.y, at.z].map(|v| v.floor() as i32); if &*stack.item == "minecraft:experience_bottle" { // ThrownExperienceBottle.onHit: the water-coloured splash (level - // event 2002) and 3 to 11 experience. - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio.play( - "minecraft:entity.splash_potion.break", - Some([at.x, at.y, at.z]), - 1.0, - pitch, - ); - for _ in 0..8 { - let v = [ - f64::from(self.world.random.next_float() - 0.5) * 0.3, - 0.2, - f64::from(self.world.random.next_float() - 0.5) * 0.3, - ]; - let p = horizon_client::particles::Particle::terrain( - &mut self.world.random, - [at.x, at.y, at.z], - v, - "item/splash_potion".into(), - 0x38_5DC6, - ); - self.particles.push(p); - } + // event 2002, its sound 1053) and 3 to 11 experience. + self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32); + self.level_event_at(1053, block, 0); let r = &mut self.world.random; let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0); self.spawn_orbs([at.x, at.y, at.z], value as u32); return; } let lingering = &*stack.item == "minecraft:lingering_potion"; - let event = if lingering { - "minecraft:entity.lingering_potion.break" - } else { - "minecraft:entity.splash_potion.break" - }; - let pitch = self.world.random.next_float() * 0.1 + 0.9; - self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch); + // AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a + // potion with instant effects, 2002 and 1053 otherwise. + let potion = stack.potion().and_then(brewing::potion); + let instant = potion.is_some_and(|d| { + d.effects + .iter() + .any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant)) + }); let color = brewing::potion_color(stack.potion()); - for _ in 0..8 { - let v = [ - f64::from(self.world.random.next_float() - 0.5) * 0.3, - 0.2, - f64::from(self.world.random.next_float() - 0.5) * 0.3, - ]; - let p = horizon_client::particles::Particle::terrain( - &mut self.world.random, - [at.x, at.y, at.z], - v, - "item/splash_potion".into(), - color, - ); - self.particles.push(p); - } - let scale = if lingering { 0.25 } else { 1.0 }; - let Some(def) = stack.potion().and_then(brewing::potion) else { + self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32); + self.level_event_at(if instant { 1054 } else { 1053 }, block, 0); + let Some(def) = potion else { return; }; + if lingering { + // The cloud sits at the mob it hit (its feet), else the impact; + // radius 3, wait 10, 600 ticks, shrinking to nothing, and a + // potion with no effects leaves none. + if !def.effects.is_empty() { + let at = mob.unwrap_or(at); + self.breath_clouds.push(Breath { + id: 0, + at: [at.x, at.y, at.z], + radius: 3.0, + age: 0, + duration: 600, + from_dragon: false, + potion: Some(PotionCloud { + effects: def.effects, + color, + victims: Vec::new(), + source: owner.unwrap_or_else(|| { + Text::translate("entity.minecraft.area_effect_cloud", &[]) + }), + }), + }); + } + return; + } // Every player in range (`ThrownSplashPotion.applySplash` over // `LivingEntity`s, the players among them). - self.for_each_player(|g| g.splash_on_player(def.effects, at, scale)); + // Instant damage is indirectMagic(potion, owner), which the death + // message names by the owner, else by the potion. + let source = + owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[])); + self.for_each_player(|g| g.splash_on_player(def.effects, at, &source)); // Mobs take the instant effects (undead invert healing and harming). let undead = &self.res.stations().enchantments.entity_tags; let mut hits = Vec::new(); @@ -757,7 +1192,7 @@ impl Game<'_> { _ => { // `Potion.applySplash`: timed effects last in // proportion to how close the mob was. - let d = (intensity * f64::from(*duration) * scale + 0.5) as i32; + let d = (intensity * f64::from(*duration) + 0.5) as i32; if d > 20 && let Some(name) = mob_effect_id(id) { diff --git a/crates/horizon-client/src/game/powder_snow.rs b/crates/horizon-client/src/game/powder_snow.rs index 6a904706f..7d935d07b 100644 --- a/crates/horizon-client/src/game/powder_snow.rs +++ b/crates/horizon-client/src/game/powder_snow.rs @@ -53,7 +53,11 @@ impl Game<'_> { if let Some(player) = self.player.as_mut() { player.fire_ticks = 0; } - // Entity.playEntityOnFireExtinguishedSound + self.extinguish_sound(at); + Ok(()) + } + /// Entity.playEntityOnFireExtinguishedSound + fn extinguish_sound(&mut self, at: [f64; 3]) { let r = self.particles.random(); let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4; self.audio.play( @@ -62,7 +66,29 @@ impl Game<'_> { 0.7, pitch, ); - Ok(()) + } + /// `Entity.applyEffectsFromBlocks`: a burning player in the rain + /// (`isInRain`) has the fire put out (`clearFire`), with the extinguish + /// sound that everyone near hears. + pub(super) fn rain_extinguish_player(&mut self) { + let Some(player) = self.player.as_ref() else { + return; + }; + let (at, top) = (player.position, player.bounds().max_position().y); + if player.fire_ticks <= 0 || !self.in_rain(at, top) { + return; + } + if let Some(player) = self.player.as_mut() { + player.fire_ticks = 0; + } + self.extinguish_sound([at.x, at.y, at.z]); + } + /// The in-window player's rain extinguish (a server's player has its + /// fire put out by the server). + pub(super) fn rain_extinguish_in_frame(&mut self) { + if self.remote.is_none() { + self.rain_extinguish_player(); + } } /// Whether the block at `pos` is powder snow. pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool { diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bedca5708..8fbcfc6dd 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -235,7 +235,7 @@ impl Game<'_> { } out } - fn difficulty_id(&self) -> i32 { + pub(super) fn difficulty_id(&self) -> i32 { use horizon_world::food::Difficulty; match self.world.health_rules.difficulty { Difficulty::Peaceful => 0, @@ -762,7 +762,7 @@ impl Game<'_> { } positions.push((m.id, m.position, s.active_raid)); } - let banners: Vec<(usize, Vec3)> = self + let banners: Vec<(usize, u32, Vec3)> = self .dropped .iter() .enumerate() @@ -771,7 +771,7 @@ impl Game<'_> { && e.stack.data().is_some_and(|d| d.ominous_banner) && e.pickup_delay == 0 }) - .map(|(i, e)| (i, e.position)) + .map(|(i, e)| (i, e.id, e.position)) .collect(); let mut taken: Vec = Vec::new(); let mut collectors: Vec<(usize, u32)> = Vec::new(); @@ -796,6 +796,8 @@ impl Game<'_> { let s = m.extra.raider.get_or_insert_with(Box::default); s.rider = has_rider; s.in_village = in_village; + // Vindicator.customServerAiStep: `ServerLevel.isRaided`. + s.raided = species == Species::Vindicator && raids.nearby(block).is_some(); if s.raid_id.is_some_and(|id| raids.get(id).is_none()) { s.raid_id = None; s.ticks_outside_raid = 0; @@ -856,20 +858,33 @@ impl Game<'_> { && raids.get(id).is_some_and(|r| r.leader(s.wave).is_none()) && !leaders.contains(&(id, s.wave)) { + let in_reach = |(i, _, p): &&(usize, u32, Vec3)| { + !taken.contains(i) + && (p.x - position.x).abs() <= range + && (p.z - position.z).abs() <= range + && (p.y - position.y).abs() <= 8.0 + }; + let by_distance = |a: &&(usize, u32, Vec3), b: &&(usize, u32, Vec3)| { + a.2.distance_squared(position) + .total_cmp(&b.2.distance_squared(position)) + }; + // The unreachable cache only picks the navigation target; + // a banner next to the raider is still picked up. let near = banners .iter() - .filter(|(i, p)| { - !taken.contains(i) - && (p.x - position.x).abs() <= range - && (p.z - position.z).abs() <= range - && (p.y - position.y).abs() <= 8.0 + .filter(in_reach) + .filter(|(_, item, _)| { + !s.unreachable_banners + .iter() + .any(|&(b, until)| b == *item && now < until) }) - .min_by(|a, b| { - a.1.distance_squared(position) - .total_cmp(&b.1.distance_squared(position)) - }); - if let Some((i, p)) = near { + .min_by(by_distance); + let nearest = banners.iter().filter(in_reach).min_by(by_distance); + let adjacent = + nearest.filter(|(_, _, p)| p.distance_squared(position) < 1.414 * 1.414); + if let Some((i, item, p)) = adjacent.or(near) { s.banner_at = Some([p.x, p.y, p.z]); + s.banner_item = *item; if p.distance_squared(position) < 1.414 * 1.414 { // pickUpItem: the banner becomes the wave's leader. taken.push(*i); @@ -1034,6 +1049,7 @@ impl Game<'_> { /// read it). fn sync_villager_raids(&mut self, infos: &HashMap>) { let raids = &self.raiding.raids; + let mut ongoing = Vec::new(); for m in &mut self.mobs { if m.species() != Species::Villager { continue; @@ -1059,7 +1075,27 @@ impl Game<'_> { let ext = mobs_villager::ext_mut(m); ext.raid = status; ext.raid_wave = wave; + if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) { + ongoing.push(m.id); + } } + // Villager.customServerAiStep: a 1 in 100 chance each tick, in an + // ongoing raid, of entity event 42 (`handleEntityEvent` splashes). + for id in ongoing { + if self.particles.random().next_int_bound(100) == Ok(0) { + self.mob_raid_splash(id); + } + } + } + /// Entity event 42 for villager `id`: the guests get the byte, and this + /// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself. + fn mob_raid_splash(&mut self, id: u32) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + self.lan_entity_event(id as i32, 42, at); + self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER); } /// The raid bar a living player at `at` is in (`Raid.getPlayers`): red, diff --git a/crates/horizon-client/src/game/recipe_book.rs b/crates/horizon-client/src/game/recipe_book.rs index 96e12d79b..9991ce366 100644 --- a/crates/horizon-client/src/game/recipe_book.rs +++ b/crates/horizon-client/src/game/recipe_book.rs @@ -338,6 +338,25 @@ impl Game<'_> { ui.client_known = Some(known.clone()); ui.client_version += 1; } + /// Every entry of the book, in its order (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_entries(&mut self) -> Vec { + self.ensure_book(); + self.recipe_ui + .book + .as_ref() + .map_or_else(Vec::new, |b| b.entries.clone()) + } + /// The display ids the player's book holds, and a number that changes + /// whenever they do (capability `recipes`). + #[cfg(feature = "mods")] + pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec) { + self.sync_client_book(); + ( + self.recipe_ui.client_version, + self.recipe_ui.client.displays(), + ) + } fn ensure_book(&mut self) { if self.recipe_ui.book.is_none() { let mut book = Book::build(self.res.recipes(), self.res.items()); @@ -590,6 +609,7 @@ impl Game<'_> { if inside(cursor, bx, by, 20.0, 18.0) { let open = !self.book_open(book_type); self.level.recipe_book.open[book_type.index()] = open; + self.send_book_settings(book_type); if !open { self.recipe_ui.overlay = None; } @@ -675,6 +695,7 @@ impl Game<'_> { if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) { let filtering = !self.book_filtering(book_type); self.level.recipe_book.filtering[book_type.index()] = filtering; + self.send_book_settings(book_type); self.recipe_ui.page = 0; self.audio .play("minecraft:ui.button.click", None, 0.25, 1.0); @@ -695,6 +716,22 @@ impl Game<'_> { } false } + /// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book. + fn send_book_settings(&self, book_type: BookType) { + self.send( + horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings( + horizon_protocol::packets::recipes::RecipeBookChangeSettings { + book_type: book_type.index() as i32, + open: self.book_open(book_type), + filtering: self.book_filtering(book_type), + }, + ), + ); + } + pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> { + let book = self.active_book()?; + Some((self.book_open(book), self.book_filtering(book))) + } /// How many displays the client's book holds. pub(super) fn recipe_book_known(&self) -> usize { self.recipe_ui.client.displays().len() @@ -826,6 +863,34 @@ impl Game<'_> { self.recipe_ui.ghost.clear(); self.place_recipe(book_type, entry_index, use_max); } + /// Whether the open screen takes `recipes.place`: a crafting grid's + /// book (the inventory's or a crafting table's). + #[cfg(feature = "mods")] + pub(super) fn mod_recipe_screen(&self) -> bool { + self.screen == Screen::Inventory + && self.active_book() == Some(BookType::Crafting) + && !self.spectator() + } + /// `recipes.place`: the book's click on the recipe with this display + /// id, refused as `handlePlaceRecipe` refuses it (no crafting screen, + /// not in the player's book; an impossible one in `place_recipe`). + #[cfg(feature = "mods")] + pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) { + if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) { + return; + } + self.ensure_book(); + let Some(index) = self + .recipe_ui + .book + .as_ref() + .and_then(|b| b.entries.iter().position(|e| e.display_id == display_id)) + else { + return; + }; + self.recipe_ui.ghost.clear(); + self.place_recipe(BookType::Crafting, index, all); + } /// `ServerboundPlaceRecipePacket`: the server places the recipe /// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost /// recipe when the items are missing. diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 6b0d2477d..31615ee9b 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -117,13 +117,17 @@ pub(super) struct Remote { joined: bool, status: String, pub(super) entity_id: i32, + /// The item the player's use began with (`LivingEntity.useItem`). + pub(super) use_item: Option>, dimension: DimensionType, /// What chunk packets decode with (the dimension's height and sky, the /// biome registry); replaced whenever one of them changes. pub(super) chunk_format: Arc, /// Decodes chunk packets as they arrive, off the frame. decoder: ChunkDecoder, - view_distance: i32, + /// Options.serverRenderDistance: the server's view distance from the + /// login or the last chunk-cache-radius packet. + pub(super) view_distance: i32, sequence: i32, position_sender: horizon_client::player_sync::PositionSender, last_input: u8, @@ -182,6 +186,9 @@ pub(super) struct Remote { online_mode: bool, /// Player entities in the level: entity id to profile UUID. level_players: HashMap, + /// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep + /// theirs in `Mob::glowing`. + glowing: std::collections::HashSet, /// The local player's profile UUID (always in the level). profile: u128, /// The local player's profile name: its score holder and team member. @@ -189,9 +196,15 @@ pub(super) struct Remote { destroying: Option<([i32; 3], u8, f32)>, destroy_delay: u32, input: horizon_client::interaction_input::InteractionInput, + /// Keys whose presses send a packet (swap hands, drop, a mount's + /// inventory): like the click queue, they wait for the tick's + /// `handleKeybinds` phase rather than going out between window events. + pub(super) pending_keys: Vec, stop_mining: bool, placing: Option<([i32; 3], u64)>, death_message: Option, + /// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`. + show_death_screen: bool, player_type: Option>, /// Inventory as last synchronised, for creative-mode slot updates. synced: Vec>, @@ -212,6 +225,9 @@ pub(super) struct Remote { /// `ClientboundTransferPacket`'s target: when the connection ends the /// game connects here, the stored cookies carried over (`TransferState`). transfer: Option, + /// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server + /// Links..." in the pause menu lists (only `http`/`https` URIs kept). + pub(super) server_links: Vec, } /// The time a frame spends on chunk packets before it leaves the rest of @@ -394,6 +410,9 @@ impl Game<'_> { self.carried = None; self.using = None; self.clicks.miss_time = 0; + // A new LocalPlayer has no effects: the server sends what it has + // (ClientboundUpdateMobEffectPacket on placement). + self.potions = Default::default(); self.set_mode_id(0); self.experience = Experience { level: 0, @@ -428,6 +447,7 @@ impl Game<'_> { joined: false, status: "Connecting to the server...".into(), entity_id: 0, + use_item: None, dimension: DimensionType::vanilla("minecraft:overworld"), chunk_format: Arc::new(ChunkFormat::new( &DimensionType::vanilla("minecraft:overworld"), @@ -468,14 +488,17 @@ impl Game<'_> { listed: Default::default(), online_mode: false, level_players: HashMap::new(), + glowing: Default::default(), profile: 0, profile_name: String::new(), destroying: None, destroy_delay: 0, input: Default::default(), + pending_keys: Vec::new(), stop_mining: false, placing: None, death_message: None, + show_death_screen: true, player_type, synced: vec![None; 36], decode_errors: 0, @@ -486,6 +509,7 @@ impl Game<'_> { chunk_center: (0, 0), backlog: VecDeque::new(), backlog_since: None, + server_links: Vec::new(), transfer: None, }); // The level's TickRateManager follows the server's ticking packets. @@ -862,6 +886,11 @@ impl Game<'_> { } Event::PackPush(p) => self.handle_pack_push(p), Event::PackPop(id) => self.handle_pack_pop(id), + Event::ServerLinks(links) => { + if let Some(r) = self.remote.as_mut() { + r.server_links = super::server_links::trusted(links); + } + } Event::ShowDialog(dialog) => self.show_dialog(&dialog), Event::ClearDialog => self.clear_dialog(), Event::DecodeError(e) => { @@ -1283,6 +1312,8 @@ impl Game<'_> { value: 1, age: 0, on_ground: false, + count: 1, + ticks: 0, }); } if let Some(t) = mob_type { @@ -1417,6 +1448,13 @@ impl Game<'_> { } return; } + // So does a firework rocket: its own tick flies it on. + if path.is_some() + && let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32) + { + f.position = Vec3::new(position[0], position[1], position[2]); + return; + } // A dragon fireball keeps Entity's NO_OP InterpolationHandler too: // the server's position replaces its own at once. if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) { @@ -1475,7 +1513,9 @@ impl Game<'_> { self.remove_remote_dragon(id); self.remove_remote_object(id as u32); self.remove_remote_projectile(id as u32); + self.persist.uuids.remove(&(id as u32)); if let Some(r) = self.remote.as_mut() { + r.glowing.remove(&id); r.entities.remove(&id); r.lerps.remove(&id); r.stepped.remove(&id); @@ -1569,7 +1609,7 @@ impl Game<'_> { } } /// Find a game slot in the menu's complete, contiguous slot mapping. - fn menu_slot_index(menu: &str, internal: usize) -> Option { + pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option { (0..) .map_while(|slot| Self::menu_slot(menu, slot)) .position(|slot| slot == internal) @@ -1630,10 +1670,15 @@ impl Game<'_> { } C::Login(p) => { self.level.hardcore = p.hardcore; + // handleLogin makes a new MultiPlayerGameMode (carried index 0) and a + // new LocalPlayer (slot 0). + self.selected = 0; if let Some(r) = self.remote.as_mut() { + r.carried = Some(0); r.online_mode = p.online_mode; r.entity_id = p.player_id; r.view_distance = p.chunk_radius; + r.show_death_screen = p.show_death_screen; } self.streaming.radius = self.settings.render_distance.min(p.chunk_radius + 1).max(2); @@ -1648,8 +1693,10 @@ impl Game<'_> { self.send(S::ClientInformation(info)); } C::Respawn(p) => { - // A new player, without effects: the server sends them again. + // A new player, without effects (the server sends them + // again) and with no item in use. self.potions.effects.clear(); + self.using = None; self.enter_remote_dimension(&p.spawn, true); self.screen = Screen::Playing; if let Some(r) = self.remote.as_mut() { @@ -1750,7 +1797,17 @@ impl Game<'_> { f64::from(p.y) / 8.0, f64::from(p.z) / 8.0, ]; - self.audio.play(&name, Some(at), p.volume, p.pitch); + // ClientPacketListener.handleSoundEvent plays with packet.getSource(). + match usize::try_from(p.source) + .ok() + .filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len()) + { + Some(source) => { + self.audio + .play_on(source, &name, Some(at), p.volume, p.pitch) + } + None => self.audio.play(&name, Some(at), p.volume, p.pitch), + } } } C::StopSound(p) => self @@ -1776,6 +1833,11 @@ impl Game<'_> { r.level_players.remove(&p.id); } } + // Entity.getTeam: a mob is on a team by its UUID. + self.persist.uuids.insert( + p.id as u32, + horizon_client::lan::playerdata::uuid_ints(p.uuid), + ); self.spawn_entity(p.id, &kind, p.position, rotation); // Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()). self.remote_motion(p.id, p.velocity); @@ -1867,7 +1929,8 @@ impl Game<'_> { } C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers), // ClientPacketListener.handleEntityLinkPacket: who holds a mob's - // lead (this player, another player or a mob), for the rope. + // lead (this player, another player, a mob or a fence knot), for + // the rope. C::SetEntityLink(p) => { let me = self.remote.as_ref().map(|r| r.entity_id); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) { @@ -1875,7 +1938,12 @@ impl Game<'_> { m.extra.leash = match p.dest { 0 => None, d if Some(d) == me => Some(Holder::Player), - d => Some(Holder::Mob(d as u32)), + d => match self.remote_knots.get(&(d as u32)) { + Some(&block) => Some(Holder::Knot(block)), + // Not a known knot yet: kept by id until the knot + // arrives (game/remote_objects.rs). + None => Some(Holder::Mob(d as u32)), + }, }; } } @@ -1894,7 +1962,10 @@ impl Game<'_> { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { for (index, _, value) in &p.values { match (index, value) { - (0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0, + (0, Value::Byte(flags)) => { + m.invisible = flags & 0x20 != 0; + m.glowing = flags & 0x40 != 0; + } (2, Value::OptText(name)) => { let text = name.as_ref().map(horizon_client::entity_nbt::name_text); m.custom_name = @@ -1911,6 +1982,15 @@ impl Game<'_> { } } } + if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0) + && let Some(r) = self.remote.as_mut() + { + if flags & 0x40 != 0 { + r.glowing.insert(p.id); + } else { + r.glowing.remove(&p.id); + } + } self.remote_mob_data(p.id, &p.values); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) && m.species() == horizon_client::mobs::Species::Creeper @@ -1925,6 +2005,22 @@ impl Game<'_> { } } } + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { + for (index, _, value) in &p.values { + match (index, value) { + // Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it. + (7, Value::Int(ticks)) => m.ticks_frozen = *ticks, + // Strider.DATA_SUFFOCATING (after AgeableMob's two + // and DATA_BOOST_TIME): a cold strider shakes. + (19, Value::Bool(cold)) + if m.species() == horizon_client::mobs::Species::Strider => + { + m.extra.flag = *cold; + } + _ => {} + } + } + } // LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player // too, remote_mob_data's apply reads index 8 above. let mannequin = self @@ -1984,9 +2080,9 @@ impl Game<'_> { } } } - // Entity.DATA_SHARED_FLAGS_ID's crouching bit: another - // player's or a mannequin's crouch (their model and - // Entity.isDiscrete's name tag). + // Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits: + // another player's or a mannequin's crouch (their model and + // Entity.isDiscrete's name tag), and the dust a runner raises. if (mannequin || self .remote @@ -1997,6 +2093,7 @@ impl Game<'_> { for (index, _, value) in &p.values { if let (0, Value::Byte(flags)) = (index, value) { m.extra.crouching = flags & 2 != 0; + m.extra.sprinting = flags & 8 != 0; } } } @@ -2022,13 +2119,22 @@ impl Game<'_> { // player holds and wears (EquipmentSlot ordinals). C::SetEquipment(p) => { self.remote_object_equipment(p.id, &p.slots); - let items: Vec<(u8, Option>)> = p + let items: Vec<(u8, Option>, Option)> = p .slots .iter() - .map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item)))) + .map(|(slot, stack)| { + ( + *slot, + stack.as_ref().map(|s| self.item_name(s.item)), + self.to_stack(stack), + ) + }) .collect(); if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { - for (slot, item) in items { + // The body slot (6: horse armour, llama carpet, harness) + // is kept in `head`, as Horizon's own mobs do. + let body = horizon_client::mobs::drops::head_is_body(m.species()); + for (slot, item, stack) in items { let e = &mut m.extra; match slot { 0 => e.main_hand = item, @@ -2036,7 +2142,9 @@ impl Game<'_> { 2 => e.feet = item, 3 => e.legs = item, 4 => e.chest = item, - 5 => e.head = item, + 5 if !body => e.head = item, + 6 if body => e.head = item, + 7 => m.set_saddle(stack), _ => {} } } @@ -2075,7 +2183,10 @@ impl Game<'_> { { self.add_pickup(super::pickups::Taken::Orb(Box::new(orb)), collector); } - self.remove_entity(p.item); + // An orb stays: the server may still hold merged ones in it. + if !orb { + self.remove_entity(p.item); + } } // ClientPacketListener.handleSetEntityMotion: knockback on this player. C::SetEntityMotion(p) if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) => { @@ -2104,8 +2215,9 @@ impl Game<'_> { } C::HurtAnimation(p) => { if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { + // Player.animateHurt: the tilt turns around the yaw. if let Some(pl) = self.player.as_mut() { - pl.health.animate_hurt(); + pl.health.animate_hurt_dir(p.yaw); } } else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { m.hurt_time = 10; @@ -2117,6 +2229,7 @@ impl Game<'_> { if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT { self.remote_firework_explosion(p.id); } + self.remote_fangs_event(p.id as u32, p.event); // LocalPlayer.setPermissions also refreshes an open World Options screen. if (24..=28).contains(&p.event) && self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) @@ -2141,9 +2254,76 @@ impl Game<'_> { { m.health = 0.0; } + // The events that start a mob's animation (the warden's). + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) { + horizon_client::mobs::sync::entity_event(m, p.event as u8); + } + // LivingEntity.handleEntityEvent 60: the poof as the dead body goes. + if p.event == 60 + && let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32) + { + let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height())); + self.death_poof([at.x, at.y, at.z], w, h); + } if p.event == super::totem::TOTEM_EVENT { self.remote_totem(p.id); } + // Mob.handleEntityEvent 20: the spawn poof. + if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) { + self.poof_particles(p.id as u32); + } + // Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and + // 7, Animal and Allay 18: the particles about the mob. + if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) { + self.entity_event_particles(p.id as u32, p.event); + } + // Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where + // the projectile is (the server removes it right after). + if p.event == 3 { + let crumbs = self + .creatures + .projectiles + .iter() + .find(|pr| pr.id == p.id as u32) + .and_then(|pr| { + let item = match pr.kind { + horizon_client::projectiles::ProjectileKind::Snowball => { + "minecraft:snowball" + } + horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg", + _ => return None, + }; + Some(([pr.position.x, pr.position.y, pr.position.z], item)) + }); + if let Some((at, item)) = crumbs { + self.item_burst(at, item); + } + } + if p.event == super::combat::DROWN_EVENT { + self.remote_drown(p.id); + } + // Entity event 53: the honey slide particles of a player + // sliding down a honey block (HoneyBlock.showSlideParticles). + if p.event == 53 { + let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) { + self.player + .as_ref() + .map(|q| [q.position.x, q.position.y, q.position.z]) + } else { + self.mobs + .iter() + .find(|m| m.id == p.id as u32) + .map(|m| [m.position.x, m.position.y, m.position.z]) + }; + if let Some(at) = at { + self.honey_slide_particles(at); + } + } + if p.event == super::consume::USE_ITEM_COMPLETE_EVENT + && self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) + { + self.remote_complete_using(); + } } C::Animate(p) => self.remote_animate(p.id, p.action), // ClientPacketListener.handleSwingAnimation: the entity swings @@ -2193,7 +2373,11 @@ impl Game<'_> { pl.health.flash_health_change(p.health); } self.apply_remote_health(); - if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() { + // The death screen opens (or the respawn is asked for) with the + // death packet when the rule hides it. + let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen); + if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some() + { self.screen = Screen::Dead; self.ui.death_time = 0; self.capture(false); @@ -2209,11 +2393,16 @@ impl Game<'_> { r.death_message = Some(message); } // ClientPacketListener.handlePlayerCombatKill: the death - // screen, whatever health was last sent. + // screen, whatever health was last sent, or `respawn()` at + // once when it is hidden. if me && self.screen != Screen::Dead && self.player.is_some() { - self.screen = Screen::Dead; - self.ui.death_time = 0; - self.capture(false); + if self.remote.as_ref().is_none_or(|r| r.show_death_screen) { + self.screen = Screen::Dead; + self.ui.death_time = 0; + self.capture(false); + } else { + self.respawn_player()?; + } } } C::TrackedWaypoint(p) => self.handle_waypoint(&p), @@ -2289,6 +2478,12 @@ impl Game<'_> { r.game_mode = mode; } } + // IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen. + 11 => { + if let Some(r) = self.remote.as_mut() { + r.show_death_screen = p.param == 0.0; + } + } 7 => { self.world.weather.rain_level = (p.param, p.param); self.world.weather.raining = p.param > 0.0; @@ -2306,10 +2501,11 @@ impl Game<'_> { } self.apply_remote_abilities(); } + // ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the + // carried index stays, so the next tick answers with SetCarriedItem. C::SetHeldSlot(p) => { - self.selected = p.slot.clamp(0, 8) as usize; - if let Some(r) = self.remote.as_mut() { - r.carried = Some(self.selected); + if (0..9).contains(&p.slot) { + self.selected = p.slot as usize; } } C::ContainerSetContent(p) => { @@ -2805,7 +3001,7 @@ impl Game<'_> { resolved.push((uuid, skin)); } } - resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin)); + resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin)); let Some((level, skins, own)) = self.remote.as_mut().map(|r| { r.skins = resolved.into_iter().collect(); ( @@ -2844,11 +3040,9 @@ impl Game<'_> { .iter() .filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some()) .map(|m| { - let t = m - .profile - .as_ref() - .and_then(|p| p.properties.iter().find(|p| p.name == "textures")); - let textures = t.map_or_else(Default::default, |t| { + let profile = m.profile.as_ref(); + let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures")); + let textures = if let Some(t) = t { let signature = if t.signature.is_some() { horizon_client::skins::Signature::Signed } else { @@ -2859,7 +3053,30 @@ impl Game<'_> { signature, &horizon_client::skins::TEXTURE_URIS, ) - }); + } else { + // `ResolvableProfile.resolveProfile`: no `textures` and + // only a name or an id resolves through the services. + let key = profile.and_then(|p| match (&p.name, p.uuid) { + (Some(name), None) => { + Some(horizon_client::skins::NameOrId::Name(name.clone())) + } + (None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)), + _ => None, + }); + key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) { + horizon_client::skins::Resolve::Done(Some(profile)) => { + profile.textures().map(|(value, signature)| { + horizon_client::skins::unpack( + &value, + signature, + &horizon_client::skins::TEXTURE_URIS, + ) + }) + } + _ => None, + }) + .unwrap_or_default() + }; (m.id, textures) }) .collect(); @@ -2874,7 +3091,7 @@ impl Game<'_> { resolved.push((id, skin)); } for (id, skin) in resolved { - let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s)); + let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s)); if let Some(mob) = self .mobs .iter_mut() @@ -2924,6 +3141,10 @@ impl Game<'_> { self.skin_slots.push(e); true } + /// `show_skin`, or — its slot evicted — the image queued for upload again. + fn again_skin(&mut self, location: &str) -> bool { + self.show_skin(location) || self.skin_downloads.borrow_mut().again(location) + } /// ChatType decoration: `chat.translation_key` with sender/content parameters. /// ChatType.Bound.decorate: the chat type's translation, with the /// sender, the target and the message as its parameters, in its style. @@ -3029,6 +3250,8 @@ impl Game<'_> { b.velocity = v; } else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) { t.velocity = v; + } else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) { + r.velocity = v; } else if let Some(p) = self .remote .as_mut() @@ -3142,13 +3365,18 @@ impl Game<'_> { fn tick_remote_level(&mut self) -> Result<()> { self.ticks += 1; + // AbstractSignEditScreen.tick: the editor closes once its sign is removed. + if let Some(edit) = &self.sign_edit + && !matches!( + self.world.block_entities.get(&edit.pos), + Some(BlockEntity::Sign(_)) + ) + { + self.close_sign_editor(); + } self.track_experience_display(); - // Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s - // for a server's client (multiplayer and the default single player), - // so the entries advance here or the slot stays shaded until the - // server says the entry ended. `onCooldownEnded` is the plain - // ItemCooldowns' no-op on a client. - self.cooldowns.tick(); + // Leaving a slot clears its bundle's selection here and on the server. + self.tick_bundle_hover(); // Options.broadcastOptions: a changed render distance or Chat option // is sent again. let view = client_view_distance(self.settings.render_distance); @@ -3182,6 +3410,14 @@ impl Game<'_> { // Window callbacks only queue clicks, so an interaction cannot leak // between CLIENT_TICK_END and a later pong. self.remote_handle_input(); + // Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after + // handleKeybinds: this path replaces `tick_world`'s for a server's + // client (multiplayer and the default single player), so the entries + // advance here or the slot stays shaded until the server says the + // entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on + // a client. Ticking it after the keybinds keeps a held use from + // trying again a tick before the server's entry ends. + self.cooldowns.tick(); // ClientLevel.tick: the border's size change. self.border.tick(); self.player_border(); @@ -3193,6 +3429,8 @@ impl Game<'_> { self.with_level(|b, level, ticks| { b.client_tick_block_entities(level, ticks); }); + let pushes = std::mem::take(&mut self.block_ticks.piston_pushes); + self.push_piston_entities(pushes); self.sync_moving_pistons(); self.tick_hand(); self.tick_particles(); @@ -3303,6 +3541,11 @@ impl Game<'_> { resources: self.motion, } .controlled_simulation_tick(keys)?; + } else { + // LivingEntity.baseTick counts hurtTime down for a dead or riding + // player too (controlled_simulation_tick does it otherwise): left + // at 10, the hurt tilt restarted every frame and shook the camera. + player.health.advance_clock(); } // LocalPlayer.onUpdateAbilities: flight turned on or off (a double // jump, a spectator, landing) is sent to the server. @@ -3313,13 +3556,12 @@ impl Game<'_> { let events = std::mem::take(&mut player.events); self.previous_eye = self.eye; self.eye += (player.dimensions.eye_height - self.eye) * 0.5; - let crouching = player.pose == horizon_client::pose::Pose::Crouching; let feet = [player.position.x, player.position.y, player.position.z]; for event in events { match event { horizon_client::motion::MotionEvent::Emission { state, in_water, .. - } if !crouching => { + } => { let sounds = self.res.facts().get(state).sounds.clone(); // Player.playStepSound: muffled under the swim sound in water. let (volume, pitch) = if in_water { @@ -3333,12 +3575,27 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.swim", Some(feet), volume, pitch); } + horizon_client::motion::MotionEvent::Chime { volume, pitch } => { + self.audio.play( + "minecraft:block.amethyst_block.chime", + Some(feet), + volume, + pitch, + ); + } // NetherPortalBlock.entityInside on the client's own level: // the portal overlay and its trigger sound (the server moves // the player). horizon_client::motion::MotionEvent::InsidePortal { .. } => { self.client_inside_nether_portal(); } + // HoneyBlock.maybeDoSlideEffects on the client's own level: + // the slide sound, heard here (LocalPlayer.playSound); the + // particles come from the server's event 53. + horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => { + self.audio + .play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0); + } _ => {} } } @@ -3492,6 +3749,7 @@ impl Game<'_> { m.fluids = fluids; } } + let game_time = self.world.game_time; let Some(r) = self.remote.as_mut() else { return; }; @@ -3500,11 +3758,15 @@ impl Game<'_> { let head_lerps = &mut r.head_lerps; let players = &r.level_players; let mut using = Vec::new(); + let mut wither_heads = Vec::new(); for m in &mut self.mobs { let player = players.contains_key(&(m.id as i32)); if !running && !player { continue; } + if m.species() == horizon_client::mobs::Species::Wither { + wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i))); + } m.previous = m.position; m.previous_yaw = m.yaw; m.previous_body_yaw = m.body_yaw; @@ -3515,6 +3777,16 @@ impl Game<'_> { if m.species() == horizon_client::mobs::Species::Rabbit { horizon_client::mobs::rabbit::base_tick(m); } + if matches!( + m.species(), + horizon_client::mobs::Species::Horse + | horizon_client::mobs::Species::Donkey + | horizon_client::mobs::Species::Mule + | horizon_client::mobs::Species::SkeletonHorse + | horizon_client::mobs::Species::ZombieHorse + ) { + horizon_client::mobs::equine::tail_tick(m); + } if m.species() == horizon_client::mobs::Species::Creeper { horizon_client::mobs::hostile::creeper_client_tick(m); } @@ -3525,6 +3797,10 @@ impl Game<'_> { ) { horizon_client::mobs::guardian::client_tick(m); } + // PolarBear.tick: the stand animation follows the synched flag. + if m.species() == horizon_client::mobs::Species::PolarBear { + horizon_client::mobs::polar_bear::client_tick(m); + } if m.hurt_time > 0 { m.hurt_time -= 1; } @@ -3563,7 +3839,7 @@ impl Game<'_> { m.extra.main_hand.clone() }; if let Some(item) = item { - let eye = if m.extra.crouching { 1.27 } else { 1.62 }; + let eye = m.eye_height(); let at = [m.position.x, m.position.y, m.position.z]; using.push(( item, @@ -3597,6 +3873,8 @@ impl Game<'_> { if m.species() == horizon_client::mobs::Species::Bat { horizon_client::mobs::bat::setup_animation_states(m); } + // The animation states a server computes in its own `ai_step`. + horizon_client::mobs::sync::client_tick(m, game_time); } for o in self.orbs.iter_mut().filter(|_| running) { o.previous = o.position; @@ -3621,10 +3899,24 @@ impl Game<'_> { d.tick(world, facts, collision, false, &self.res.motion().fluids); } head_lerps.retain(|_, (_, steps)| *steps > 0); + // WitherBoss.aiStep runs on a client too: smoke around each head. + let smoke = horizon_client::particles::ParticleOptions::simple( + horizon_client::particles::ParticleType::Smoke, + ); + for h in wither_heads { + let r = self.particles.random(); + let at = [ + h[0] + r.next_gaussian() * 0.3, + h[1] + r.next_gaussian() * 0.3, + h[2] + r.next_gaussian() * 0.3, + ]; + self.particles.add(&smoke, at, [0.0; 3]); + } for (item, ticks, at, eye, pitch, yaw) in using { self.remote_player_eating(&item, ticks, at, eye, pitch, yaw); } if running { + self.guardian_bubbles(); // The dragon: LivingEntity's interpolation, inside its aiStep. let dragon_lerp = self .dragon @@ -3879,6 +4171,9 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.destroying = Some((hit.pos, face, 0.0)); } + // startDestroyBlock plays the first hit and crack itself; the + // server's level events 2019 and 2020 do the rest. + self.breaking_block_effects(hit.pos, hit.face, true); self.mining = Some(Mining { pos: hit.pos, progress: 0.0, @@ -3891,20 +4186,6 @@ impl Game<'_> { }; let done = done + progress; let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1; - if ticks % 4 == 1 { - let c = [ - f64::from(pos[0]) + 0.5, - f64::from(pos[1]) + 0.5, - f64::from(pos[2]) + 0.5, - ]; - self.audio.play( - &facts.sounds.hit, - Some(c), - (facts.sounds.volume + 1.0) / 8.0, - facts.sounds.pitch * 0.5, - ); - } - self.crack_particle(pos, hit.face, state); if done >= 1.0 { let seq = self.next_sequence(); self.predict_break(pos, state, seq); @@ -4064,18 +4345,15 @@ impl Game<'_> { world_border: false, sequence: seq, })); - let target = [ - hit.pos[0] + hit.face[0], - hit.pos[1] + hit.face[1], - hit.pos[2] + hit.face[2], - ]; - let ticks = self.ticks; + // Only a predicted placement (predict_place) marks a cell whose + // resend plays a place sound: an empty hand or a non-block item + // places nothing (BlockItem.place is where the sound comes from). if let Some(r) = self.remote.as_mut() { r.use_target = Some(hit.pos); - r.placing = Some((target, ticks)); } if reacts { self.predict_use(&changes, seq); + self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2])); } else if let Some(stack) = held.as_ref() && self.predict_item_on_block(stack, &hit) { @@ -4198,6 +4476,16 @@ impl Game<'_> { if self.keys.contains(&KeyCode::ShiftLeft) && hands_full { return (false, DryUse::default()); } + // BeehiveBlock.useItemOn: shears or a glass bottle on a full hive + // (honey level 5) is SUCCESS, so the click ends after UseItemOn and + // no UseItem follows (the server does the harvest, see `use_beehive`). + if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle")) + && self + .hive_state(hit.pos) + .is_some_and(|(honey, _)| honey == Some(5)) + { + return (true, DryUse::default()); + } use horizon_core::geometry::Direction; let face = match hit.face { [0, 1, 0] => Direction::Up, @@ -4307,13 +4595,27 @@ impl Game<'_> { let Some(info) = self.res.items().get(&held.item).cloned() else { return false; }; - // MultiPlayerGameMode.useItem runs the use here too (inside - // startPrediction): the `use_cooldown` component starts its entry, and - // the server's ClientboundCooldownPacket confirms it. - self.apply_use_cooldown(&held); - // Food starts being eaten (Consumable.startConsuming), a book and - // quill opens. - self.with_hand(hand, |g| g.start_eating(&info)); + // MultiPlayerGameMode.useItem's prediction: an item cooling down + // passes (its packet still goes). + let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref()); + if self.cooldowns.is_on_cooldown(&group) { + return false; + } + // Food starts being eaten (Consumable.startConsuming). A food that + // can't be eaten (full and not always-edible) fails the use + // (InteractionResult.FAIL): startUseItem tries the other hand, and + // no cooldown or use animation starts. + let eating = self.with_hand(hand, |g| g.start_eating(&info)); + if info.food.is_some() && !eating { + return false; + } + // It runs the use here too: ItemStack.use starts the `use_cooldown` + // of an item with no use time, and the server's + // ClientboundCooldownPacket confirms it; one held in use starts it at + // finishUsingItem (remote_complete_using). + if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) { + self.apply_use_cooldown(&held); + } if self.with_hand(hand, |g| g.remote_use_book(&held)) { return true; } @@ -4385,14 +4687,25 @@ impl Game<'_> { self.remote_use_item(Hand::Off); } /// `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. + /// use (`held_until_released`), and a consumable's eating animation and + /// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the + /// stack, its hunger and the sounds. fn remote_tick_using(&mut self) { let Some((slot, ticks)) = self.using else { return; }; let stack = self.stack_in(slot).cloned(); + // The use stops when the hand no longer holds the item it began with + // (ItemStack.isSameItem). + let item = stack.as_ref().map(|s| Arc::clone(&s.item)); + if let Some(r) = self.remote.as_mut() { + if ticks == 0 { + r.use_item = item; + } else if r.use_item != item { + self.using = None; + return; + } + } // Another hotbar slot picked: LivingEntity.updatingUsingItem sees a // different stack in the hand. The off hand keeps its item. let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT; @@ -4417,20 +4730,51 @@ impl Game<'_> { self.using = None; return; } - // Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. - let duration = consumable.ticks().max(0) as u32; - let remaining = duration - ticks.min(duration); - if consumable.particles && consumable.should_emit(remaining as i32) { + // Consumable.startConsuming eats one with no use time at once, without + // a use (nor a USE_ITEM_COMPLETE). + if consumable.ticks() <= 0 { + self.using = None; + return; + } + // Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The + // countdown goes on past zero (LivingEntity.updateUsingItem completes + // only on the server) until the server's USE_ITEM_COMPLETE. + let remaining = consumable.ticks() - ticks as i32; + if consumable.particles && consumable.should_emit(remaining) { self.eat_particles(&stack, 5); } - if ticks + 1 >= duration { - self.using = None; - if consumable.particles { - self.eat_particles(&stack, 16); - } - } else { - self.using = Some((slot, ticks + 1)); + self.using = Some((slot, ticks + 1)); + } + /// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume, + /// which runs on the client too (Consumable.onConsume calls its + /// ConsumableListeners on both sides): the client's FoodData eats at once, + /// so a held right click finds `Player.canEat` false instead of starting + /// another bite before the server's ClientboundSetHealth arrives. + fn remote_complete_using(&mut self) { + let Some((slot, _)) = self.using.take() else { + return; + }; + let Some(stack) = self.stack_in(slot).cloned() else { + return; + }; + let Some(info) = self.res.items().get(&stack.item).cloned() else { + return; + }; + if info.consumable.as_ref().is_some_and(|c| c.particles) { + self.eat_particles(&stack, 16); } + if let Some((nutrition, saturation)) = info.food + && let Some(r) = self.remote.as_mut() + { + // FoodData.eat(nutrition, saturation), as finish_consuming does; + // the next ClientboundSetHealth replaces it. + let (_, food, sat) = &mut r.health; + *food = (*food + nutrition as i32).clamp(0, 20); + *sat = (*sat + saturation).clamp(0.0, *food as f32); + self.apply_remote_health(); + } + // ItemStack.finishUsingItem's applyAfterUseComponentSideEffects. + self.apply_use_cooldown(&stack); } /// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick. pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> { @@ -4495,6 +4839,31 @@ impl Game<'_> { } let (attack, use_item) = (self.left, self.right); input.tick(&mut RemoteInputWorld(self), attack, use_item); + // handleKeybinds' keybind polls: the queued presses act now, in tick + // order, so no packet goes out between CLIENT_TICK_END and a pong. + let pending = self + .remote + .as_mut() + .map(|r| std::mem::take(&mut r.pending_keys)) + .unwrap_or_default(); + for key in pending { + match key { + KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(), + KeyCode::KeyQ if !self.spectator() => { + let all = self.keys.contains(&KeyCode::ControlLeft); + self.remote_drop(all); + } + KeyCode::KeyE if self.riding_mount_with_menu() => { + let id = self.remote.as_ref().map_or(0, |r| r.entity_id); + self.send(S::PlayerCommand(play::PlayerCommand { + id, + action: 5, + data: 0, + })); + } + _ => {} + } + } if self.clicks.miss_time > 0 { self.clicks.miss_time -= 1; } @@ -4606,22 +4975,8 @@ impl Game<'_> { if self.spectator() { return Ok(true); } - let all = self.keys.contains(&KeyCode::ControlLeft); - // MultiPlayerGameMode.dropItem: predict removal before sending - // the action, then swing only if a nonempty stack was removed. - let dropped = horizon_client::items::take_held_drop( - &mut self.inventory[self.selected], - all, - false, - ); - self.send(S::PlayerAction(play::PlayerAction { - action: if all { 4 } else { 5 }, - pos: [0; 3], - direction: 0, - sequence: 0, - })); - if dropped.is_some() { - self.swing_hand(Hand::Main, SwingAnimation::DEFAULT); + if let Some(r) = self.remote.as_mut() { + r.pending_keys.push(key); } Ok(true) } @@ -4629,12 +4984,9 @@ impl Game<'_> { // horse or llama, the server opens its inventory // (ServerboundPlayerCommandPacket OPEN_INVENTORY). KeyCode::KeyE if self.riding_mount_with_menu() => { - let id = self.remote.as_ref().map_or(0, |r| r.entity_id); - self.send(S::PlayerCommand(play::PlayerCommand { - id, - action: 5, - data: 0, - })); + if let Some(r) = self.remote.as_mut() { + r.pending_keys.push(key); + } Ok(true) } KeyCode::KeyE => { @@ -4681,6 +5033,21 @@ impl Game<'_> { })); } } + /// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send + /// the action; swing only if a nonempty stack was removed. + fn remote_drop(&mut self, all: bool) { + let dropped = + horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false); + self.send(S::PlayerAction(play::PlayerAction { + action: if all { 4 } else { 5 }, + pos: [0; 3], + direction: 0, + sequence: 0, + })); + if dropped.is_some() { + self.swing_hand(Hand::Main, SwingAnimation::DEFAULT); + } + } /// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands /// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and /// sends both slots back; nothing is predicted. @@ -4714,7 +5081,7 @@ impl Game<'_> { } /// The server's open menu: its window id and menu type (0, "inventory" /// when none is open). - fn remote_menu(&self) -> (i32, String) { + pub(super) fn remote_menu(&self) -> (i32, String) { match self.remote.as_ref().and_then(|r| r.menu.clone()) { Some((id, name)) => (id, name), None => (0, "inventory".to_owned()), @@ -4804,26 +5171,6 @@ impl Game<'_> { })); } } - /// A `ContainerClick` on game slot `internal` of the server's open menu - /// (`input` is the ContainerInput ordinal). False when the menu has no - /// such slot. - pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool { - let (container, menu) = self.remote_menu(); - let slot = Self::menu_slot_index(&menu, internal); - let Some(slot) = slot else { return false }; - let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s)); - let state = self.remote.as_ref().map_or(0, |r| r.state_id); - self.send(S::ContainerClick(play::ContainerClick { - container, - state, - slot: slot as i16, - button, - input, - changed: Vec::new(), - carried, - })); - true - } /// The server's open menu is the inventory or a chest-like container /// (chests, barrels, ender chests: `generic_9xN`; shulker boxes). pub(super) fn remote_storage_open(&self) -> bool { @@ -5202,6 +5549,11 @@ impl Game<'_> { batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true); } } + /// Shared flag 6 of an entity on a server's client (`None` without a server). + pub(super) fn remote_glowing(&self, id: u32) -> Option { + let r = self.remote.as_ref()?; + Some(r.glowing.contains(&(id as i32))) + } /// Status for `horizon-ctl status`. /// The other players' entities in the level and their names (from the /// player list). diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index c6702dfb6..f85a96119 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -4,8 +4,9 @@ //! own simulation, which a client of a server doesn't run. use super::Game; use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES}; -use horizon_client::mobs::{Mob, Species, Who, bat, tame}; -use horizon_protocol::entity_data::Value; +use horizon_client::mobs::{Mob, Species, Who, bat, camel, sniffer, sync, tame}; +use horizon_client::particles::{ParticleOptions, ParticleType}; +use horizon_protocol::entity_data::{Particle, ParticleData, Value}; use horizon_protocol::registry::Builtin; /// The values `apply` takes from one SetEntityData packet, by vanilla's entry @@ -16,10 +17,18 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti let baby = super::lan::baby_index(&m.kind.id); for (index, _, value) in values { match (*index, value) { + // Entity.DATA_POSE: a player's or mannequin's box follows it. + (6, Value::Id(pose)) + if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") => + { + m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose); + } // Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a // client, which draws the flames. A client doesn't count the // fire down: the flag goes off when the server's fire is out. (0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1), + // Entity.DATA_POSE: the pose the species animates by. + (6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8), // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the // off hand 2. The flag starts and stops the use; the client ticks // the count itself (a bow's pull follows it). @@ -34,6 +43,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti // LivingEntity.DATA_HEALTH_ID: the boss bars and the death // animation follow it. (9, Value::Float(health)) => m.health = *health, + // LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: + // the swirls of its effects (`tickEffects` draws them). + (10, Value::Particles(list)) => m.extra.effect_particles = swirls(list, builtin), + (11, Value::Bool(ambient)) => m.extra.effect_ambient = *ambient, // Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4. // Mob.isAggressive draws the illagers' and skeletons' attack // poses and the vindicator's axe. @@ -59,6 +72,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti Who::Mob(*target as u32) }); } + // PolarBear.DATA_STANDING_ID: the client animates the rearing. + (Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing, // Bat.DATA_ID_FLAGS: resting in bit 0. (Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0), // Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared. @@ -66,6 +81,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.extra.color = (*wool & 15) as u8; m.extra.sheared = wool & 16 != 0; } + // SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on. + (Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0, + // MushroomCow.DATA_TYPE: 0 red, 1 brown. + (Species::Mooshroom, 17, Value::Int(variant)) => { + m.extra.variant = (*variant == 1) as u8 + } + // Piglin.DATA_IS_DANCING: the dance pose. + (Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing), // TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4. (Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => { m.extra.sitting = flags & 1 != 0; @@ -79,6 +102,29 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti (Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => { m.extra.size = (*size).clamp(1, 127); } + // Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace. + (Species::Warden, 16, Value::Int(anger)) => { + if let Some(st) = m.extra.warden.as_mut() { + st.client_anger = *anger; + } + } + // Sniffer.DATA_STATE (after AgeableMob's two): the animation + // follows it in `sync::client_tick`. + (Species::Sniffer, 18, Value::Id(state)) => { + sniffer::set_state(m, (*state).clamp(0, 6) as u8); + } + // Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID. + (Species::Shulker, 16, Value::Id(face)) => m.extra.variant3 = (*face).clamp(0, 5) as u8, + (Species::Shulker, 17, Value::Byte(peek)) => m.extra.counter = i32::from(*peek), + (Species::Shulker, 18, Value::Byte(color)) => m.extra.color = *color as u8, + // Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's + // flags). + (Species::Camel | Species::CamelHusk, 19, Value::Bool(dashing)) => { + camel::set_dashing(m, *dashing); + } + (Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => { + camel::set_synced_pose_tick(m, *tick); + } // Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's // IS_CELEBRATING takes 16). (Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging, @@ -100,6 +146,30 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti } } +/// The options of a synched particle list: an entity effect carries its ARGB +/// colour, the omens none. +fn swirls(list: &[Particle], builtin: &Builtin) -> Vec { + let channel = |argb: i32, shift: u32| ((argb >> shift) & 255) as f32 / 255.0; + list.iter() + .filter_map(|p| { + let name = builtin.get("minecraft:particle_type")?.name(p.id)?; + let ty = ParticleType::from_name(name)?; + Some(match p.data { + Some(ParticleData::Color(argb)) => ParticleOptions::colored( + ty, + [ + channel(argb, 16), + channel(argb, 8), + channel(argb, 0), + channel(argb, 24), + ], + ), + _ => ParticleOptions::simple(ty), + }) + }) + .collect() +} + /// VillagerData as the network sends it: the type and the profession by /// their built-in registry ids, then the level. fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) { diff --git a/crates/horizon-client/src/game/remote_mob_use.rs b/crates/horizon-client/src/game/remote_mob_use.rs index 9575cf925..bd38c7301 100644 --- a/crates/horizon-client/src/game/remote_mob_use.rs +++ b/crates/horizon-client/src/game/remote_mob_use.rs @@ -66,8 +66,11 @@ impl Game<'_> { | Species::Salmon | Species::TropicalFish | Species::Pufferfish - | Species::Axolotl - | Species::Tadpole => item == "minecraft:water_bucket", + | Species::Axolotl => item == "minecraft:water_bucket", + // Tadpole.mobInteract: frog food, else the bucket. + Species::Tadpole => { + item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item) + } Species::Armadillo => item == "minecraft:brush", // ZombieVillager.mobInteract: CONSUME for a golden apple. Species::ZombieVillager => item == "minecraft:golden_apple", diff --git a/crates/horizon-client/src/game/remote_objects.rs b/crates/horizon-client/src/game/remote_objects.rs index 05b1a2b9e..7f484c35f 100644 --- a/crates/horizon-client/src/game/remote_objects.rs +++ b/crates/horizon-client/src/game/remote_objects.rs @@ -1,9 +1,10 @@ -//! Boats, minecarts, armor stands, item frames and paintings on a server's +//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's //! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`, //! `handleSetEquipment`): the server simulates them, the client places them //! where it says and interpolates their moves like a mob's. use super::Game; use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting}; +use horizon_client::mobs::leash::Holder; use horizon_core::geometry::Vec3; use horizon_protocol::entity_data::Value; use std::sync::Arc; @@ -24,7 +25,17 @@ impl Game<'_> { let at = Vec3::new(position[0], position[1], position[2]); let block = position.map(|v| v.floor() as i32); self.remove_remote_object(id); - if kind == "minecraft:text_display" { + if kind == "minecraft:leash_knot" { + // A lead that came before its knot waits as a mob holder with the + // knot's id (handleEntityLinkPacket's delayed holder); now it can + // be tied to the fence. + self.remote_knots.insert(id, block); + for m in &mut self.mobs { + if m.extra.leash == Some(Holder::Mob(id)) { + m.extra.leash = Some(Holder::Knot(block)); + } + } + } else if kind == "minecraft:text_display" { self.text_displays.insert( id as i32, super::text_displays::RemoteTextDisplay::new(position, yaw), @@ -304,6 +315,7 @@ impl Game<'_> { /// Every object a server sent (a new connection, level or dimension). pub(super) fn clear_remote_objects(&mut self) { self.text_displays.clear(); + self.remote_knots.clear(); self.carts.clear(); self.boats.clear(); self.armor_stands.clear(); @@ -314,6 +326,7 @@ impl Game<'_> { pub(super) fn remove_remote_object(&mut self, id: u32) { self.text_displays.remove(&(id as i32)); + self.remote_knots.remove(&id); self.carts.retain(|c| c.id != id); self.boats.retain(|b| b.id != id); self.armor_stands.retain(|a| a.id != id); diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 133c2b9fd..409c28c88 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -3,8 +3,11 @@ //! and `handleSetEntityData`): the server simulates them, the client draws //! them where it says and interpolates their moves. use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector}; -use horizon_client::particles::{ParticleOptions, ParticleType}; use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld}; +use horizon_client::{ + collisions::SceneCollisions, + particles::{ParticleOptions, ParticleType}, +}; use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource}; use horizon_protocol::entity_data::Value; use horizon_world::registry::BlockStateId; @@ -23,6 +26,59 @@ fn default_cloud_particle() -> ParticleOptions { ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4]) } +/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles +/// near the middle; then about one per square block of the disc, drifting +/// (an entity effect stays still). +pub(super) fn cloud_client_tick( + particles: &mut horizon_client::particles::ParticleEngine, + at: [f64; 3], + radius: f32, + waiting: bool, + particle: &ParticleOptions, +) { + if waiting && particles.random().next_boolean() { + return; + } + let (count, spread) = if waiting { + (2, 0.2) + } else { + ( + (std::f32::consts::PI * radius * radius).ceil() as i32, + radius, + ) + }; + for _ in 0..count { + let r = particles.random(); + let angle = r.next_float() * std::f32::consts::TAU; + let distance = r.next_float().sqrt() * spread; + let at = [ + at[0] + f64::from(angle.cos() * distance), + at[1], + at[2] + f64::from(angle.sin() * distance), + ]; + let (options, velocity) = if particle.ty == ParticleType::EntityEffect { + let default = waiting && r.next_boolean(); + let options = if default { + default_cloud_particle() + } else { + particle.clone() + }; + (options, [0.0; 3]) + } else if waiting { + (particle.clone(), [0.0; 3]) + } else { + let v = [ + (0.5 - r.next_double()) * 0.15, + f64::from(0.01f32), + (0.5 - r.next_double()) * 0.15, + ]; + (particle.clone(), v) + }; + // `addAlwaysVisibleParticle`. + particles.add_with(&options, at, velocity, false, true); + } +} + /// The projectile a type id names, as Horizon models it. fn projectile_kind(kind: &str) -> Option { Some(match kind { @@ -77,8 +133,36 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { } p.velocity.y -= 0.05; } +/// A server's client ticks a projectile itself: it flies on between the +/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag, +/// and a block stops it there; only the server decides what it hits. +pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) { + if !matches!(p.tick(w).1, Hit::None) { + p.velocity = Vec3::ZERO; + } +} + +/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them +/// but the wind charges (no trail) and the dragon's fireball (its own). +pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool { + matches!( + kind, + ProjectileKind::SmallFireball + | ProjectileKind::LargeFireball { .. } + | ProjectileKind::WitherSkull { .. } + ) +} impl Game<'_> { + /// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a + /// block above each position. + pub(super) fn smoke_trails(&mut self, at: impl IntoIterator) { + let smoke = ParticleOptions::simple(ParticleType::Smoke); + for p in at { + self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]); + } + } + /// `handleAddEntity` for these types; false when `kind` isn't one. pub(super) fn spawn_remote_projectile( &mut self, @@ -95,6 +179,7 @@ impl Game<'_> { let (yaw, pitch) = rotation; self.remove_remote_projectile(id); match kind { + "minecraft:evoker_fangs" => self.insert_fangs(id, position, yaw, 0, 0), "minecraft:tnt" => self.tnts.push(super::tnt::PrimedTnt { id, position: at, @@ -163,12 +248,16 @@ impl Game<'_> { let Some(k) = projectile_kind(kind) else { return false; }; + if k == ProjectileKind::LlamaSpit { + self.particles + .llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]); + } let mut p = Projectile::new(k, at, None); p.id = id; p.velocity = velocity; p.yaw = yaw; p.pitch = pitch; - self.creatures.projectiles.push(p); + self.add_projectile(p); } } true @@ -326,6 +415,7 @@ impl Game<'_> { /// One tick of interpolation, as `tick_remote_objects`. pub(super) fn tick_remote_projectiles(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) { + self.tick_remote_fangs(); // PrimedTnt.tick runs on a client too: it falls, bounces and burns its // fuse down (the swell and flash follow it) between the server's // updates (every 10 ticks), and a client discards it when the fuse @@ -372,11 +462,7 @@ impl Game<'_> { p.age += 1; p.position = p.position.add_vector(p.velocity); } - _ => { - if !matches!(p.tick(&world).1, Hit::None) { - p.velocity = Vec3::ZERO; - } - } + _ => remote_projectile_tick(p, &world), } } for t in &mut tridents { @@ -385,31 +471,66 @@ impl Game<'_> { } self.creatures.projectiles = projectiles; self.tridents = tridents; - // FireworkRocketEntity.tick: a rocket attached to the gliding local - // player boosts it client-side too (attachedToEntity.isFallFlying). + let trails: Vec = self + .creatures + .projectiles + .iter() + .filter(|p| has_smoke_trail(&p.kind)) + .map(|p| p.position) + .collect(); + self.smoke_trails(trails); + // FireworkRocketEntity.tick: a rocket attached to an entity follows it + // and boosts it client-side too (attachedToEntity.isFallFlying), and + // every rocket leaves its spark trail on a client. let me = self .remote .as_ref() .and_then(|r| u32::try_from(r.entity_id).ok()); - for r in &mut self.rockets { - let id = r.id; - r.remote_step(|at| step(id, at)); - if me.is_some() - && r.attached_to == me - && let Some(p) = self.player.as_mut() - && p.fall_flying - { - let look = view_vector(self.yaw, self.pitch); - let m = p.velocity; - p.velocity = Vec3::new( - m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, - m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, - m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, - ); - r.position = p.position; - r.velocity = p.velocity; + let mut rockets = std::mem::take(&mut self.rockets); + for r in &mut rockets { + r.remote_step(&mut SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }); + if let Some(target) = r.attached_to { + if me == Some(target) { + if let Some(p) = self.player.as_mut() { + if p.fall_flying { + let look = view_vector(self.yaw, self.pitch); + let m = p.velocity; + p.velocity = Vec3::new( + m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5, + m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5, + m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5, + ); + } + r.position = p.position; + r.velocity = p.velocity; + } + } else if let Some(m) = self.mobs.iter().find(|m| m.id == target) { + // Another player's motion is not known here: its step. + r.velocity = Vec3::new( + m.position.x - r.previous.x, + m.position.y - r.previous.y, + m.position.z - r.previous.z, + ); + r.position = m.position; + } } + let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05; + let v = [ + g(&mut self.world.random), + -r.velocity.y * 0.5, + g(&mut self.world.random), + ]; + self.particles.add( + &ParticleOptions::simple(ParticleType::Firework), + [r.position.x, r.position.y, r.position.z], + v, + ); } + self.rockets = rockets; for c in &mut self.crystals { c.time += 1; c.position = step(c.id, c.position); @@ -439,56 +560,15 @@ impl Game<'_> { } } - /// `AreaEffectCloud.clientTick`: while waiting, now and then two - /// particles near the middle; then about one per square block of the - /// disc, drifting (an entity effect stays still). + /// `AreaEffectCloud.clientTick` for each cloud a server tracks. fn tick_remote_clouds(&mut self) { for c in &self.potions.clouds { - let r = self.particles.random(); - if c.waiting && r.next_boolean() { - continue; - } - let (count, spread) = if c.waiting { - (2, 0.2) - } else { - ( - (std::f32::consts::PI * c.radius * c.radius).ceil() as i32, - c.radius, - ) - }; - for _ in 0..count { - let r = self.particles.random(); - let angle = r.next_float() * std::f32::consts::TAU; - let distance = r.next_float().sqrt() * spread; - let at = [ - c.at[0] + f64::from(angle.cos() * distance), - c.at[1], - c.at[2] + f64::from(angle.sin() * distance), - ]; - let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect { - let default = c.waiting && r.next_boolean(); - let options = if default { - default_cloud_particle() - } else { - c.particle.clone() - }; - (options, [0.0; 3]) - } else if c.waiting { - (c.particle.clone(), [0.0; 3]) - } else { - let v = [ - (0.5 - r.next_double()) * 0.15, - f64::from(0.01f32), - (0.5 - r.next_double()) * 0.15, - ]; - (c.particle.clone(), v) - }; - self.particles.add(&options, at, velocity); - } + cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle); } } pub(super) fn remove_remote_projectile(&mut self, id: u32) { + self.fangs.list.retain(|f| f.id != id); self.tnts.retain(|t| t.id != id); self.falling.retain(|f| f.id != id); self.creatures.projectiles.retain(|p| p.id != id); @@ -504,6 +584,7 @@ impl Game<'_> { /// Every one a server sent (a new connection, level or dimension). pub(super) fn clear_remote_projectiles(&mut self) { + self.fangs.list.clear(); self.tnts.clear(); self.falling.clear(); self.creatures.projectiles.clear(); @@ -524,6 +605,12 @@ impl Game<'_> { use serde_json::json; let at = |p: Vec3| [p.x, p.y, p.z]; let mut out = Vec::new(); + for f in &self.fangs.list { + out.push( + json!({"id": f.id, "kind": "minecraft:evoker_fangs", "position": f.pos, + "yaw": f.yaw, "started": f.started, "life": f.life}), + ); + } for t in &self.tnts { out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse})); } @@ -550,3 +637,68 @@ impl Game<'_> { out } } + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::mobs::Who; + use horizon_core::geometry::Aabb; + + /// A world with nothing in it: the client tick of a projectile only + /// needs the blocks around it. + struct Empty; + impl ProjectileWorld for Empty { + fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { + None + } + fn in_water(&self, _: Vec3) -> bool { + false + } + fn targets(&self) -> &[(Who, Aabb)] { + &[] + } + } + + /// A thrown item, as a server's client tracks it: the spawn position and + /// the motion the server gave it. + fn thrown(kind: ProjectileKind) -> Projectile { + let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), None); + p.velocity = Vec3::new(1.5, 0.0, 0.0); + p + } + + #[test] + fn a_thrown_item_flies_on_between_the_server_updates() { + // A thrown egg, an ender pearl and a wind charge (#1762): the server + // sends their position every 10 ticks, and the client ticks them + // itself in between, as vanilla's no-op interpolation handler does. + for kind in [ + ProjectileKind::Egg, + ProjectileKind::EnderPearl, + ProjectileKind::WindCharge, + ] { + let mut p = thrown(kind); + remote_projectile_tick(&mut p, &Empty); + assert!(p.position.x > 1.0, "the projectile did not fly on: {p:?}"); + // A second tick moves it further: it keeps flying, not a hop. + let first = p.position.x; + remote_projectile_tick(&mut p, &Empty); + assert!(p.position.x > first, "the projectile stopped: {p:?}"); + } + } + + #[test] + fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() { + // ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99. + let mut p = thrown(ProjectileKind::Egg); + remote_projectile_tick(&mut p, &Empty); + assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}"); + assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}"); + // A wind charge keeps its speed: no gravity and no drag + // (AbstractWindCharge: acceleration 0, inertia 1). + let mut p = thrown(ProjectileKind::WindCharge); + remote_projectile_tick(&mut p, &Empty); + assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}"); + assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}"); + } +} diff --git a/crates/horizon-client/src/game/render_control.rs b/crates/horizon-client/src/game/render_control.rs index 0a2f9b715..7c5ff4b2d 100644 --- a/crates/horizon-client/src/game/render_control.rs +++ b/crates/horizon-client/src/game/render_control.rs @@ -19,33 +19,7 @@ pub const HELP: &[&str] = &[ impl Game<'_> { /// Parse `name[k=v,...]` into a registry state (defaults for missing properties). fn parse_state(&self, text: &str) -> Result { - let (name, props) = match text.split_once('[') { - Some((n, rest)) => (n, rest.trim_end_matches(']')), - None => (text, ""), - }; - let name = if name.contains(':') { - name.to_owned() - } else { - format!("minecraft:{name}") - }; - let registry = &self.pack.assets.registry; - let default = registry - .default_state(&name) - .ok_or_else(|| format!("unknown block {name}"))?; - let mut state = registry - .state(default) - .ok_or("missing default state")? - .clone(); - for pair in props.split(',').filter(|p| !p.is_empty()) { - let (k, v) = pair - .split_once('=') - .ok_or_else(|| format!("bad property {pair}"))?; - if !state.properties.contains_key(k) { - return Err(format!("{name} has no property {k}").into()); - } - state.properties.insert(k.to_owned(), v.to_owned()); - } - Ok(registry.resolve(&state)?) + parse_state(self.pack.assets.registry, text) } pub(super) fn render_command(&mut self, words: &[&str]) -> Result> { let int = |i: usize| -> Result { @@ -137,3 +111,36 @@ impl Game<'_> { } } } + +/// Parse `name[k=v,...]` into a registry state (defaults for missing properties). +pub(super) fn parse_state( + registry: &horizon_world::registry::BlockRegistry, + text: &str, +) -> Result { + let (name, props) = match text.split_once('[') { + Some((n, rest)) => (n, rest.trim_end_matches(']')), + None => (text, ""), + }; + let name = if name.contains(':') { + name.to_owned() + } else { + format!("minecraft:{name}") + }; + let default = registry + .default_state(&name) + .ok_or_else(|| format!("unknown block {name}"))?; + let mut state = registry + .state(default) + .ok_or("missing default state")? + .clone(); + for pair in props.split(',').filter(|p| !p.is_empty()) { + let (k, v) = pair + .split_once('=') + .ok_or_else(|| format!("bad property {pair}"))?; + if !state.properties.contains_key(k) { + return Err(format!("{name} has no property {k}").into()); + } + state.properties.insert(k.to_owned(), v.to_owned()); + } + Ok(registry.resolve(&state)?) +} diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index e013c8d29..fecb58615 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -7,6 +7,7 @@ use horizon_client::{ biome::Biomes, blocks::{BlockFacts, Fluid}, chunk::{HEIGHT, MIN_Y}, + containers::BlockEntity, particles::{ParticleLevel, ParticleOptions, ParticleType}, world::World, }; @@ -244,6 +245,7 @@ impl Game<'_> { 1.0, ); } + self.particles.particle_level = self.settings.particles; if let Some(e) = eye { self.particles.camera = e; } @@ -287,12 +289,16 @@ impl Game<'_> { e[2].floor() as i32, ]; animate_tick(self.res.animate(), &mut self.particles, &ctx, block); - if rain > 0.0 { - // ClientLevel.tickWeatherEffects: rain splashes (weather radius 10). + // The Horizon Weather option off means no rain visuals or sounds at all. + if rain > 0.0 && self.settings.horizon.weather { + // ClientLevel.tickWeatherEffects: rain splashes within the weather + // radius, and the rain sound. let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231)); - let radius = 10; + let radius = self.settings.weather_radius; let diameter = 2 * radius + 1; - let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32; + let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32 + / if self.settings.particles == 1 { 2 } else { 1 }; + let mut rain_position = None; for _ in 0..count { let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius; let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius; @@ -305,6 +311,12 @@ impl Game<'_> { continue; } let below = top - 1; + rain_position = Some([x, below, z]); + // The Minimal option stops at the first spot that rains (the + // rain sound still plays there). + if self.settings.particles >= 2 { + break; + } let (bx, bz) = (random.next_double(), random.next_double()); let state = world.block(x, below, z); let f = facts.get(state); @@ -336,6 +348,26 @@ impl Game<'_> { self.particles .add(&ParticleOptions::simple(ty), p, [0.0; 3]); } + // The last rain column of the tick makes the sound, more often the + // longer it has been quiet; indoors it is the quieter one. + if let Some(at) = rain_position { + let waited = self.rain_sound_time; + self.rain_sound_time += 1; + if random.next_int_bound(3).unwrap_or(0) < waited { + self.rain_sound_time = 0; + let roof = + motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT); + let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] { + ("minecraft:weather.rain.above", 0.1, 0.5) + } else { + ("minecraft:weather.rain", 0.2, 1.0) + }; + let p = at.map(|c| f64::from(c) + 0.5); + // playLocalSound: guests make their own rain, so a host + // must not capture it for them. + self.audio.play_unshared(name, Some(p), volume, pitch); + } + } } } if let Some(r) = &self.renderer { @@ -344,6 +376,10 @@ impl Game<'_> { &mut self.particles, &ctx, r.block_entities(), + &|pos| match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) { + Some(BlockEntity::Campfire(c)) => c.items.each_ref().map(Option::is_some), + _ => [false; 4], + }, ); } let level = ParticleLevel { diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 81c03e288..53323fdbb 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -132,8 +132,18 @@ impl Game<'_> { match t { Target::Player => Some(self.acting_player_name().to_owned()), Target::Guest(conn) => self.command_guest(conn).map(|g| g.name.clone()), - Target::Mob(id) | Target::Item(id) => Some(uuid_text(self.uuid_for(id))), - _ => None, + Target::Mob(id) + | Target::Item(id) + | Target::Stand(id) + | Target::Frame(id) + | Target::Painting(id) + | Target::Boat(id) + | Target::Cart(id) + | Target::Crystal(id) + | Target::Dragon(id) + | Target::Other(id) => Some(uuid_text(self.uuid_for(id))), + // Orbs and projectiles have no UUID here. + Target::Orb(_) | Target::Projectile(_) => None, } } fn holder_for(&mut self, t: Target) -> Option { @@ -180,7 +190,11 @@ impl Game<'_> { | Target::Cart(id) | Target::Other(id) => self.persist.raw.get(&id), Target::Player => self.level.player_extra.as_ref(), - Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None, + Target::Guest(_) + | Target::Orb(_) + | Target::Projectile(_) + | Target::Crystal(_) + | Target::Dragon(_) => None, }; match raw.and_then(|r| r.get("Tags")) { Some(Tag::List(l)) => l diff --git a/crates/horizon-client/src/game/screens.rs b/crates/horizon-client/src/game/screens.rs index f8885f83a..4b5c4c333 100644 --- a/crates/horizon-client/src/game/screens.rs +++ b/crates/horizon-client/src/game/screens.rs @@ -19,6 +19,9 @@ impl Screen { | Self::Advancements | Self::WorldOptions | Self::Friends + | Self::ServerLinks + | Self::ConfirmLink + | Self::DialogWarning | Self::Mod => true, Self::Playing | Self::GameModeSwitcher @@ -35,20 +38,35 @@ impl Screen { } /// PauseScreen buttons: (x, y, width, label key, action id, active). +/// `links`: the `minecraft:server_links` dialog's button, which the +/// `pause_screen_additions` tag (empty in vanilla) falls back to when the +/// server sent links — a full-width row that pushes the last two rows down. /// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen -fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> { +fn pause_buttons( + w: f32, + h: f32, + remote: bool, + links: bool, +) -> Vec<(f32, f32, f32, &'static str, u8, bool)> { // GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25). - let (grid_w, grid_h) = (212.0, 166.0); + let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 }); let x0 = ((w - grid_w) * 0.5).floor(); let y0 = ((h - grid_h) * 0.25).floor(); - vec![ + let down = if links { 24.0 } else { 0.0 }; + let mut v = vec![ (x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true), (x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true), (x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true), - (x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true), + ]; + if links { + // CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS. + v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true)); + } + v.extend([ + (x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true), ( x0 + 110.0, - y0 + 122.0, + y0 + 122.0 + down, 98.0, "options.worldOptions.button", 9, @@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s ), ( x0 + 4.0, - y0 + 146.0, + y0 + 146.0 + down, 204.0, if remote { "menu.disconnect" @@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s 2, true, ), - ] + ]); + v } /// The icon row: report bugs, feedback, friends, player reporting. /// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's /// Play with Friends; the others stay inactive. const FRIENDS_ICON: usize = 2; -fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] { +fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] { let x0 = ((w - 212.0) * 0.5).floor() + 60.0; - let y = ((h - 166.0) * 0.25).floor() + 98.0; + let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0; [ (x0, y, "gui/sprites/pause_menu/bug"), (x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"), @@ -120,7 +139,9 @@ impl Game<'_> { u32::MAX, true, ); - for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) { + let links = !self.server_links().is_empty(); + let mut tooltip = None; + for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) { let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0); ui::button( batch, @@ -133,9 +154,12 @@ impl Game<'_> { active, hover, ); + // PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP. + if id == 11 && hover { + tooltip = Some(Text::translate("menu.custom_options.tooltip", &[])); + } } - let mut tooltip = None; - for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() { + for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() { let active = i == FRIENDS_ICON && friends; let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0); // The icon has no label: name it for controller focus and the @@ -168,7 +192,13 @@ impl Game<'_> { } // SpriteIconButton.withTootip: the label as a tooltip. if let Some(tip) = tooltip { - ui::tooltip(batch, atlas, &[tip], self.cursor); + let lines: Vec = atlas + .font + .split_lines(&tip, batch.width) + .into_iter() + .map(|(line, _)| line) + .collect(); + ui::tooltip(batch, atlas, &lines, self.cursor); } } pub(super) fn pause_click(&mut self) -> Result<()> { @@ -180,8 +210,9 @@ impl Game<'_> { let b = HudBatch::new(size); (b.width, b.height) }; - let friends = pause_icons(w, h)[FRIENDS_ICON]; - let hit = pause_buttons(w, h, self.external_server()) + let links = !self.server_links().is_empty(); + let friends = pause_icons(w, h, links)[FRIENDS_ICON]; + let hit = pause_buttons(w, h, self.external_server(), links) .into_iter() .find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0)) .map(|b| b.4) @@ -200,6 +231,11 @@ impl Game<'_> { Some(4) => self.open_advancements(true), Some(9) => self.open_world_options(), Some(10) => self.screen = Screen::Friends, + // DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS). + Some(11) => { + self.links_ui.scroll = 0.0; + self.screen = Screen::ServerLinks; + } Some(2) => self.close_world()?, _ => {} } @@ -286,6 +322,58 @@ impl Game<'_> { ); } } + /// LocalPlayer.respawn: the player comes back (the death screen's + /// button, or at once with `immediate_respawn`). + pub(super) fn respawn_player(&mut self) -> Result<()> { + if self.remote.is_none() { + // PlayerList.respawn removes the dead player, and Player.remove's + // inventoryMenu.removed drops what the cursor held, whatever + // keep_inventory says (the 20-tick branch handles a later respawn). + if let Some(stack) = self.carried.take() + && let Some(eye) = self.eye_position() + { + self.throw_from_eyes(stack, eye, false); + } + let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); + self.experience.respawn(keep); + // The guests are sent the respawned host as a new entity, not the dead body. + if let Some(host) = self.lan.as_mut() { + let id = host.host_entity; + host.hub.respawned(id); + } + // Autosaves keep a login position; death must resolve the respawn point instead. + self.level.position = None; + } + if self.remote.is_some() { + self.ui.death_text = None; + self.toggles.respawn(); + self.remote_respawn(); + } else if self.respawn_config.is_some() { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + // PlayerList.respawn at the respawn anchor, once it is loaded. + self.begin_anchor_respawn()?; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } else { + // LocalPlayer.respawn: KeyMapping.resetToggleKeys. + self.toggles.respawn(); + self.respawn_out_of_end()?; + self.player = None; + self.screen = Screen::Playing; + self.ui.death_text = None; + self.spawn_player()?; + self.capture(true); + } + // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's + // respawned player only watches. + if self.remote.is_none() && self.level.hardcore { + self.set_mode(GameMode::Spectator); + } + Ok(()) + } pub(super) fn death_click(&mut self) -> Result<()> { if self .ui @@ -303,51 +391,11 @@ impl Game<'_> { .into_iter() .find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0)) .map(|d| d.3); - if hit == Some(0) && self.remote.is_none() { - let keep = self.ui.rules.bool("keep_inventory") || self.spectator(); - self.experience.respawn(keep); - // The guests are sent the respawned host as a new entity, not the dead body. - if let Some(host) = self.lan.as_mut() { - let id = host.host_entity; - host.hub.respawned(id); - } - // Autosaves keep a login position; death must resolve the respawn point instead. - self.level.position = None; - } match hit { - Some(0) if self.remote.is_some() => { - self.ui.death_text = None; - self.toggles.respawn(); - self.remote_respawn(); - } - Some(0) if self.respawn_config.is_some() => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - // PlayerList.respawn at the respawn anchor, once it is loaded. - self.begin_anchor_respawn()?; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } - Some(0) => { - // LocalPlayer.respawn: KeyMapping.resetToggleKeys. - self.toggles.respawn(); - self.respawn_out_of_end()?; - self.player = None; - self.screen = Screen::Playing; - self.ui.death_text = None; - self.spawn_player()?; - self.capture(true); - } + Some(0) => self.respawn_player()?, Some(1) => self.close_world()?, _ => {} } - // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's - // respawned player only watches. - if hit == Some(0) && self.remote.is_none() && self.level.hardcore { - self.set_mode(GameMode::Spectator); - } if hit.is_some() { self.audio.play_button_click(); } @@ -357,7 +405,7 @@ impl Game<'_> { #[cfg(test)] mod tests { - use super::{Screen, pause_buttons}; + use super::{Screen, pause_buttons, pause_icons}; #[test] fn menu_blur_matches_vanilla_screen_backgrounds() { @@ -390,11 +438,32 @@ mod tests { #[test] fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() { for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] { - let buttons = pause_buttons(427.0, 240.0, remote); + let buttons = pause_buttons(427.0, 240.0, remote, false); let quit = buttons.iter().find(|b| b.4 == 2).unwrap(); assert_eq!(quit.3, label); assert!(quit.5); assert!(buttons.iter().any(|b| b.4 == 9 && b.5)); } } + + #[test] + fn server_links_adds_a_full_width_row_above_options() { + let with = pause_buttons(427.0, 240.0, true, true); + let without = pause_buttons(427.0, 240.0, true, false); + let links = with.iter().find(|b| b.4 == 11).unwrap(); + assert_eq!(links.2, 204.0); + assert_eq!(links.3, "menu.server_links"); + assert!(links.5); + // The icon row sits above it; Options and Disconnect move down 24. + let icons = pause_icons(427.0, 240.0, true); + let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0; + assert_eq!(icons[0].1, y0_icons); + assert!(links.1 > icons[0].1); + for b in &with { + if b.4 == 1 || b.4 == 9 || b.4 == 2 { + let same = without.iter().find(|o| o.4 == b.4).unwrap(); + assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3); + } + } + } } diff --git a/crates/horizon-client/src/game/sculk.rs b/crates/horizon-client/src/game/sculk.rs index 7916c46a1..65dfede1a 100644 --- a/crates/horizon-client/src/game/sculk.rs +++ b/crates/horizon-client/src/game/sculk.rs @@ -1194,14 +1194,14 @@ impl Game<'_> { /// `LightmapRenderStateExtractor`: the night vision effect's intensity /// (`GameRenderer.nightVisionScale`), or conduit power's water vision. - pub(super) fn night_vision_intensity(&self) -> f32 { + pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 { use horizon_client::fog::FogType; if let Some(e) = self.potions.effects.get("night_vision") { // MobEffectInstance.endsWithin(200); -1 is infinite. return if e.duration < 0 || e.duration > 200 { 1.0 } else { - 0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3 + 0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3 }; } if self.potions.effects.get("conduit_power").is_some() @@ -1515,6 +1515,16 @@ impl Game<'_> { fn tick_entity_events(&mut self) { self.tick_player_sculk_events(); self.tick_mob_sculk_events(); + // Dropped items are entities too: Entity.applyEffectsFromBlocks. + let items: Vec<[f64; 3]> = self + .dropped + .iter() + .filter(|i| i.on_ground) + .map(|i| [i.position.x, i.position.y, i.position.z]) + .collect(); + for at in items { + self.step_on_redstone_ore(at, false); + } } /// Entity.move emits on the authoritative server too, for each ServerPlayer. @@ -1530,6 +1540,9 @@ impl Game<'_> { }); if let Some((at, on_ground, health, flying)) = player { let crouch = self.keys.contains(&KeyCode::ShiftLeft); + if on_ground && !self.spectator() { + self.step_on_redstone_ore(at, crouch); + } let in_water = self.in_water_at(at); self.entity_motion_events( Who::Player, @@ -1548,6 +1561,26 @@ impl Game<'_> { } } + /// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the + /// entity is stepping carefully (`attack_block` only lights an unlit ore). + fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) { + if carefully { + return; + } + // getOnPosLegacy: 0.2 below the feet. + let pos = [ + at[0].floor() as i32, + (at[1] - 0.2).floor() as i32, + at[2].floor() as i32, + ]; + if matches!( + &*self.block_name(pos), + "minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore" + ) { + let _ = self.attack_block(pos); + } + } + fn tick_mob_sculk_events(&mut self) { let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option)> = self .mobs @@ -1565,6 +1598,9 @@ impl Game<'_> { }) .collect(); for (id, at, on_ground, in_water, species, damaged) in snapshot { + if on_ground { + self.step_on_redstone_ore(at, false); + } let emits = !matches!(species, Species::Shulker); if emits { self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false); diff --git a/crates/horizon-client/src/game/server_links.rs b/crates/horizon-client/src/game/server_links.rs new file mode 100644 index 000000000..85fda71d6 --- /dev/null +++ b/crates/horizon-client/src/game/server_links.rs @@ -0,0 +1,487 @@ +//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens +//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's +//! fallback when a server sent ClientboundServerLinksPacket): a scrollable +//! list of the server's links, each opened through `ConfirmLinkScreen` +//! (Screen.clickUrlAction), and the `dialog/warning_button`'s +//! DialogScreen.WarningScreen ("Note about custom screens"). +//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen +use super::{Game, Screen, packs::open_uri}; +use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui}; +use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind}; + +/// HeaderAndFooterLayout's header and footer heights; the content wants to +/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`). +const HEADER: f32 = 33.0; +const FOOTER: f32 = 33.0; +const CONTENT_TOP: f32 = HEADER + 30.0; +/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200. +const LINK_WIDTH: f32 = 310.0; +/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons. +const ROW: f32 = 22.0; +/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6). +const SCROLL_RESERVE: f32 = 10.0; +/// DialogScreen's `dialog/warning_button` ImageButton. +const WARNING_BUTTON: f32 = 20.0; + +/// The ServerLinks screen's state: ScrollableLayout's scroll and the link +/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`). +#[derive(Default)] +pub(super) struct LinksUi { + pub scroll: f32, + pub confirm: Option, +} + +/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI +/// (java.net.URI's scheme then the allowlist; whitespace-free). +pub(super) fn valid_untrusted_uri(uri: &str) -> bool { + let Some(colon) = uri.find(':') else { + return false; + }; + let scheme = &uri[..colon]; + let mut chars = scheme.chars(); + if !chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + || !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + { + return false; + } + if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") { + return false; + } + !uri.chars().any(|c| c.is_control() || c == ' ') +} +/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links +/// that validate, warning about the dropped ones. +pub(super) fn trusted(links: Vec) -> Vec { + links + .into_iter() + .filter(|l| { + let ok = valid_untrusted_uri(&l.link); + if !ok { + log::warn!("Horizon: server sent an invalid link: {}", l.link); + } + ok + }) + .collect() +} +/// `ServerLinks.Entry.displayName`: `known_server_link.` for a known +/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else. +fn link_label(link: &ServerLink) -> Text { + const KNOWN: [&str; 10] = [ + "report_bug", + "community_guidelines", + "support", + "status", + "feedback", + "community", + "website", + "forums", + "news", + "announcements", + ]; + match &link.kind { + ServerLinkKind::Known(id) => Text::translate( + &format!( + "known_server_link.{}", + KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0]) + ), + &[], + ), + ServerLinkKind::Custom(tag) => { + horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag)) + } + } +} + +/// The scrolled column of link buttons: content height and, for a list that +/// fits, the `HeaderAndFooterLayout` contents frame's top. +fn list_height(n: usize) -> f32 { + n as f32 * ROW - 2.0 +} +fn list_top(h: f32, n: usize) -> f32 { + CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER)) +} +fn scroll_limit(h: f32, n: usize) -> f32 { + (list_height(n) - (h - HEADER - FOOTER)).max(0.0) +} + +/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the +/// footer `Back` button's corner. `top` includes the scroll. +fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> { + let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor(); + (0..n).map(|i| (x, top + i as f32 * ROW)).collect() +} +/// The warning button beside the title (DialogScreen's title row: 10 pixels +/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds). +fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) { + let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0; + let y = ((HEADER - WARNING_BUTTON) / 2.0).floor(); + if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON { + (0.0f32.max(w - 40.0), 5.0f32.min(h)) + } else { + (x, y) + } +} +fn back_button(w: f32, h: f32) -> (f32, f32) { + ( + ((w - 200.0) / 2.0).floor(), + h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(), + ) +} + +impl Game<'_> { + /// `connection.serverLinks()`: the links the open server sent (empty for + /// a world without a connection or one that sent none). + pub(super) fn server_links(&self) -> &[ServerLink] { + self.remote + .as_ref() + .map_or(&[], |r| r.server_links.as_slice()) + } + /// DialogScreen over the pause menu: `menu.server_links.title`, the + /// scrollable link buttons and `gui.back` in the footer. + pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let links = self.server_links().to_vec(); + self.links_ui.scroll = self + .links_ui + .scroll + .clamp(0.0, scroll_limit(h, links.len())); + let top = list_top(h, links.len()) - self.links_ui.scroll.trunc(); + // The title row: the title, then 10 pixels on the warning button. + let title = Text::translate("menu.server_links.title", &[]); + let tw = atlas.font.width_text(&title); + let tx = ((w - tw - 30.0) / 2.0).floor(); + batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true); + let (wx, wy) = warning_button(w, h, tw); + let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON); + horizon_client::widgets::record( + "menu.custom_screen_info.tooltip", + [wx, wy, WARNING_BUTTON, WARNING_BUTTON], + true, + ); + batch.sprite_scaled( + atlas, + if wh { + "gui/sprites/dialog/warning_button_highlighted" + } else { + "gui/sprites/dialog/warning_button" + }, + wx, + wy, + WARNING_BUTTON, + WARNING_BUTTON, + u32::MAX, + ); + let first = batch.vertices.len(); + let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER; + for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) { + let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0); + ui::button( + batch, + atlas, + x, + y, + LINK_WIDTH, + 20.0, + &link_label(link), + true, + hover, + ); + } + batch.clip_since(first, 0.0, HEADER, w, h - FOOTER); + ui::scrollbar( + batch, + atlas, + (w / 2.0).floor() + 155.0 + 4.0, + HEADER, + h - FOOTER, + list_height(links.len()), + self.links_ui.scroll.trunc(), + ); + let (bx, by) = back_button(w, h); + let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0); + ui::button( + batch, + atlas, + bx, + by, + 200.0, + 20.0, + &Text::translate("gui.back", &[]), + true, + hover, + ); + if wh { + ui::tooltip( + batch, + atlas, + &[Text::translate("menu.custom_screen_info.tooltip", &[])], + self.cursor, + ); + } + } + /// A click on `Screen::ServerLinks`: a link, the warning button or Back. + pub(super) fn server_links_click(&mut self) -> Result<()> { + let (w, h) = self.gui_size(); + let links = self.server_links().to_vec(); + let top = list_top(h, links.len()) - self.links_ui.scroll.trunc(); + let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER; + if inside + && let Some(i) = link_buttons(w, top, links.len()) + .iter() + .position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0)) + { + self.audio.play_button_click(); + self.click_link(&links[i].link); + return Ok(()); + } + let title = Text::translate("menu.server_links.title", &[]); + let tw = self.gui.atlas.font.width_text(&title); + let (wx, wy) = warning_button(w, h, tw); + if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) { + self.audio.play_button_click(); + self.screen = Screen::DialogWarning; + return Ok(()); + } + let (bx, by) = back_button(w, h); + if ui::hovered(self.cursor, bx, by, 200.0, 20.0) { + self.audio.play_button_click(); + self.screen = Screen::Paused; + } + Ok(()) + } + /// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on + /// Links" asks with ConfirmLinkScreen, else the browser opens directly. + fn click_link(&mut self, url: &str) { + if !self.settings.chat_links { + return; + } + if self.settings.chat_links_prompt { + self.links_ui.confirm = Some(url.to_owned()); + self.screen = Screen::ConfirmLink; + } else { + open_uri(url); + } + } + /// `ScrollableLayout`'s mouse wheel over the links. + pub(super) fn server_links_scroll(&mut self, delta: f32) { + let (_, h) = self.gui_size(); + self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0) + .clamp(0.0, scroll_limit(h, self.server_links().len())); + } + /// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the + /// following website?", the link, the red warning, and Yes / Copy Link to + /// Clipboard / No. + pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let url = self.links_ui.confirm.clone().unwrap_or_default(); + let layout = confirm_link_layout(atlas, (w, h), &url); + for (line, x, y) in &layout.lines { + batch.text_styled(atlas, line, *x, *y, u32::MAX, true); + } + let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"]; + for ((x, y), key) in layout.buttons.iter().zip(labels) { + let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0); + ui::button( + batch, + atlas, + *x, + *y, + 100.0, + 20.0, + &Text::translate(key, &[]), + true, + hover, + ); + } + } + /// ConfirmLinkScreen's answer: yes opens the browser, the middle button + /// copies the link, no closes; all return to Server Links. + pub(super) fn confirm_link_click(&mut self) { + let (w, h) = self.gui_size(); + let url = self.links_ui.confirm.clone().unwrap_or_default(); + let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url); + for (i, (x, y)) in layout.buttons.iter().enumerate() { + if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) { + continue; + } + self.audio.play_button_click(); + match i { + 0 => open_uri(&url), + 1 => { + self.clipboard.set(&url); + } + _ => {} + } + self.screen = Screen::ServerLinks; + return; + } + } + /// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect + /// button ("Custom screen rejected" is the shown reason) and Back. + pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) { + let atlas = &self.gui.atlas; + let (w, h) = (batch.width, batch.height); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + let layout = dialog_warning_layout(atlas, (w, h)); + for (line, x, y) in &layout.lines { + batch.text_styled(atlas, line, *x, *y, u32::MAX, true); + } + let quit = if self.external_server() { + "menu.disconnect" + } else { + "menu.returnToMenu" + }; + for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) { + let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0); + ui::button( + batch, + atlas, + x, + y, + 150.0, + 20.0, + &Text::translate(key, &[]), + true, + hover, + ); + } + } + /// WarningScreen's answer: disconnect, or back to Server Links. + pub(super) fn dialog_warning_click(&mut self) -> Result<()> { + let (w, h) = self.gui_size(); + let layout = dialog_warning_layout(&self.gui.atlas, (w, h)); + for (i, (x, y)) in layout.buttons.iter().enumerate() { + if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) { + continue; + } + self.audio.play_button_click(); + if i == 0 { + // DialogConnectionAccess.disconnect: leave with the custom + // screens' reason. + let reason = self.translate( + "menu.custom_screen_info.disconnect", + "Custom screen rejected", + ); + self.leave_server(Some(reason))?; + } else { + self.screen = Screen::ServerLinks; + } + return Ok(()); + } + Ok(()) + } +} + +/// A confirm screen's laid-out text (title, message, extra warning lines) and +/// button corners; the whole column centred (ConfirmScreen.init). +struct ConfirmLayout { + lines: Vec<(Text, f32, f32)>, + buttons: Vec<(f32, f32)>, +} +/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at +/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16. +fn confirm_layout( + font: &horizon_client::font::Font, + (w, h): (f32, f32), + title: &Text, + message: &Text, + extra: &[Text], + button_widths: &[f32], +) -> ConfirmLayout { + let message: Vec = font + .split_lines(message, w - 50.0) + .into_iter() + .take(15) + .map(|(line, _)| line) + .collect(); + // LinearLayout vertical spacing 8; the button row's children pad 16 on top. + let extra_h = 17.0 * extra.len() as f32; + let mh = 9.0 * message.len() as f32; + let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0; + let mut y = ((h - total) / 2.0).trunc(); + let mut lines = Vec::new(); + let mut push = |line: &Text, at: f32| { + let lw = font.width_text(line); + lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at)); + }; + push(title, y); + y += 17.0; + for line in &message { + push(line, y); + y += 9.0; + } + for line in extra { + y += 8.0; + push(line, y); + y += 9.0; + } + let row_w: f32 = button_widths.iter().sum::() + 4.0 * (button_widths.len() - 1) as f32; + let by = y + 8.0 + 16.0; + let mut bx = ((w - row_w) / 2.0).trunc(); + let buttons = button_widths + .iter() + .map(|bw| { + let p = (bx, by); + bx += bw + 4.0; + p + }) + .collect(); + ConfirmLayout { lines, buttons } +} +fn confirm_link_layout( + atlas: &horizon_client::hud::HudAtlas, + size: (f32, f32), + url: &str, +) -> ConfirmLayout { + // ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the + // message, `chat.link.warning` in its red (0xFFCCCC). + confirm_layout( + &atlas.font, + size, + &Text::translate("chat.link.confirm", &[]), + &Text::literal(url.to_owned()), + &[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)], + &[100.0; 3], + ) +} +fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout { + confirm_layout( + &atlas.font, + size, + &Text::translate("menu.custom_screen_info.title", &[]), + &Text::translate("menu.custom_screen_info.contents", &[]), + &[], + &[150.0; 2], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn untrusted_uris_are_http_or_https_with_a_scheme() { + for ok in [ + "https://example.net/x", + "http://example.net", + "HTTPS://EXAMPLE.NET", + ] { + assert!(valid_untrusted_uri(ok), "{ok}"); + } + for bad in [ + "example.net/no-scheme", + "ftp://example.net", + "file:///etc/passwd", + "javascript:alert(1)", + "https://exa mple.net", + "://example.net", + "", + ] { + assert!(!valid_untrusted_uri(bad), "{bad}"); + } + } +} diff --git a/crates/horizon-client/src/game/server_mods.rs b/crates/horizon-client/src/game/server_mods.rs index db88639eb..a5a4b0f3f 100644 --- a/crates/horizon-client/src/game/server_mods.rs +++ b/crates/horizon-client/src/game/server_mods.rs @@ -47,19 +47,129 @@ impl Game<'_> { ) -> bool { false } + pub(super) fn apply_mod_block_writes(&mut self) {} } #[cfg(feature = "mods")] mod run { use super::{Game, LanHost, ModEvent, Who}; + use horizon_client::chunk_map::ChunkMap; use horizon_client::{ command::args::GameMode as Mode, lan::data::component_nbt, text::{self, Text}, }; - use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer}; + use horizon_mods::{ + ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView, + }; use horizon_protocol::packets::play::{self, Clientbound as C}; - use horizon_world::registry::BlockStateId; + use horizon_world::registry::{BlockRegistry, BlockStateId}; + use serde_json::{Value, json}; + + /// Blocks queued for levels that don't tick (another dimension, in-frame) + /// are kept up to this many; the oldest go first. + const MAX_QUEUED_WRITES: usize = 1 << 16; + + /// Capability `world`'s view during a call: the levels' chunks, moved out + /// of the game for the call and back after it (no copy). + struct LentWorld { + registry: &'static BlockRegistry, + biomes: std::sync::Arc, + dims: Vec<(&'static str, ChunkMap)>, + } + impl WorldView for LentWorld { + fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option { + let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?; + let chunk = chunks.get(&(x >> 4, z >> 4))?; + Some(state_string(self.registry, chunk.block(x & 15, y, z & 15))) + } + fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option { + let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?; + let chunk = chunks.get(&(x >> 4, z >> 4))?; + Some(self.biomes.get(chunk.biome(x, y, z)).name.clone()) + } + fn has_dimension(&self, dimension: &str) -> bool { + matches!( + dimension, + "minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end" + ) + } + fn check_state(&self, state: &str) -> Result<(), String> { + crate::game::render_control::parse_state(self.registry, state) + .map(drop) + .map_err(|e| format!("{state}: {e}")) + } + } + + /// Server mods' commands as a root of the commands report + /// (`lan::commands`): a literal per command, then its arguments in a + /// chain, each node runnable where only optional arguments follow. + fn command_tree<'a>(commands: impl Iterator) -> Value { + let mut root = serde_json::Map::new(); + for c in commands { + let mut next: Option<(String, Value)> = None; + for (i, a) in c.arguments.iter().enumerate().rev() { + let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional); + let (parser, properties) = match a.kind { + ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})), + ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})), + ArgumentKind::Integer((min, max)) => { + let mut p = serde_json::Map::new(); + if let Some(min) = min { + p.insert("min".into(), min.into()); + } + if let Some(max) = max { + p.insert("max".into(), max.into()); + } + ("brigadier:integer", Value::Object(p)) + } + ArgumentKind::Number => ("brigadier:double", Value::Null), + ArgumentKind::Boolean => ("brigadier:bool", Value::Null), + ArgumentKind::Player => ( + "minecraft:entity", + json!({"amount": "single", "type": "players"}), + ), + ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null), + }; + let mut node = json!({ + "type": "argument", + "parser": parser, + "properties": properties, + "executable": executable, + }); + if let Some((name, child)) = next.take() { + node["children"] = json!({ name: child }); + } + next = Some((a.name.clone(), node)); + } + let executable = c.arguments.first().is_none_or(|a| a.optional); + let mut literal = json!({"type": "literal", "executable": executable}); + if let Some((name, child)) = next { + literal["children"] = json!({ name: child }); + } + root.insert(c.name.clone(), literal); + } + if root.is_empty() { + return Value::Null; + } + json!({"type": "root", "children": root}) + } + + /// `minecraft:oak_log[axis=y]`. + fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String { + let Some(s) = registry.state(state) else { + return "minecraft:air".into(); + }; + if s.properties.is_empty() { + return s.name.clone(); + } + let props: Vec = s + .properties + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect(); + format!("{}[{}]", s.name, props.join(",")) + } /// `UUID.toString()`. fn uuid_string(u: u128) -> String { @@ -138,12 +248,14 @@ mod run { // A server has no frames to check its dev mods in. self.reload_dev_mods(); } + self.send_server_mod_commands(); if !self.mods.server { return; } let players = self.server_players(None); let snapshot = self.mod_snapshot_with(players.clone()); self.mods.mods.set_snapshot(snapshot); + self.lend_world(); let before = std::mem::take(&mut self.mods.players); for p in before .iter() @@ -197,8 +309,94 @@ mod run { } } self.mods.mods.server_tick(self.ticks); + self.return_world(); self.mods.players = players; self.show_mod_output(); + self.apply_mod_block_writes(); + } + + /// Players' command trees list the running server mods' commands + /// after vanilla's, whenever they change (or a hub opens). + fn send_server_mod_commands(&mut self) { + let key = self.mods.mods.generation(); + let Some(host) = self.lan.as_mut() else { + return; + }; + if host.hub.mod_commands_key() == Some(key) { + return; + } + let tree = command_tree(self.mods.mods.server_commands()); + host.hub.set_mod_commands(key, tree); + } + + /// Lends the levels' chunks to capability `world` for the calls up to + /// [`Game::return_world`], when a running server mod has it. + fn lend_world(&mut self) { + if !self.mods.world { + return; + } + let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))]; + for (dim, chunks) in self.levels.parked_chunks() { + dims.push((dim.id(), std::mem::take(chunks))); + } + let registry = self.pack.assets.registry; + let biomes = self.res.biomes().clone(); + self.mods.mods.lend_world(Box::new(LentWorld { + registry, + biomes, + dims, + })); + } + + /// Puts back the chunks [`Game::lend_world`] lent. + fn return_world(&mut self) { + let Some(view) = self.mods.mods.take_world() else { + return; + }; + let view: Box = view; + let Ok(lent) = view.downcast::() else { + return; + }; + let mut dims = lent.dims; + if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) { + self.world.chunks = dims.swap_remove(i).1; + } + for (dim, chunks) in self.levels.parked_chunks() { + if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) { + *chunks = dims.swap_remove(i).1; + } + } + } + + /// Makes the blocks server mods set in the level in place, in order, + /// as `/setblock` does. Those of other dimensions wait for their level. + pub(in crate::game) fn apply_mod_block_writes(&mut self) { + if self.mods.block_writes.is_empty() { + return; + } + let dim = self.dim.id(); + let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes) + .into_iter() + .partition(|(d, _)| d == dim); + self.mods.block_writes = later; + let registry = self.pack.assets.registry; + for (_, b) in now { + let state = match crate::game::render_control::parse_state(registry, &b.state) { + Ok(state) => state, + Err(e) => { + log::warn!("Horizon: a server mod set {}: {e}", b.state); + continue; + } + }; + if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) { + log::debug!( + "Horizon: a server mod's block at {} {} {}: {e:?}", + b.x, + b.y, + b.z + ); + } + } } /// A player broke (or placed) `state` at `pos`: the player acting now. @@ -240,8 +438,11 @@ mod run { }; let snapshot = self.mod_snapshot_with(players); self.mods.mods.set_snapshot(snapshot); + self.lend_world(); let owned = self.mods.mods.server_command(&me, line); + self.return_world(); self.show_mod_output(); + self.apply_mod_block_writes(); owned } @@ -266,10 +467,14 @@ mod run { }; let snapshot = self.mod_snapshot_with(players); self.mods.mods.set_snapshot(snapshot); - if !self.mods.mods.server_command(&me, line) { + self.lend_world(); + let owned = self.mods.mods.server_command(&me, line); + self.return_world(); + if !owned { return false; } self.route_mod_output(Some(host)); + self.apply_mod_block_writes(); true } @@ -349,6 +554,21 @@ mod run { self.tell_server_player(host.as_deref_mut(), &p.id, line); } } + Output::SetBlocks { + name, + dimension, + blocks, + } => { + let queued = &mut self.mods.block_writes; + queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b))); + if queued.len() > MAX_QUEUED_WRITES { + let over = queued.len() - MAX_QUEUED_WRITES; + queued.drain(..over); + log::warn!( + "Horizon: mod {name} set more blocks than wait for their level: {over} dropped" + ); + } + } _ => {} } } diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 58d25de0e..d0769b894 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::Game; +use super::{Game, Progress}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -82,6 +82,9 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, + /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the + /// frame draws its progress until `tick_server_packs` applies it. + pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -191,6 +194,21 @@ fn saves() -> PathBuf { std::env::current_dir().unwrap_or_default().join("saves") } +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a +/// worker while `progress` feeds the loading screen, then the apply lands +/// on the render thread. +pub(super) struct PackReload { + pub(super) progress: Progress, + handle: std::thread::JoinHandle>, +} + impl Game<'_> { /// `ConnectScreen`: the server's setting decides what its packs do /// (`configureForServerControl`); single player's own server takes them @@ -314,6 +332,7 @@ impl Game<'_> { if self.rest_loader.is_some() || self.world_loader.is_some() { return; } + self.drive_pack_reload(); let finished = self .server_packs .queue @@ -328,13 +347,8 @@ impl Game<'_> { if !tick.download.is_empty() { self.start_pack_downloads(tick.download); } - if let Some(mut load) = tick.reload { - while let Some(recovery) = { - let ok = self.reload_server_packs(&load); - self.server_packs.packs.reloaded(ok) - } { - load = recovery; - } + if let Some(load) = tick.reload { + self.start_pack_reload(&load); } } for (id, status) in self.server_packs.packs.take_replies() { @@ -379,24 +393,90 @@ impl Game<'_> { } } /// `DownloadedPackSource.startReload`: the packs on top of the player's - /// own, then `Minecraft.reloadResourcePacks`. False when a pack has no - /// readable `pack.mcmeta` or the reload failed. - fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool { - let packs: Option> = load + /// own, staged on a worker (`Minecraft.reloadResourcePacks` under a + /// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no + /// readable `pack.mcmeta` fails the reload at once, as `onFailure`. + fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) { + let server: Option> = load .iter() .map(|(id, path)| packs::server_pack(*id, path)) .collect(); - let Some(packs) = packs else { + let Some(server) = server else { log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all"); - return false; + self.finish_pack_reload(false); + return; }; - self.server_packs.active = packs; - match self.reload_selected_packs() { + self.server_packs.active = server.clone(); + // The overlay swallows input until the apply (`LoadingOverlay`): + // release what is held so nothing keeps acting while it covers the + // frame (`KeyMapping.releaseAll` when a screen opens). + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; + let vanilla = self.vanilla_root(); + if self.ui.vanilla_root.as_os_str().is_empty() { + self.ui.vanilla_root = vanilla.clone(); + } + let (selected, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + let progress = Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("server-pack-reload".into()) + .spawn(move || { + super::packs::stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, + unicode, japanese, &worker, + ) + .map_err(|e| e.to_string()) + }); + match handle { + Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), + Err(e) => { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); + } + } + } + /// `PackReloadConfig.Callbacks`: applies the staged reload once its + /// worker is done; on failure the packs active before reload again. + fn drive_pack_reload(&mut self) { + let Some(reload) = &self.server_packs.reload else { + return; + }; + if !reload.handle.is_finished() { + return; + } + let reload = self.server_packs.reload.take().unwrap(); + let staged = reload + .handle + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + let ok = match staged + .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) + { Ok(()) => true, Err(e) => { log::warn!("Horizon: server resource pack reload failed: {e}"); false } + }; + self.finish_pack_reload(ok); + } + /// `ServerPacks::reloaded` and the recovery reload it can ask for. + fn finish_pack_reload(&mut self, ok: bool) { + if let Some(recovery) = self.server_packs.packs.reloaded(ok) { + self.start_pack_reload(&recovery); } } diff --git a/crates/horizon-client/src/game/shield.rs b/crates/horizon-client/src/game/shield.rs index c4f7f98d1..a26bc5d75 100644 --- a/crates/horizon-client/src/game/shield.rs +++ b/crates/horizon-client/src/game/shield.rs @@ -182,12 +182,17 @@ impl Game<'_> { return; }; let (mx, mz) = (m.position.x, m.position.z); - let disable = m - .extra - .main_hand - .as_deref() - .and_then(|item| self.res.items().get(item)) - .map_or(0.0, |i| i.disable_blocking_seconds); + // LivingEntity.getSecondsToDisableBlocking: the held weapon's value; + // Warden overrides it with a flat 5 seconds. + let disable = if m.species() == horizon_client::mobs::Species::Warden { + 5.0 + } else { + m.extra + .main_hand + .as_deref() + .and_then(|item| self.res.items().get(item)) + .map_or(0.0, |i| i.disable_blocking_seconds) + }; // Ravager.blockedByItem: stunned half the time (the defender pushed // apart), else the defender flung away; no roar in progress. if m.species() == horizon_client::mobs::Species::Ravager { diff --git a/crates/horizon-client/src/game/sign_edit.rs b/crates/horizon-client/src/game/sign_edit.rs index 2d010f1f5..c26314513 100644 --- a/crates/horizon-client/src/game/sign_edit.rs +++ b/crates/horizon-client/src/game/sign_edit.rs @@ -5,7 +5,8 @@ use super::{Game, lan::LanHost}; use horizon_client::{ containers::BlockEntity, hud::{HudBatch, rgba}, - signs::wood_type, + items::ItemStack, + signs::{DYE_TEXT, wood_type}, ui::EditBox, }; use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S}; @@ -20,6 +21,9 @@ pub(super) struct SignEditor { pub line: usize, /// The line being edited (`AbstractSignEditScreen`'s text field). edit: EditBox, + /// `AbstractSignEditScreen.messages`: the side's lines as edited, sent + /// when the editor closes, even if the sign is gone by then. + lines: [String; 4], } fn sign_box() -> EditBox { @@ -31,13 +35,172 @@ fn sign_box() -> EditBox { } impl Game<'_> { + /// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in + /// front of the sign's text (within 90 degrees of its facing). + fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool { + let Some(player) = self.player.as_ref() else { + return true; + }; + let state = self.world.block(pos.0, pos.1, pos.2); + let Some(state) = self.pack.assets.registry.state(state) else { + return true; + }; + let prop = |key: &str| state.properties.get(key).map(String::as_str); + // getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or + // ceiling sign's rotation segment. + let y_rot = if state.name.contains("_wall_") { + match prop("facing") { + Some("west") => 90.0, + Some("north") => 180.0, + Some("east") => 270.0, + _ => 0.0, + } + } else { + prop("rotation") + .and_then(|r| r.parse::().ok()) + .unwrap_or(0.0) + * 22.5 + }; + // getSignHitboxCenterPosition: only a wall sign's board is off-centre, + // against the wall it hangs on. + let centre = if state.name.ends_with("_wall_sign") { + match y_rot as i32 { + 90 => (0.9375, 0.5), + 180 => (0.5, 0.9375), + 270 => (0.0625, 0.5), + _ => (0.5, 0.0625), + } + } else { + (0.5, 0.5) + }; + let xd = player.position.x - (f64::from(pos.0) + centre.0); + let zd = player.position.z - (f64::from(pos.2) + centre.1); + let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32; + // Mth.degreesDifferenceAbs. + let diff = (player_y_rot - y_rot).rem_euclid(360.0); + let diff = if diff >= 180.0 { 360.0 - diff } else { diff }; + diff <= 90.0 + } + /// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed + /// sign plays the failed-interaction sound; otherwise a player who may + /// build edits the side they face. + pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) { + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return; + }; + if sign.waxed { + // `getSignInteractionFailedSoundEvent`: a hanging sign has its own. + let sound = if sign.hanging { + "minecraft:block.hanging_sign.waxed_interact_fail" + } else { + "minecraft:block.sign.waxed_interact_fail" + }; + self.audio + .play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0); + } else if self.may_build() { + let front = self.facing_sign_front(key); + self.open_sign_editor(key, front); + } + } + /// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs, + /// honeycomb): the item changes the side the player faces and is used up. + /// Whether it did; a sign it does not apply to goes on to the empty hand. + pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool { + let Some(stack) = held else { + return false; + }; + let key = (pos[0], pos[1], pos[2]); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else { + return false; + }; + if sign.waxed || !self.may_build() { + return false; + } + let item = &*stack.item; + let dye = if item.ends_with("_dye") { + self.res + .items() + .get(&stack.item) + .and_then(|i| i.dye.clone()) + } else { + None + }; + let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d)); + if !matches!( + item, + "minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac" + ) && colour.is_none() + { + return false; + } + let front = self.facing_sign_front(key); + let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else { + return false; + }; + let centre = pos.map(|v| f64::from(v) + 0.5); + let sound = if item == "minecraft:honeycomb" { + // HoneycombItem.tryApplyToSign: waxes any sign, empty or not. + sign.waxed = true; + "minecraft:item.honeycomb.wax_on" + } else { + let side = if front { + &mut sign.front + } else { + &mut sign.back + }; + // SignApplicator.canApplyToSign: the side must have text. + if side.is_empty() { + return false; + } + match colour { + Some(c) if side.color != c as u8 => { + side.color = c as u8; + "minecraft:item.dye.use" + } + None if item == "minecraft:glow_ink_sac" && !side.glowing => { + side.glowing = true; + "minecraft:item.glow_ink_sac.use" + } + None if item == "minecraft:ink_sac" && side.glowing => { + side.glowing = false; + "minecraft:item.ink_sac.use" + } + _ => return false, + } + }; + if item == "minecraft:honeycomb" { + self.level_event_at(3003, pos, 0); + } + self.audio.play(sound, Some(centre), 1.0, 1.0); + self.mark_block_entity_updated(key); + let state = self.world.block(pos[0], pos[1], pos[2]); + self.post_block_game_event( + "minecraft:block_change", + pos, + self.sculk_actor(), + Some(state), + ); + self.stats.add("used", &stack.item, 1); + self.consume_held(); + self.trigger_block("minecraft:item_used_on_block", pos, stack); + true + } /// `SignBlock.openTextEdit` for the front side after placement. pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) { + // Minecraft.setScreen: the editor already open is removed first. + self.close_sign_editor(); + let lines = match self.world.block_entities.get(&pos) { + Some(BlockEntity::Sign(sign)) if front => sign.front.lines.clone(), + Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(), + _ => Default::default(), + }; self.sign_edit = Some(SignEditor { pos, front, line: 0, edit: sign_box(), + lines, }); self.sign_load(); self.keys.clear(); @@ -50,14 +213,11 @@ impl Game<'_> { pub(super) fn close_sign_editor(&mut self) { if let Some(edit) = self.sign_edit.take() { 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, - })); - } + self.send(S::SignUpdate(play::SignUpdate { + pos: [edit.pos.0, edit.pos.1, edit.pos.2], + lines: edit.lines, + front: edit.front, + })); } else { self.mark_block_entity_updated(edit.pos); } @@ -206,7 +366,8 @@ impl Game<'_> { edit.edit.set_value(&text); } } - /// The box's text onto the sign, when it still fits the line. + /// The box's text onto the editor's lines and the sign + /// (`AbstractSignEditScreen.setMessage`), when it still fits the line. fn sign_write(&mut self) { let Some(edit) = self.sign_edit.as_ref() else { return; @@ -215,6 +376,9 @@ impl Game<'_> { if !self.sign_fits(&value) { return; } + if let Some(edit) = self.sign_edit.as_mut() { + edit.lines[line].clone_from(&value); + } let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else { return; }; diff --git a/crates/horizon-client/src/game/spawner.rs b/crates/horizon-client/src/game/spawner.rs index 538cb39e7..a3761cf7a 100644 --- a/crates/horizon-client/src/game/spawner.rs +++ b/crates/horizon-client/src/game/spawner.rs @@ -253,12 +253,21 @@ impl Game<'_> { self.world.modified.insert((key.0 >> 4, key.2 >> 4)); } /// `LivingEntity.makePoofParticles` (entity event 20) for a mob. - fn mob_poof(&mut self, id: u32) { + pub(super) fn mob_poof(&mut self, id: u32) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let at = m.position; + self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]); + self.poof_particles(id); + } + /// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about + /// mob `id`. + pub(super) fn poof_particles(&mut self, id: u32) { let Some(m) = self.mobs.iter().find(|m| m.id == id) else { return; }; let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height())); - self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]); let options = ParticleOptions::simple(ParticleType::Poof); for _ in 0..20 { let r = self.particles.level_random(); diff --git a/crates/horizon-client/src/game/spill.rs b/crates/horizon-client/src/game/spill.rs index 7f370507f..b1953596b 100644 --- a/crates/horizon-client/src/game/spill.rs +++ b/crates/horizon-client/src/game/spill.rs @@ -171,8 +171,8 @@ pub(super) struct Spawn { pub(super) struct Spill { pub(super) spawns: Vec, /// Experience orbs (`ExperienceOrb.award`): where and how much, each - /// having drawn its merge id from the level random. - pub(super) orbs: Vec<([f64; 3], u32)>, + /// and the merge id it drew from the level random. + pub(super) orbs: Vec<([f64; 3], u32, i32)>, /// Block entities the behaviour removed, for the game's own follow-ups /// (open screens, playing songs). pub(super) removed: Vec<([i32; 3], BlockEntity)>, @@ -342,8 +342,8 @@ impl GameLevel<'_> { while amount > 0 { let value = removal::orb_value(amount); amount -= value; - let _merge_id = self.world.random.next_int_bound(40); - self.spill.orbs.push((at, value)); + let merge_id = self.world.random.next_int_bound(40).unwrap_or(0); + self.spill.orbs.push((at, value, merge_id)); } } /// `BlockEntity.preRemoveSideEffects` and `removeBlockEntity` for the block @@ -583,8 +583,8 @@ impl Game<'_> { let motion = s.motion.unwrap_or_else(new_item_motion); self.spawn_item(s.stack, s.pos, motion, s.delay); } - for (at, value) in spill.orbs { - self.add_orb(at, value); + for (at, value, merge_id) in spill.orbs { + self.award_orb(at, value, merge_id); } for (pos, entity) in spill.removed { let key = (pos[0], pos[1], pos[2]); diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index ed5971324..fdeb726ae 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -546,6 +546,11 @@ impl Loading { (batch.width, batch.height), self.menu.modifiers.shift_key(), ); + title.press_rules( + &gui.atlas, + self.menu.cursor, + (batch.width, batch.height), + ); self.menu_action(event_loop, action); } } diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 56b19e393..361879d2e 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -277,6 +277,10 @@ fn argb(c: i32) -> u32 { ) } +/// A text style's `0xRRGGBB` colour from a HUD colour (`rgba`). +fn rgb_of(rgba: u32) -> u32 { + (rgba & 0xFF) << 16 | (rgba >> 8 & 0xFF) << 8 | (rgba >> 16 & 0xFF) +} impl Game<'_> { /// `Component.translatable`: the selected language (which `lang` layers /// over `en_us`), the caller's English text last. @@ -713,7 +717,8 @@ impl Game<'_> { if let Some(rest) = self.give(stack) && let Some(eye) = self.eye_position() { - self.spawn_item(rest, [eye[0], eye[1] - 0.3, eye[2]], [0.0, 0.1, 0.0], 40); + // Inventory.placeItemBackInInventory: `player.drop(stack, false)`. + self.throw_from_eyes(rest, eye, false); } } /// `Player.giveExperienceLevels`. @@ -1689,20 +1694,34 @@ impl Game<'_> { &self, stack: &ItemStack, ) -> Vec<(horizon_client::text::Text, u32)> { - let e: &Enchantments = &self.res.stations().enchantments; - let props = e.props(&stack.item); + self.stack_tooltip_lines(stack, false) + } + /// `ItemStack.getRarity`'s colour for the name line and a `[name]` link. + fn rarity_color(&self, stack: &ItemStack) -> u32 { + let props = self.res.stations().enchantments.props(&stack.item); let rarity = horizon_client::item_tooltip::rarity( stack, props.map_or("common", |p| p.rarity.as_str()), ); - let color = match rarity { + match rarity { "uncommon" => rgba(255, 255, 85, 255), "rare" => rgba(85, 255, 255, 255), "epic" => rgba(255, 85, 255, 255), _ => u32::MAX, - }; - let name = if let Some(n) = stack.custom_name() { + } + } + /// `ItemStack.getHoverName`: the custom name, a written book's title, the + /// item name component, else the item's or potion's name. + fn stack_hover_name(&self, stack: &ItemStack) -> String { + if let Some(n) = stack.custom_name() { n.to_owned() + } else if let Some(title) = stack + .data() + .and_then(|d| d.written.as_ref()) + .map(|w| w.title.as_str()) + .filter(|t| !t.trim().is_empty()) + { + title.to_owned() } else if let Some(n) = stack.item_name() { n } else if stack.data().is_some_and(|d| d.ominous_banner) { @@ -1718,16 +1737,47 @@ impl Game<'_> { .unwrap_or_else(|| self.pack.names.display_item(&stack.item)) } else { self.pack.names.display_item(&stack.item) - }; + } + } + /// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's + /// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details + /// without the name, the player and the advanced flag. + fn stack_tooltip_lines( + &self, + stack: &ItemStack, + nested: bool, + ) -> Vec<(horizon_client::text::Text, u32)> { + let e: &Enchantments = &self.res.stations().enchantments; + let color = self.rarity_color(stack); + let advanced = !nested && self.options_advanced_tooltips(); let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack); let mut lines = Vec::new(); - if display.name_visible(self.screen == Screen::Creative) { - lines.push((horizon_client::text::Text::legacy(&name), color)); + if !nested && display.name_visible(self.screen == Screen::Creative) { + // `getStyledHoverName`: a custom name is italic. + let name = self.stack_hover_name(stack); + let name = if stack.custom_name().is_some() { + horizon_client::text::Text::literal(name).italic() + } else { + horizon_client::text::Text::legacy(&name) + }; + lines.push((name, color)); } let translate = |k: &str| self.pack.names.display_get(k); let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| { lines.push((horizon_client::text::Text::legacy(&s), c)); }; + let gray = rgba(170, 170, 170, 255); + let item_info = self.res.items().get(&stack.item); + // InstrumentComponent.addToTooltip. + if let Some(text) = + horizon_client::item_tooltip::instrument_line(stack, &display, &translate) + { + push(&mut lines, text, gray); + } + // Bees.addToTooltip. + if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) { + push(&mut lines, text, gray); + } // ItemContainerContents.addToTooltip. let hover_name = |s: &ItemStack| { s.custom_name() @@ -1742,6 +1792,70 @@ impl Game<'_> { push(&mut lines, text, u32::MAX); } } + // BannerPatternLayers.addToTooltip: up to six layer names. + if display.shows("minecraft:banner_patterns") { + for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) { + let key = self.res.stations().banners.layer_key(layer); + push( + &mut lines, + self.translate(&key, &key), + rgba(170, 170, 170, 255), + ); + } + } + // WrittenBookContent.addToTooltip. + for text in horizon_client::item_tooltip::written_book_lines(stack, &display, &translate) { + push(&mut lines, text, gray); + } + // ChargedProjectiles.addToTooltip: each run of equal projectiles, then + // the projectile's own details indented under it. + if display.shows("minecraft:charged_projectiles") { + let charged = stack.data().map_or(&[][..], |d| &d.charged[..]); + for run in charged.chunk_by(|a, b| a.same_item_same_components(b)) { + let projectile = &run[0]; + let name = self.stack_hover_name(projectile); + let line = if run.len() == 1 { + // `getDisplayName`: `[name]` in the rarity colour. + let mut shown = horizon_client::text::Text::literal(format!("[{name}]")) + .with_color(rgb_of(self.rarity_color(projectile))); + if projectile.custom_name().is_some() { + shown = shown.italic(); + } + horizon_client::text::format_translation( + &self.translate( + "item.minecraft.crossbow.projectile.single", + "Projectile: %s", + ), + &[shown], + ) + } else { + horizon_client::text::format_translation( + &self.translate( + "item.minecraft.crossbow.projectile.multiple", + "Projectile: %s x %s", + ), + &[ + horizon_client::text::Text::literal(run.len().to_string()), + horizon_client::text::Text::literal(name), + ], + ) + }; + lines.push((line, u32::MAX)); + for (text, c) in self.stack_tooltip_lines(projectile, true) { + // `literal(" ").append(line).withStyle(GRAY)`: the line's + // own colours win over the gray. + let rgb = if c == u32::MAX { + horizon_client::text::GRAY + } else { + rgb_of(c) + }; + let indented = horizon_client::text::Text::literal(" ") + .append(text) + .with_color(rgb); + lines.push((indented, u32::MAX)); + } + } + } // Fireworks / FireworkExplosion.addToTooltip. for text in horizon_client::item_tooltip::firework_lines(stack, &translate) { push(&mut lines, text, rgba(170, 170, 170, 255)); @@ -1764,6 +1878,17 @@ impl Game<'_> { }; push(&mut lines, text, c); } + // The attribute modifiers of the effects (`When Applied:`). + for (text, c) in horizon_client::item_tooltip::potion_modifier_lines(stack, &translate) + { + push(&mut lines, text, c); + } + } + // JukeboxPlayable.addToTooltip. + if let Some(text) = + horizon_client::item_tooltip::jukebox_line(stack, item_info, &display, &translate) + { + push(&mut lines, text, gray); } if display.shows("minecraft:trim") && let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone()) @@ -1811,16 +1936,12 @@ impl Game<'_> { ); } } - // BannerPatternLayers.addToTooltip: up to six layer names. - if display.shows("minecraft:banner_patterns") { - for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) { - let key = self.res.stations().banners.layer_key(layer); - push( - &mut lines, - self.translate(&key, &key), - rgba(170, 170, 170, 255), - ); - } + // DyedItemColor.addToTooltip. + if let Some((text, italic)) = + horizon_client::item_tooltip::dyed_line(stack, &display, advanced, &translate) + { + let text = horizon_client::text::Text::legacy(&text); + lines.push((if italic { text.italic() } else { text }, gray)); } // ItemLore.addToTooltip: after the enchantments, before the attributes // (ItemStack.addDetailsToTooltip). @@ -1829,6 +1950,50 @@ impl Game<'_> { lines.push((text, u32::MAX)); } } + if display.shows("minecraft:attribute_modifiers") { + // The item's own modifiers, then its enchantments' for each slot + // group (`ItemStack.forEachModifier`). + let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default(); + modifiers.extend(e.modifier_rows(stack)); + let base = |name: &str, default| { + self.player + .as_ref() + .and_then(|player| { + let key = player.attributes.key(name)?; + let handle = player.attributes.lookup(Some(&key))?; + player + .attributes + .instance(&handle) + .ok() + .map(|value| value.base_value()) + }) + .unwrap_or(default) + }; + let player_base = (!nested).then(|| { + [ + base("minecraft:attack_damage", 1.0), + base( + "minecraft:attack_speed", + horizon_client::attack_strength::BASE_ATTACK_SPEED, + ), + ] + }); + for (text, c) in + horizon_client::item_tooltip::attribute_lines(&modifiers, player_base, &translate) + { + push(&mut lines, text, c); + } + } + // ItemStack.UNBREAKABLE_TOOLTIP. + if let Some(text) = + horizon_client::item_tooltip::unbreakable_line(stack, &display, &translate) + { + push(&mut lines, text, rgba(85, 85, 255, 255)); + } + // BlockItemStateProperties.addToTooltip. + if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) { + push(&mut lines, text, gray); + } // Spawner.appendHoverText: what a spawner or trial spawner item spawns. if display.shows("minecraft:block_entity_data") && matches!( @@ -1852,43 +2017,11 @@ impl Game<'_> { } } } - if display.shows("minecraft:attribute_modifiers") - && let Some(info) = self.res.items().get(&stack.item) - { - let base = |name: &str, default| { - self.player - .as_ref() - .and_then(|player| { - let key = player.attributes.key(name)?; - let handle = player.attributes.lookup(Some(&key))?; - player - .attributes - .instance(&handle) - .ok() - .map(|value| value.base_value()) - }) - .unwrap_or(default) - }; - let player_base = [ - base("minecraft:attack_damage", 1.0), - base( - "minecraft:attack_speed", - horizon_client::attack_strength::BASE_ATTACK_SPEED, - ), - ]; - for (text, c) in horizon_client::item_tooltip::attribute_lines( - &info.modifiers, - Some(player_base), - &translate, - ) { - push(&mut lines, text, c); - } - } if display.shows("minecraft:damage") - && let Some(info) = self.res.items().get(&stack.item) + && let Some(info) = item_info && info.max_damage > 0 && stack.damage > 0 - && self.options_advanced_tooltips() + && advanced { push( &mut lines, @@ -1900,8 +2033,23 @@ impl Game<'_> { u32::MAX, ); } + // ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id + // and how many components the stack has. + if advanced { + let dark_gray = rgba(85, 85, 85, 255); + push(&mut lines, stack.item.to_string(), dark_gray); + let count = horizon_client::item_tooltip::component_count(stack, item_info); + if count > 0 { + let text = self.translate("item.components", "%s component(s)"); + push( + &mut lines, + text.replacen("%s", &count.to_string(), 1), + dark_gray, + ); + } + } // ItemStack.OP_NBT_WARNING, for a player with game master permission. - if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) { + if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) { for (key, fallback) in [ ("item.op_warning.line1", "Warning:"), ( @@ -2104,12 +2252,18 @@ impl Game<'_> { batch: &mut HudBatch, layout: &horizon_client::bundle::TooltipLayout, ) { - for (at, stack) in &layout.slots { + for (i, (at, stack)) in layout.slots.iter().enumerate() { + let selected = layout.selected == Some(i); + let sprite = |name: &str| format!("gui/sprites/container/bundle/{name}"); // Each draw takes the atlas on its own: the item's needs `&mut self`. horizon_client::ui::nine_slice( batch, &self.gui.atlas, - "gui/sprites/container/bundle/slot_background", + &sprite(if selected { + "slot_highlight_back" + } else { + "slot_background" + }), at[0], at[1], 24.0, @@ -2119,6 +2273,18 @@ impl Game<'_> { let stack = stack.clone(); let [x, y] = *at; self.draw_item(batch, &stack, x + 4.0, y + 4.0); + if selected { + horizon_client::ui::nine_slice( + batch, + &self.gui.atlas, + &sprite("slot_highlight_front"), + x, + y, + 24.0, + 24.0, + u32::MAX, + ); + } } let atlas = &self.gui.atlas; if let Some((at, hidden)) = layout.overflow { diff --git a/crates/horizon-client/src/game/stats.rs b/crates/horizon-client/src/game/stats.rs index 7f64c6bb2..68a574eb1 100644 --- a/crates/horizon-client/src/game/stats.rs +++ b/crates/horizon-client/src/game/stats.rs @@ -256,9 +256,8 @@ impl Game<'_> { .fluid == horizon_client::blocks::Fluid::Water; let on_ground = p.ground.on_ground; - let (sprinting, flying, gliding, swimming) = ( + let (sprinting, gliding, swimming) = ( p.sprinting, - p.flying, p.fall_flying, p.pose == horizon_client::pose::Pose::Swimming, ); @@ -272,15 +271,27 @@ impl Game<'_> { }); let s = &mut self.stats; match riding { - Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total), - Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total), - Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => { - s.custom("horse_one_cm", horizontal) + Some(Ok(sp)) => { + use horizon_client::mobs::Species; + // `checkRidingStatistics`: only these mounts count, the horse + // family by the full 3D distance; any other mount nothing. + match sp { + Species::Pig => s.custom("pig_one_cm", total), + Species::Strider => s.custom("strider_one_cm", total), + Species::HappyGhast => s.custom("happy_ghast_one_cm", total), + Species::Nautilus | Species::ZombieNautilus => { + s.custom("nautilus_one_cm", total) + } + sp if horizon_client::mobs::equine::is_equine(sp) => { + s.custom("horse_one_cm", total) + } + _ => {} + } } - Some(_) => { + Some(Err(())) => { if self.boats.iter().any(|b| Some(b.id) == self.riding) { s.custom("boat_one_cm", total) - } else { + } else if self.carts.iter().any(|c| Some(c.id) == self.riding) { s.custom("minecart_one_cm", total) } } @@ -300,8 +311,9 @@ impl Game<'_> { } } None if gliding => s.custom("aviate_one_cm", total), - None if flying => s.custom("fly_one_cm", horizontal), - None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32), + // Airborne and not gliding: more than 25 cm sideways per move + // (creative flight, or a long jump), not only flying. + None if horizontal > 25 => s.custom("fly_one_cm", horizontal), None => {} } } diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index 1bcb6a524..81895dba3 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -3,6 +3,7 @@ //! player's `PlayerEnderChestContainer`, saved as `EnderItems`). use super::Game; use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text}; +use horizon_core::random::RandomSource; pub(super) fn is_shulker_box(name: &str) -> bool { name.ends_with("shulker_box") @@ -33,6 +34,55 @@ impl Game<'_> { self.container = Some(key); self.ender_open = Some(key); } + /// The block above `key` is a redstone conductor. + fn conductor_above(&self, key: (i32, i32, i32)) -> bool { + let above = self.world.block(key.0, key.1 + 1, key.2); + self.res.behaviours().facts(above).flags + & horizon_world::level::data::state_flags::CONDUCTOR + != 0 + } + /// `ChestBlock.isChestBlockedAt`: a redstone conductor above the chest or + /// a cat sitting on it. + fn chest_blocked_at(&self, key: (i32, i32, i32)) -> bool { + if self.conductor_above(key) { + return true; + } + // isCatSittingOnChest: a cat whose box meets the block above. + let area = horizon_core::geometry::Aabb::new( + horizon_core::geometry::Vec3::new( + f64::from(key.0), + f64::from(key.1 + 1), + f64::from(key.2), + ), + horizon_core::geometry::Vec3::new( + f64::from(key.0 + 1), + f64::from(key.1 + 2), + f64::from(key.2 + 1), + ), + ); + self.mobs.iter().any(|m| { + m.species() == horizon_client::mobs::Species::Cat + && m.alive() + && horizon_client::mobs::tame::cat_sitting(m) + && m.bounds().intersects(area) + }) + } + /// `ChestBlock.getMenuProvider` (`combine(.., false)`) and + /// `EnderChestBlock.useWithoutItem`: a blocked chest, or a double chest + /// with either half blocked, opens no menu. + pub(super) fn chest_menu_blocked(&self, key: (i32, i32, i32), name: &str) -> bool { + match name { + "minecraft:chest" | "minecraft:trapped_chest" => { + self.chest_blocked_at(key) + || self + .chest_partner(key) + .is_some_and(|(other, _)| self.chest_blocked_at(other)) + } + // The ender chest only looks at the block above (no cat check). + "minecraft:ender_chest" => self.conductor_above(key), + _ => false, + } + } /// Closing it copies the items back and leaves no block-entity contents. pub(super) fn close_ender_chest(&mut self) { let Some(key) = self.ender_open.take() else { @@ -107,6 +157,79 @@ impl Game<'_> { ); paired.then_some((other, kind == "right")) } + /// The open or close sound of a container block (`ChestBlockEntity.playSound`, + /// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume + /// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of + /// both halves, a barrel from the face it opens on. + pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) { + let mut at = [ + f64::from(key.0) + 0.5, + f64::from(key.1) + 0.5, + f64::from(key.2) + 0.5, + ]; + if let Some((other, _)) = self.chest_partner(key) { + at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5; + at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5; + } else if name == "minecraft:barrel" + && let Some(state) = self + .pack + .assets + .registry + .state(self.world.block(key.0, key.1, key.2)) + && let Some(facing) = state.properties.get("facing") + { + let (dx, dy, dz) = match facing.as_str() { + "up" => (0.0, 0.5, 0.0), + "down" => (0.0, -0.5, 0.0), + f => { + let (x, z) = facing_step(f); + (f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0) + } + }; + at = [at[0] + dx, at[1] + dy, at[2] + dz]; + } + let pitch = self.world.random.next_float() * 0.1 + 0.9; + self.audio.play(sound, Some(at), 0.5, pitch); + } + /// The open or close sound of the container block at `key`, if it has one. + /// Callers play it only when the opener count crosses zero + /// (`ContainerOpenersCounter.onOpen` / `onClose`). + pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) { + let name = self + .res + .facts() + .get(self.world.block(key.0, key.1, key.2)) + .name + .clone(); + let sound = if is_shulker_box(&name) { + if open { + "minecraft:block.shulker_box.open" + } else { + "minecraft:block.shulker_box.close" + } + } else if &*name == "minecraft:ender_chest" { + if open { + "minecraft:block.ender_chest.open" + } else { + "minecraft:block.ender_chest.close" + } + } else if name.contains("chest") { + if open { + "minecraft:block.chest.open" + } else { + "minecraft:block.chest.close" + } + } else if &*name == "minecraft:barrel" { + if open { + "minecraft:block.barrel.open" + } else { + "minecraft:block.barrel.close" + } + } else { + return; + }; + self.container_sound(sound, key, &name); + } /// The other half of a double chest at `key`, for neighbour updates. pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> { self.chest_partner(key).map(|p| p.0) diff --git a/crates/horizon-client/src/game/text_displays.rs b/crates/horizon-client/src/game/text_displays.rs index 45bd64fa7..6812be7bf 100644 --- a/crates/horizon-client/src/game/text_displays.rs +++ b/crates/horizon-client/src/game/text_displays.rs @@ -164,11 +164,12 @@ impl Game<'_> { } else { [self.yaw, self.pitch] }; + let scale = self.view_scale(); for display in self.text_displays.values_mut() { display.last_progress = display.progress(partial as f32); let d = display.shown.blend(&display.from, display.last_progress); let position = display.previous.lerp(display.position, partial); - let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance); + let range = f64::from(d.view_range) * 64.0 * scale; if position.distance_squared(camera) >= range * range || d.text.is_empty() { continue; } @@ -268,6 +269,8 @@ impl Game<'_> { light, layer: if through { 2 } else { 0 }, normal: [0; 4], + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/titles.rs b/crates/horizon-client/src/game/titles.rs index cbae1f6b4..9bb000314 100644 --- a/crates/horizon-client/src/game/titles.rs +++ b/crates/horizon-client/src/game/titles.rs @@ -21,6 +21,22 @@ impl Game<'_> { draws.extend(self.titles.title(w, h, partial, font, background)); hud_layout::paint(batch, &self.gui.atlas, &draws); } + /// `Hud.extractSavingIndicator`, under the screens: the option, then the + /// fading "Saving world" text. + pub(super) fn draw_saving_indicator(&self, batch: &mut HudBatch) { + if !self.settings.autosave_indicator { + return; + } + let draws = self.saving_indicator.draw( + batch.width as i32, + batch.height as i32, + self.clock.fraction() as f32, + &self.gui.atlas.font, + Text::literal(self.translate("menu.savingLevel", "Saving world")), + self.settings.text_background(0.0), + ); + hud_layout::paint(batch, &self.gui.atlas, &draws); + } /// The selected stack as `Hud.tick` compares it for the name highlight. fn held_name(&self) -> Option { let stack = self.inventory.get(self.selected)?.as_ref()?; @@ -44,6 +60,12 @@ impl Game<'_> { /// The title, action bar and selected-item parts of `Hud.tick`. pub(super) fn tick_hud(&mut self) { self.titles.tick(); + // `MinecraftServer.isCurrentlySaving` of single player's server. + let saving = self + .integrated + .as_ref() + .map_or(self.autosaving, |server| server.is_saving()); + self.saving_indicator.tick(saving); let held = self.held_name(); self.tool_highlight .tick(held, f64::from(self.settings.notification_display_time)); diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index 6bd2ae956..beaac42cc 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -178,7 +178,9 @@ impl Game<'_> { ]; self.tnts.remove(i); count -= 1; - self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?; + self.with_hive_emptying_blast(|g| { + g.server_explode(center, 4.0, BlockInteraction::Destroy, false) + })?; } else { i += 1; } @@ -252,7 +254,7 @@ impl Game<'_> { } let range = super::entity_culling::default_range( glam::DVec3::splat(PrimedTnt::SIZE), - self.settings.entity_distance, + self.view_scale(), ); for t in &self.tnts { // EntityRenderer.shouldRender. @@ -332,6 +334,8 @@ impl Game<'_> { layer: if lit { 5 } else { 0 }, color, light: packed, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/game/trading.rs b/crates/horizon-client/src/game/trading.rs index 6986d8baa..0f8723737 100644 --- a/crates/horizon-client/src/game/trading.rs +++ b/crates/horizon-client/src/game/trading.rs @@ -245,7 +245,7 @@ impl Game<'_> { self.handle_mob_events(events)?; return Ok(true); } - self.stats.custom("talk_to_villager", 1); + self.stats.custom("talked_to_villager", 1); self.ensure_offers(index); let m = &self.mobs[index]; let trade = m.extra.trade.as_deref().cloned().unwrap_or_default(); @@ -398,18 +398,21 @@ impl Game<'_> { .as_ref() .and_then(|s| s.merchant.as_ref()) .and_then(|m| m.mob); - let session = self.merchant_apply(session, mob); + let (session, traded) = self.merchant_apply(session, mob); + self.merchant_traded(mob, &traded); if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) { m.session = session; } } - /// What a menu click's session did, applied to the world and its mob. + /// What a menu click's session did, applied to the world and its mob; + /// with the result of each trade made, for `merchant_traded`. pub(super) fn merchant_apply( &mut self, session: Box, mob: Option, - ) -> Box { + ) -> (Box, Vec) { let mut session = session; + let mut traded = Vec::new(); let events = std::mem::take(&mut session.events); let (villager, origin) = (session.villager, session.origin); if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) { @@ -438,11 +441,26 @@ impl Game<'_> { }; self.audio.play(sound, Some(origin), 1.0, pitch); } - merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1), + merchant::Event::Traded(index) => { + traded.extend(session.offers.get(index).map(|o| o.result.clone())); + } merchant::Event::TradeSound => {} } } - session + (session, traded) + } + /// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade` + /// for each trade made: the statistic and the `villager_trade` trigger. + pub(super) fn merchant_traded(&mut self, mob: Option, results: &[ItemStack]) { + for result in results { + self.stats.custom("traded_with_villager", 1); + let villager = mob + .and_then(|id| self.mobs.iter().find(|m| m.id == id)) + .map(|m| self.mob_entity(m)); + if let Some(villager) = villager { + self.trigger_trade(&villager, result); + } + } } /// `Villager.customServerAiStep`: the TRADE reputation event of the last /// trade, a tick later; the prices follow the new reputation. diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 75b546804..05c23568b 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -1,17 +1,20 @@ //! Tridents: `TridentItem.releaseUsing` (throw after 10 ticks, or Riptide in //! water or rain) and `ThrownTrident` (arrow flight with 0.99 water inertia, //! 8 damage plus Impaling, the reversed bounce off a hit, sticking, Loyalty's -//! return to the owner, pickup) with `ThrownTridentRenderer`. +//! return to the owner, pickup) with `ThrownTridentRenderer`, and Channeling's +//! lightning on a hit (`channeling.json`). use super::{Game, view_vector}; use horizon_client::{ collisions::SceneCollisions, + enchant::EffectContext, items::ItemStack, mesher::Vertex, mobs::{Damage, DamageKind, Who, anim::Poses}, projectiles::ProjectileWorld, }; use horizon_core::{ - geometry::{Aabb, Vec3}, + geometry::{Aabb, Vec3, traverse_blocks}, + position::BlockPos, random::RandomSource, }; @@ -20,7 +23,7 @@ pub(super) struct ThrownTrident { pub id: u32, pub(super) stack: ItemStack, pub(super) position: Vec3, - previous: Vec3, + pub(super) previous: Vec3, pub(super) velocity: Vec3, pub(super) yaw: f32, pub(super) pitch: f32, @@ -33,6 +36,8 @@ pub(super) struct ThrownTrident { creative_only: bool, /// The entity id a server sent as its owner (the one Loyalty returns it to). pub(super) owner: u32, + /// The guest that threw it (`Projectile.getOwner`); `None` for the host. + guest: Option, } impl ThrownTrident { /// A trident a server sent (`handleAddEntity`): placed and moved by it. @@ -53,6 +58,7 @@ impl ThrownTrident { returning: 0, creative_only: false, owner: 0, + guest: None, } } /// `ThrownTrident.tick`'s Loyalty return: pulled up and towards the @@ -172,7 +178,7 @@ impl Game<'_> { } true } - fn in_water_or_rain(&self) -> bool { + fn in_water_or_rain(&mut self) -> bool { let Some(p) = &self.player else { return false }; let (x, y, z) = ( p.position.x.floor() as i32, @@ -181,8 +187,8 @@ impl Game<'_> { ); let water = self.res.facts().get(self.world.block(x, y, z)).fluid == horizon_client::blocks::Fluid::Water; - let sky = self.world.light(x, y + 1, z) >> 4 == 15; - water || (self.world.weather.raining && sky) + let (at, top) = (p.position, p.bounds().max_position().y); + water || self.in_rain(at, top) } /// The living entities a spinning player can touch this tick, for /// `LivingEntity.checkAutoSpinAttack` (none when not spinning). @@ -216,6 +222,69 @@ impl Game<'_> { p.auto_spin_attack_dmg = 0.0; } } + /// The `summon_entity` and `play_sound` effects of `channeling.json`: in a + /// thunderstorm (`weather_check`) a spot that sees the sky (`can_see_sky`) + /// gets a lightning bolt and the thunder sound. + fn channel_lightning(&mut self, at: [f64; 3]) { + // Level.isThundering: the thunder level, in dimensions with weather. + if self.dim != super::Dim::Overworld || self.world.weather.levels(1.0).1 <= 0.9 { + return; + } + // BlockAndLightGetter.canSeeSky: full sky light. + let (x, y, z) = ( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ); + if self.world.light(x, y, z) >> 4 != 15 { + return; + } + self.spawn_bolt(at, false); + self.audio + .play("minecraft:item.trident.thunder", Some(at), 5.0, 1.0); + } + /// `minecraft:hit_block` of Channeling (`AbstractArrow.hitBlockEnchantmentEffects`): + /// the block the trident's path first hits, if it is a lightning rod, with + /// the hit location clamped into the block (`BlockPos.clampLocationWithin`). + fn channel_rod_hit(&mut self, from: Vec3, velocity: Vec3) { + let to = Vec3::new( + from.x + velocity.x, + from.y + velocity.y, + from.z + velocity.z, + ); + let mut scene = SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + // ClipContext.Block.COLLIDER with Fluid.NONE. + let hit = traverse_blocks(from, to, 1024, |p: BlockPos| { + let state = self.world.block(p.x, p.y, p.z); + if self.res.facts().is_air(state) { + return None; + } + let shape = self + .collision + .collision_shape(&mut scene, state, [p.x, p.y, p.z]) + .ok()?; + shape.clip(from, to, p) + }); + let Some(hit) = hit.ok().flatten() else { + return; + }; + let state = self.world.block(hit.block.x, hit.block.y, hit.block.z); + if !self.res.facts().get(state).name.ends_with("lightning_rod") { + return; + } + let edge = f64::from(1.0e-5f32); + let clamp = + |v: f64, origin: i32| v.clamp(f64::from(origin) + edge, f64::from(origin) + 1.0 - edge); + self.channel_lightning([ + clamp(hit.location.x, hit.block.x), + clamp(hit.location.y, hit.block.y), + clamp(hit.location.z, hit.block.z), + ]); + } /// `TridentItem.releaseUsing`. pub(super) fn release_trident(&mut self, ticks: u32) { if ticks < 10 { @@ -233,10 +302,11 @@ impl Game<'_> { .as_ref() .map(|p| [p.position.x, p.position.y, p.position.z]); if riptide > 0 { - if !self.in_water_or_rain() { + // `isInWaterOrRain() && !isPassenger()`. + if !self.in_water_or_rain() || self.riding.is_some() { return; } - self.damage_held(1); + self.hurt_held_without_breaking(1); // trident_spin_attack_strength: 1.5 + 0.75 per level above the first. let strength = 1.5 + 0.75 * (riptide as f32 - 1.0); let d = view_vector(self.yaw, self.pitch); @@ -257,12 +327,8 @@ impl Game<'_> { self.audio.play(&sound, at, 1.0, 1.0); return; } - // hurtWithoutBreaking, then the thrown copy. - if !self.creative - && let Some(s) = self.held_mut().as_mut() - { - s.damage += 1; - } + // hurtWithoutBreaking (Unbreaking applies), then the thrown copy. + self.hurt_held_without_breaking(1); let thrown = self.held().cloned().unwrap_or(stack); if !self.creative { *self.held_mut() = None; @@ -303,11 +369,33 @@ impl Game<'_> { returning: 0, creative_only: self.creative, owner: 0, + guest: self.lan_acting, }); self.player_projectile_shot(); self.audio .play("minecraft:item.trident.throw", at, 1.0, 1.0); } + /// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the + /// guest that threw it, and not at all once they have left. + fn thrower_hit( + &mut self, + guest: Option, + victim: &horizon_client::advancements::EntityView, + direct: horizon_client::advancements::EntityView, + damage: f32, + ) { + let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else { + self.player_hit(victim, "minecraft:trident", Some(direct), damage); + return; + }; + let Some(mut host) = self.lan.take() else { + return; + }; + self.as_guest(&mut host, conn, |g| { + g.player_hit(victim, "minecraft:trident", Some(direct), damage) + }); + self.lan = Some(host); + } pub(super) fn tick_tridents(&mut self) { let Some(eye) = self.eye_position() else { return; @@ -356,15 +444,34 @@ impl Game<'_> { } } if let Some((id, _)) = hit_mob { - let impaling = t.stack.enchantment_level("minecraft:impaling") as f32; - let aquatic = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| { - m.kind.flags.aquatic_swimmer || m.kind.flags.breathes_water - }); - let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 }; + // ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8 + // (impaling hits `#sensitive_to_impaling`, the aquatic tag). + let victim = self + .mobs + .iter() + .find(|m| m.id == id) + .map(|m| m.kind.id.clone()); + let ctx = EffectContext { + this: victim.as_deref(), + attacker: Some("minecraft:player"), + direct_attacker: Some("minecraft:trident"), + is_direct: true, + damage_type: Some("minecraft:trident"), + items: Some(self.res.items()), + ..Default::default() + }; + let damage = self.res.stations().enchantments.modify_value( + Some(&t.stack), + "minecraft:damage", + &ctx, + &mut self.world.random, + 8.0, + ); t.dealt_damage = true; + let mut hurt_at = None; if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { let from = Some([t.position.x, t.position.y, t.position.z]); - m.damage( + let hurt = m.damage( damage, Damage { kind: DamageKind::Arrow, @@ -372,6 +479,27 @@ impl Game<'_> { from, }, ); + if hurt { + hurt_at = Some([m.position.x, m.position.y, m.position.z]); + } + } + // `minecraft:post_attack` of Channeling, on the victim. + if let Some(at) = hurt_at + && t.stack.enchantment_level("minecraft:channeling") > 0 + { + self.channel_lightning(at); + } + if hurt_at.is_some() + && let Some(m) = self.mobs.iter().find(|m| m.id == id) + { + // LivingEntity.hurtServer: PLAYER_HURT_ENTITY. + let victim = self.mob_entity(m); + let direct = horizon_client::advancements::EntityView { + kind: "minecraft:trident".into(), + position: [t.position.x, t.position.y, t.position.z], + ..Default::default() + }; + self.thrower_hit(t.guest, &victim, direct, damage); } self.audio.play( "minecraft:item.trident.hit", @@ -392,6 +520,7 @@ impl Game<'_> { subject: None, }; if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { + let (from, flight) = (t.position, t.velocity); let blocked = (moved.x - t.velocity.x).abs() > 1e-7 || (moved.y - t.velocity.y).abs() > 1e-7 || (moved.z - t.velocity.z).abs() > 1e-7; @@ -401,6 +530,9 @@ impl Game<'_> { t.position.z + moved.z, ); if blocked { + if t.stack.enchantment_level("minecraft:channeling") > 0 { + self.channel_rod_hit(from, flight); + } t.in_ground = true; t.velocity = Vec3::ZERO; self.audio.play( @@ -470,7 +602,7 @@ impl Game<'_> { let range = 10.0 * super::entity_culling::default_range( glam::DVec3::splat(2.0 * HALF), - self.settings.entity_distance, + self.view_scale(), ); for t in &self.tridents { // EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5. diff --git a/crates/horizon-client/src/game/tweaks.rs b/crates/horizon-client/src/game/tweaks.rs index cec83e248..648073874 100644 --- a/crates/horizon-client/src/game/tweaks.rs +++ b/crates/horizon-client/src/game/tweaks.rs @@ -70,8 +70,7 @@ impl Game<'_> { /// One ordinary click on a game slot. False when nothing took it. pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool { if self.remote.is_some() { - let ordinal = super::remote::input_ordinal(input); - return self.remote_slot_click(slot, button as i8, ordinal); + return self.remote_menu_ops(&[(Some(slot), button, input)]); } if !self.menu_click(Some(slot), button, input) { return false; diff --git a/crates/horizon-client/src/game/vault.rs b/crates/horizon-client/src/game/vault.rs index f3edfddb3..18430daf6 100644 --- a/crates/horizon-client/src/game/vault.rs +++ b/crates/horizon-client/src/game/vault.rs @@ -169,6 +169,8 @@ impl Game<'_> { self.consume_held(); } } + // ServerPlayerGameMode.useItemOn: the action was consumed. + self.trigger_block("minecraft:item_used_on_block", pos, &stack); Ok(true) } fn vault_events(&mut self, key: Pos, events: Vec) -> Result<()> { diff --git a/crates/horizon-client/src/game/villagers.rs b/crates/horizon-client/src/game/villagers.rs index 3cfe8aa1d..36c79f893 100644 --- a/crates/horizon-client/src/game/villagers.rs +++ b/crates/horizon-client/src/game/villagers.rs @@ -21,6 +21,38 @@ impl Game<'_> { fn item_max_stack(&self, item: &str) -> u32 { self.res.items().get(item).map_or(64, |i| i.max_stack) } + /// `WorkAtComposter.useWorkstation`: at a composter the farmer makes + /// bread, then composts seeds. + pub(super) fn villager_work_at_composter(&mut self, id: u32, pos: [i32; 3]) { + let Some(i) = self.villager_index(id) else { + return; + }; + if self.composter_level(pos).is_none() { + return; + } + self.villager_make_bread(i); + self.villager_compost_items(i, pos); + } + /// `WorkAtComposter.makeBread`: up to three loaves from nine wheat while + /// the villager holds at most 36 bread; what does not fit drops. + fn villager_make_bread(&mut self, i: usize) { + let max_stack = self.item_max_stack("minecraft:bread"); + let e = villager::ext_mut(&mut self.mobs[i]); + if e.count_item("minecraft:bread") > 36 { + return; + } + let loaves = (e.count_item("minecraft:wheat") / 3).min(3); + if loaves == 0 { + return; + } + e.remove_item("minecraft:wheat", loaves * 3); + let left = e.add_item(ItemStack::new("minecraft:bread", loaves), max_stack); + if let Some(rest) = left { + // spawnAtLocation(level, stack, 0.5F) + let p = self.mobs[i].position; + self.spawn_item(rest, [p.x, p.y + 0.5, p.z], [0.0, 0.2, 0.0], 10); + } + } /// `BehaviorUtils.throwItem`: toss a stack from a villager's hand toward /// a point. fn villager_throw(&mut self, from: Vec3, eye: f64, to: Vec3, stack: ItemStack) { @@ -116,7 +148,7 @@ impl Game<'_> { // VillagerMakeLove: entity event 13 for the partner, then the parent. for i in [ib, ia] { let id = self.mobs[i].id; - self.villager_particles(id, "angry_villager"); + self.mob_event_particles(id, 13); } return; }; @@ -141,12 +173,31 @@ impl Game<'_> { child.extra.bed = Some(bed); } // VillagerMakeLove.giveBirth: entity event 12 for the child. - self.villager_particles(id, "heart"); + self.mob_event_particles(id, 12); } - /// `Villager.handleEntityEvent` 12, 13, 14 and 42: - /// `AbstractVillager.addParticlesAroundSelf`. - pub(super) fn villager_particles(&mut self, id: u32, kind: &'static str) { - self.particles_around_mob(id, kind, horizon_client::particles::Burst::VILLAGER); + /// `Level.broadcastEntityEvent` of an event whose `handleEntityEvent` + /// draws particles about the mob (villager 12, 13 and 14, taming 6 and 7, + /// love 18): the guests get the byte, and this game draws it as its own + /// client would. + pub(super) fn mob_event_particles(&mut self, id: u32, event: i8) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + self.lan_entity_event(id as i32, event, at); + self.entity_event_particles(id, event); + } + /// `handleEntityEvent(event)`'s particles about mob `id` + /// (`Villager`, `TamableAnimal`, `AbstractHorse`, `Animal`, `Allay`). + pub(super) fn entity_event_particles(&mut self, id: u32, event: i8) { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + if let Some((kind, burst)) = + horizon_client::particles::entity_event_burst(&m.kind.id, event as u8) + { + self.particles_around_mob(id, kind, burst); + } } /// `particles::around_self` about mob `id`. pub(super) fn particles_around_mob( @@ -283,7 +334,7 @@ impl Game<'_> { } // Villager.setLastHurtByMob: entity event 13. for id in angry { - self.villager_particles(id, "angry_villager"); + self.mob_event_particles(id, 13); } } /// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a @@ -338,7 +389,7 @@ impl Game<'_> { gossip::REPUTATION_CHANGE_PER_TRADE, ); // Villager.customServerAiStep after a trade: entity event 14. - self.villager_particles(id, "happy_villager"); + self.mob_event_particles(id, 14); } /// `Villager.getPlayerReputation` of the local player. pub(super) fn villager_reputation(&self, id: u32) -> i32 { diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index fb24102ba..63ac2641d 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -68,7 +68,7 @@ impl Game<'_> { velocity, on_ground, ); - self.creatures.projectiles.push(proj); + self.add_projectile(proj); let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8); self.audio .play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch); diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 8084425ca..5f01633b0 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -2,11 +2,53 @@ //! its boss bar, and its skulls hitting and exploding. use super::Game; use horizon_client::{ + items::ItemStack, mobs::{Damage, DamageKind, Species, Who}, projectiles::{Projectile, ProjectileKind}, }; +use horizon_core::position::BlockPos; impl Game<'_> { + /// `LivingEntity.createWitherRose`: a mob a wither killed leaves a wither + /// rose where it stood (with mobGriefing, on air that can hold it), else + /// drops one. + pub(super) fn create_wither_rose(&mut self, m: &horizon_client::mobs::Mob) { + // getKillCredit: the player who last hurt it, else the last mob (kept + // for 100 ticks). + let by_wither = m.ai.last_hurt_by_player <= 0 + && m.age as i32 - m.ai.last_hurt_by_time <= 100 + && matches!(m.ai.last_hurt_by, Some(Who::Mob(id)) + if self.mobs.iter().any(|o| o.id == id && o.alive() && o.species() == Species::Wither)); + if !by_wither { + return; + } + let pos = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + let registry = self.pack.assets.registry; + let rose = registry.default_state("minecraft:wither_rose"); + if self.ui.rules.bool("mob_griefing") + && let Some(rose) = rose + && self + .res + .facts() + .is_air(self.world.block(pos[0], pos[1], pos[2])) + && self.with_level(|b, level, ticks| { + b.can_survive(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]), rose) + }) + { + let _ = self.level_set(pos, rose); + return; + } + self.spawn_item( + ItemStack::new("minecraft:wither_rose", 1), + [m.position.x, m.position.y, m.position.z], + super::dispenser::new_item_motion(), + 0, + ); + } fn is_wither_skull(&self, p: [i32; 3]) -> bool { let name = &*self .res @@ -130,7 +172,10 @@ impl Game<'_> { if let Some(id) = owner { self.note_attacker("minecraft:explosion", id); } - self.explode(at, 1.0) + self.combat.explosion_owner = owner; + let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0)); + self.combat.explosion_owner = None; + result } } /// Wither skulls drawn as the skull model, blue when dangerous. diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index ef47cc9c0..d7c4f2b03 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -46,6 +46,10 @@ impl WorldRequest { options: &Options, settings: &Settings, ) -> Result { + // Before any chunk is read: a loaded container with a loot table is rolled as its chunk + // is decoded (`save::roll_loaded_loot`), and the spawn chunks are read while the world + // opens. Every world opens here: in-frame, on the server thread, on a dedicated server. + horizon_client::structure_blocks::loot_data(&options.resources); let Self { save_root, folder, @@ -89,7 +93,14 @@ impl WorldRequest { level.hardcore = o.hardcore; level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into(); level.allow_commands = Some(o.commands(creative)); - horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?; + horizon_client::save::write_world_gen_settings_with( + &save_root, + level.seed, + o.world_gen(), + o.flat_settings(), + )?; + // WorldCreationUiState.getGameRules, written with the level. + level.game_rules = o.rules.by_name(); } // Worlds made without the Create New World screen (--world, the // control socket) take its default: commands on for Creative only. @@ -192,6 +203,82 @@ impl WorldLoader { } } +/// How long the preloaded world data outlives the world screens: a player +/// who goes back to the title or to Multiplayer and opens no world for this +/// long gets the memory back. +const PRELOAD_KEPT: std::time::Duration = std::time::Duration::from_secs(60); + +enum Preload { + Idle, + /// Read, or being read, on the "World data preload" thread. `left`: when + /// the player last left the world screens. + Started { + thread: std::thread::JoinHandle<()>, + left: Option, + }, + /// A world opened: the data is the world's now, and stays. + Used, +} + +static PRELOAD: std::sync::Mutex = std::sync::Mutex::new(Preload::Idle); + +/// Each frame of the title: read what every world opening loads and keeps +/// for the rest of the process (the worldgen bundle, the loot tables and the +/// registries the server sends) on a helper thread while the player is on the +/// world screens (`on_world_screens`: Singleplayer, Create World and their +/// dialogs), so it overlaps the time spent choosing a world. A world opened +/// meanwhile waits for the part being read instead of reading it again. Not +/// at the title screen itself: many players only go to Multiplayer, and +/// small machines need the memory. Dropped after `PRELOAD_KEPT` away from +/// the world screens without a world opening. +pub(super) fn preload_world_data( + on_world_screens: bool, + resources: &std::path::Path, + registry: &'static horizon_world::registry::BlockRegistry, +) { + let mut preload = PRELOAD.lock().unwrap_or_else(|e| e.into_inner()); + match &mut *preload { + Preload::Used => {} + Preload::Idle if on_world_screens => { + let resources = resources.to_owned(); + let spawned = std::thread::Builder::new() + .name("World data preload".into()) + .spawn(move || { + horizon_threads::behind_server(); + // Errors are left for the world opening to report. + let _ = horizon_client::vanillagen::shared_data( + &resources.join("worldgen"), + registry, + ); + let _ = horizon_client::structure_blocks::loot_data(&resources); + let _ = super::lan::sync_data(&resources); + }); + match spawned { + Ok(thread) => *preload = Preload::Started { thread, left: None }, + Err(e) => log::warn!("Horizon: world data preload: {e}"), + } + } + Preload::Idle => {} + Preload::Started { left, .. } if on_world_screens => *left = None, + Preload::Started { thread, left } => { + let left = *left.get_or_insert_with(std::time::Instant::now); + if left.elapsed() >= PRELOAD_KEPT && thread.is_finished() { + horizon_client::vanillagen::forget_shared_data(); + horizon_client::structure_blocks::forget_loot_data(); + super::lan::forget_sync_data(); + horizon_threads::release_free_memory(); + *preload = Preload::Idle; + log::info!("Horizon: preloaded world data dropped, no world opened"); + } + } + } +} + +/// A world opens: what `preload_world_data` read is the world's from now on. +pub(super) fn world_data_used() { + *PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used; +} + pub(super) fn make_streaming( res: &Resources, pack: &PackResources, diff --git a/crates/horizon-client/src/game/zombie_reinforcements.rs b/crates/horizon-client/src/game/zombie_reinforcements.rs new file mode 100644 index 000000000..4c6100f43 --- /dev/null +++ b/crates/horizon-client/src/game/zombie_reinforcements.rs @@ -0,0 +1,134 @@ +//! `Zombie.hurtServer`'s reinforcements: a hurt zombie on Hard may call +//! another of its kind from 7 to 40 blocks away to fight its target. +use super::Game; +use horizon_client::mobs::{ + MobType, Species, Who, + finalize::{self, Attr, Op}, + spawn_rules::{self, Reason}, + spawn_selection::{self, NaturalLevel}, + spawner::SpawnerLevel, +}; +use horizon_core::random::{LegacyRandom, RandomSource}; +use horizon_world::food::Difficulty; + +/// `Zombie.REINFORCEMENT_ATTEMPTS`. +const ATTEMPTS: usize = 50; +/// `Zombie.REINFORCEMENT_RANGE_MIN` and `REINFORCEMENT_RANGE_MAX`. +const RANGE_MIN: i32 = 7; +const RANGE_MAX: i32 = 40; + +/// One axis of the offset: `Mth.nextInt(random, 7, 40) * Mth.nextInt(random, -1, 1)`. +fn offset(random: &mut LegacyRandom) -> i32 { + let distance = random + .next_int_between_inclusive(RANGE_MIN, RANGE_MAX) + .unwrap_or(RANGE_MIN); + distance * random.next_int_between_inclusive(-1, 1).unwrap_or(0) +} + +impl Game<'_> { + /// `Zombie.hurtServer` after the hit: on Hard, with the zombie's chance + /// and monsters spawning, a copy of it appears out of sight and takes + /// `target`; both then call less often. + pub(super) fn call_reinforcements(&mut self, caller: u32, target: Who) { + let Some(index) = self.mobs.iter().position(|m| m.id == caller) else { + return; + }; + if self.world.health_rules.difficulty != Difficulty::Hard { + return; + } + let zombie = &mut self.mobs[index]; + if f64::from(zombie.random.next_float()) >= zombie.attrs.spawn_reinforcements { + return; + } + // ServerLevel.isSpawningMonsters. + if !self.ui.rules.bool("spawn_mobs") || !self.ui.rules.bool("spawn_monsters") { + return; + } + let kind = zombie.kind.clone(); + let from = [ + zombie.position.x.floor() as i32, + zombie.position.y.floor() as i32, + zombie.position.z.floor() as i32, + ]; + let mut own = std::mem::replace(&mut zombie.random, LegacyRandom::new(0)); + let mut rules = std::mem::replace(&mut self.world.random, LegacyRandom::new(0)); + let spot = self.reinforcement_spot(&kind, from, &mut own, &mut rules); + self.mobs[index].random = own; + self.world.random = rules; + let Some(spot) = spot else { + return; + }; + let Some(id) = self.spawn_mob(&kind.id, spot.map(f64::from), false) else { + return; + }; + if let Some(reinforcement) = self.mobs.iter_mut().find(|m| m.id == id) { + reinforcement.ai.target = Some(target); + // ZOMBIE_REINFORCEMENT_CALLEE_CHARGE. + finalize::add_modifier( + reinforcement, + Attr::SpawnReinforcements, + "minecraft:reinforcement_callee_charge", + -f64::from(0.05f32), + Op::AddValue, + ); + } + // The caller's charge grows by 0.05 each time it calls. + let caller = &mut self.mobs[index]; + let charged = finalize::modifier_amount( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + ) + .unwrap_or(0.0); + finalize::add_modifier( + caller, + Attr::SpawnReinforcements, + "minecraft:reinforcement_caller_charge", + charged - 0.05, + Op::AddValue, + ); + } + /// The block `Zombie.hurtServer` puts the reinforcement at: the first of + /// 50 random offsets with a valid spawn surface, no alive player within + /// 7 blocks, and room for the zombie standing on the block's corner. + fn reinforcement_spot( + &self, + kind: &MobType, + from: [i32; 3], + own: &mut LegacyRandom, + rules: &mut LegacyRandom, + ) -> Option<[i32; 3]> { + let level = self.natural_level(); + let (width, height) = (kind.width as f32, kind.height as f32); + for _ in 0..ATTEMPTS { + let [x, y, z] = from.map(|v| v + offset(own)); + if !spawn_rules::is_spawn_position_ok(kind.species, &level, x, y, z) + || !spawn_rules::check_spawn_rules( + kind.species, + &level, + Reason::Reinforcement, + rules, + x, + y, + z, + ) + { + continue; + } + // setPos(x, y, z): the box is centred on the block's corner. + let bounds = + spawn_selection::spawn_box(f64::from(x), f64::from(y), f64::from(z), width, height); + // Drowned.canSpawnInLiquids. + let wet_ok = kind.species == Species::Drowned + || !spawn_selection::contains_any_liquid(&level, bounds); + if !level.alive_player_near(f64::from(x), f64::from(y), f64::from(z), 7.0) + && level.unobstructed(bounds) + && level.no_collision(bounds) + && wet_ok + { + return Some([x, y, z]); + } + } + None + } +} diff --git a/crates/horizon-client/src/game/game_rules_screen.rs b/crates/horizon-client/src/game_rules_screen.rs similarity index 91% rename from crates/horizon-client/src/game/game_rules_screen.rs rename to crates/horizon-client/src/game_rules_screen.rs index 0992f7689..7f7c307b6 100644 --- a/crates/horizon-client/src/game/game_rules_screen.rs +++ b/crates/horizon-client/src/game_rules_screen.rs @@ -1,8 +1,9 @@ -//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World -//! Options: the rules by category under a search box, a switch or a number -//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket). -//! Leaving with changes asks first (ConfirmScreen). -use horizon_client::{ +//! AbstractGameRulesScreen: the rules by category under a search box, a +//! switch or a number box each. InWorldGameRulesScreen, opened from World +//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and +//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen, +//! opened from Create New World, hands them back and leaves without asking. +use crate::{ confirm_screen::Confirm, font::Font, gamerules::{self, GameRules, RULES, Rule, Value}, @@ -32,14 +33,14 @@ enum Row { /// What a click or Escape asks of World Options. #[derive(Debug, PartialEq)] -pub(super) enum Exit { +pub enum Exit { /// Done: the rules that changed. Apply(Vec<(&'static str, Value)>), /// Cancel, or discarding the changes. Discard, } -pub(super) struct RulesScreen { +pub struct RulesScreen { search: EditBox, values: GameRules, initial: GameRules, @@ -49,6 +50,8 @@ pub(super) struct RulesScreen { scroll: ui::Scroll, /// ConfirmScreen "Game Rule Changes" in its place. confirm: bool, + /// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen. + in_world: bool, } fn name_key(r: &Rule) -> String { @@ -102,7 +105,15 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm { } impl RulesScreen { - pub(super) fn new(rules: &GameRules) -> Self { + /// InWorldGameRulesScreen over the world's rules. + pub fn new(rules: &GameRules) -> Self { + Self::build(rules, true) + } + /// WorldCreationGameRulesScreen over the new world's rules. + pub fn for_creation(rules: &GameRules) -> Self { + Self::build(rules, false) + } + fn build(rules: &GameRules, in_world: bool) -> Self { // setInitialFocus: the search box. let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0); let numbers = RULES @@ -123,6 +134,7 @@ impl RulesScreen { numbers, scroll: ui::Scroll::default(), confirm: false, + in_world, } } @@ -197,9 +209,10 @@ impl RulesScreen { }) .collect() } - /// InWorldGameRulesScreen.onClose: pending changes ask first. + /// InWorldGameRulesScreen.onClose: pending changes ask first; + /// WorldCreationGameRulesScreen.onClose discards them. fn close(&mut self) -> Option { - if self.changes().is_empty() { + if !self.in_world || self.changes().is_empty() { Some(Exit::Discard) } else { self.confirm = true; @@ -207,7 +220,7 @@ impl RulesScreen { } } /// Escape: ConfirmScreen answers no; the rules close. - pub(super) fn escape(&mut self) -> Option { + pub fn escape(&mut self) -> Option { if self.confirm { self.confirm = false; None @@ -216,7 +229,7 @@ impl RulesScreen { } } /// The mouse wheel (AbstractSelectionList: half an entry a step). - pub(super) fn wheel(&mut self, delta: f32, h: f32) { + pub fn wheel(&mut self, delta: f32, h: f32) { if !self.confirm { let content = self.content(); self.scroll @@ -224,20 +237,20 @@ impl RulesScreen { } } /// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling). - pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool { + pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool { let content = self.content(); self.scroll .press(cursor, Self::bar(w), HEADER, h - FOOTER, content) } /// The pointer moved while the scrollbar is held. - pub(super) fn drag(&mut self, y: f32, h: f32) { + pub fn drag(&mut self, y: f32, h: f32) { if self.scroll.held() { let content = self.content(); self.scroll.drag(y, HEADER, h - FOOTER, content); } } /// The button came up: the scrollbar lets go. - pub(super) fn release(&mut self) { + pub fn release(&mut self) { self.scroll.release(); } fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> { @@ -272,7 +285,7 @@ impl RulesScreen { } } /// Run an edit on the focused box, then its responder. - pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) { + pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) { let Some((rule, b)) = self.focused() else { return; }; @@ -282,12 +295,12 @@ impl RulesScreen { self.edited(rule); } } - pub(super) fn typing(&self) -> bool { + pub fn typing(&self) -> bool { !self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused)) } /// A left click; Some when the screen closes. - pub(super) fn click( + pub fn click( &mut self, atlas: &HudAtlas, cursor: (f32, f32), @@ -355,12 +368,7 @@ impl RulesScreen { } } /// Whether a click lands on a button (for its sound). - pub(super) fn clicks_button( - &self, - atlas: &HudAtlas, - cursor: (f32, f32), - size: (f32, f32), - ) -> bool { + pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool { let (w, h) = size; let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0); if self.confirm { @@ -377,9 +385,9 @@ impl RulesScreen { }) } - pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) { + pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) { let (w, h) = (batch.width, batch.height); - ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true); + ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world); if self.confirm { let labels = [ Text::translate("gui.yes", &[]), @@ -393,7 +401,7 @@ impl RulesScreen { self.scroll.clamp(HEADER, h - FOOTER, content); // RuleList: the in-world list background, its entries clipped to it, // then the separators and scrollbar. - ui::list_background(batch, atlas, HEADER, h - FOOTER, true); + ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world); let first = batch.vertices.len(); let hovered = self.hovered(rows.len(), cursor, (w, h)); let (left, x) = (row_left(w), control_x(w)); @@ -450,7 +458,7 @@ impl RulesScreen { } } batch.clip_since(first, 0.0, HEADER, w, h - FOOTER); - ui::separators(batch, atlas, HEADER, h - FOOTER, true); + ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world); ui::scrollbar( batch, atlas, @@ -526,8 +534,8 @@ mod tests { #[test] fn box_colours_are_vanillas() { - assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255)); - assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255)); + assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255)); + assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255)); } #[test] diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 70d882a98..801dedde9 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -35,9 +35,13 @@ fn entity_shader() -> String { #[cfg(test)] mod crumbling_tests; mod entity_batch; +mod entity_outline; mod weather; use entity_batch::EntityBatch; -pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY}; +pub use entity_batch::{ + ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, + TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, +}; /// Window.getAppropriateLineWidth, in framebuffer pixels. pub fn outline_width(width: u32) -> f32 { @@ -172,12 +176,13 @@ struct Globals { inverse_view: [[f32; 4]; 4], sunrise: [f32; 4], sun: [f32; 4], - /// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon), w: unused. + /// x: sky fog end, y: cloud fog end, z: dark disc (camera below the horizon), + /// w: the glint strength (`GlintAlpha`). fog_ends: [f32; 4], /// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png. end_portal_sprites: [[f32; 4]; 2], /// x: vanilla's `GameTime` uniform; y: nether cardinal lighting; z: how many - /// `dynamic_lights` are set; w unused. + /// `dynamic_lights` are set; w: the glint scroll clock (`Glint::scroll_ticks`). time: [f32; 4], /// Atlas rect of a destroy stage (entity.wgsl takes only its size: every stage's is the same, /// and each crack carries its own stage's origin). @@ -247,6 +252,8 @@ pub struct Frame<'a> { pub outline: Vec<[f64; 6]>, /// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain. pub outline_after_terrain: bool, + /// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one. + pub outline_high_contrast: bool, /// `lines` with a colour and pixel width (`gizmos::camera_relative`): /// the fishing line and debug gizmos, depth tested, then gizmos drawn /// over everything. @@ -256,10 +263,19 @@ pub struct Frame<'a> { /// drawn after the hand at its projection, without the bob or fog. pub debug_crosshair: &'a [[f32; 8]], pub hud: &'a HudBatch, + /// `GlintAlpha` (accessibility Glint Strength); the entity shader uses it + /// for held/world items, independent of what the HUD batch last drew. + pub glint_alpha: f32, + /// The glint scroll clock (`Glint::scroll_ticks`): entity.wgsl's + /// `ARMOR_ENTITY_GLINT_TEXTURING` phase. + pub glint_ticks: f32, /// CardinalLighting.Type.NETHER, otherwise DEFAULT. pub nether_lighting: bool, /// Camera-relative entity geometry textured from the shared atlas. pub entities: &'a [Vertex], + /// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`), + /// outlined through walls after the hand. + pub glowing: &'a [Vertex], /// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`). pub entity_spans: &'a [pack_draw::EntitySpan], pub weather: &'a [crate::weather::Vertex], @@ -377,6 +393,7 @@ pub struct Renderer { sky: wgpu::RenderPipeline, dark_disc: wgpu::RenderPipeline, lines: wgpu::RenderPipeline, + lines_secondary: wgpu::RenderPipeline, /// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the /// always-on-top ones. debug_lines: wgpu::RenderPipeline, @@ -391,6 +408,7 @@ pub struct Renderer { weather: weather::Weather, crumbling_pipeline: wgpu::RenderPipeline, shadow_blob_pipeline: wgpu::RenderPipeline, + translucent_item_pipeline: wgpu::RenderPipeline, /// `RenderPipelines.WATER_MASK`: depth writes only, no colour. water_mask_pipeline: wgpu::RenderPipeline, cloud_pipeline: wgpu::RenderPipeline, @@ -400,6 +418,8 @@ pub struct Renderer { cloud_uniform: wgpu::Buffer, cloud_group: wgpu::BindGroup, additive_pipeline: wgpu::RenderPipeline, + /// `RenderPipelines.ENERGY_SWIRL`: additive, unlit, the sprite repeated within itself. + energy_swirl_pipeline: wgpu::RenderPipeline, lightning_pipeline: wgpu::RenderPipeline, lightning: lightning::Renderer, see_through_pipeline: wgpu::RenderPipeline, @@ -407,6 +427,8 @@ pub struct Renderer { celestial_buffer: wgpu::Buffer, end_sky: end_sky::EndSky, stars: stars::Stars, + /// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`). + entity_outline: entity_outline::EntityOutline, panorama: panorama::Panorama, /// `RenderPipelines.WORLD_BORDER` and its buffers. border_pipeline: wgpu::RenderPipeline, @@ -511,10 +533,10 @@ pub struct Renderer { occlusion: occlusion::Occlusion, /// Resolution scale and FXAA (Horizon settings). world_post: world_post::Post, - /// HDR output (Horizon setting): `Some` while the swapchain is scRGB. + /// HDR output (Horizon setting): `Some` while the swapchain is HDR. hdr: Option, - /// Whether the window's surface can present scRGB. - hdr_capable: bool, + /// The HDR output the window's surface can present. + hdr_output: Option, /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. pack_post: Option<(i32, Option)>, /// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s. @@ -1019,6 +1041,8 @@ impl Renderer { sprite_mips: &[crate::mipmap::Settings], animations: &[SpriteAnimation], hud_atlas: (&[u8], u32, u32), + glint: (&[u8], u32, u32), + glint_armor: (&[u8], u32, u32), forcefield: (&[u8], u32), panorama_faces: &panorama::Faces, prepared: Option, @@ -1071,7 +1095,7 @@ impl Renderer { let info = adapter.get_info(); crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info)); let size = window.inner_size(); - let (config, present_modes, hdr_capable) = if let Some(surface) = &surface { + let (config, present_modes, hdr_output) = if let Some(surface) = &surface { let mut config = surface .get_default_config(&adapter, size.width.max(1), size.height.max(1)) .ok_or("surface has no supported configuration")?; @@ -1099,7 +1123,7 @@ impl Renderer { )); } surface.configure(&device, &config); - (config, caps.present_modes.clone(), hdr::supported(&caps)) + (config, caps.present_modes.clone(), hdr::output(&caps)) } else { let format = wgpu::TextureFormat::Bgra8Unorm; let config = wgpu::SurfaceConfiguration { @@ -1113,7 +1137,7 @@ impl Renderer { view_formats: vec![format], color_space: wgpu::SurfaceColorSpace::default(), }; - (config, Vec::new(), false) + (config, Vec::new(), None) }; let offscreen = surface .is_none() @@ -1213,12 +1237,6 @@ impl Renderer { attributes: &instance_attributes, }), ]; - let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3]; - let line_buffers = [Some(wgpu::VertexBufferLayout { - array_stride: 24, - step_mode: wgpu::VertexStepMode::Instance, - attributes: &line_attributes, - })]; let alpha_blend = wgpu::BlendState::ALPHA_BLENDING; let debug_line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32]; @@ -1351,13 +1369,25 @@ impl Renderer { "Block outline", "vs_line", "fs_line", - &line_buffers, + &debug_line_buffers, wgpu::PrimitiveTopology::TriangleStrip, None, true, true, Some(alpha_blend), ); + // RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline. + let lines_secondary = pipeline( + "Block outline secondary", + "vs_line", + "fs_line", + &debug_line_buffers, + wgpu::PrimitiveTopology::TriangleStrip, + None, + false, + true, + Some(alpha_blend), + ); let debug_lines = pipeline( "Debug lines", "vs_debug_line", @@ -1531,7 +1561,7 @@ impl Renderer { }); let outline = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Outline"), - size: 24 * MAX_OUTLINE_EDGES, + size: 32 * 2 * MAX_OUTLINE_EDGES, usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -1572,7 +1602,68 @@ impl Renderer { atlas_pixels, ); let atlas_view = atlas.create_view(&Default::default()); - let mut hud = HudRenderer::new(&device, config.format.remove_srgb_suffix(), &atlas_view)?; + // vanilla's `GlintSampler`: enchanted_glint_item as its own texture, + // repeated per pixel (`clamp: false`), linear without mipmaps + // (`blur: true`). + let (glint_pixels, glint_width, glint_height) = glint; + let glint_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("Enchanted glint"), + size: wgpu::Extent3d { + width: glint_width, + height: glint_height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + glint_pixels, + ); + let glint_view = glint_texture.create_view(&Default::default()); + let glint_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Glint"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + // ARMOR_ENTITY_GLINT_TEXTURING's sampler is vanilla's GlintSampler: the + // same repeat and linear filtering as the item glint. + let (glint_armor_pixels, glint_armor_width, glint_armor_height) = glint_armor; + let glint_armor_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("Enchanted armor glint"), + size: wgpu::Extent3d { + width: glint_armor_width, + height: glint_armor_height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + glint_armor_pixels, + ); + let glint_armor_view = glint_armor_texture.create_view(&Default::default()); + let mut hud = HudRenderer::new( + &device, + config.format.remove_srgb_suffix(), + &atlas_view, + &glint_view, + &glint_sampler, + )?; hud.atlas_texture = Some(atlas.clone()); let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon entities"), @@ -1618,6 +1709,29 @@ impl Renderer { vf, wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), ), + entry( + 5, + vf, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ), + entry( + 6, + vf, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + ), + entry( + 7, + vf, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ), ], }); let weather = weather::Weather::new( @@ -1654,6 +1768,18 @@ impl Renderer { binding: 4, resource: wgpu::BindingResource::Sampler(&lightmap_sampler), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&glint_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&glint_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&glint_armor_view), + }, ], }); let hand_group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1680,6 +1806,18 @@ impl Renderer { binding: 4, resource: wgpu::BindingResource::Sampler(&lightmap_sampler), }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&glint_view), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: wgpu::BindingResource::Sampler(&glint_sampler), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&glint_armor_view), + }, ], }); let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -1724,7 +1862,7 @@ impl Renderer { usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; + let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; let entity_vertex = [Some(wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, @@ -1818,25 +1956,40 @@ impl Renderer { 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). + // writes. The quads lie exactly on the block tops like vanilla's, but + // terrain and shadows are separate draw paths, so their fp32 depth + // interpolations disagree by a few dozen ulps of the fragment depth + // and the shadow's rim z-fights with the block top (speckle that + // re-rolls as the camera drifts a subpixel). A constant bias of 128 + // ulps covers that spread at any resolution while staying far below + // the 0.001 a model's feet sit higher, so shadows never bleed onto + // the feet. let shadow_blob_pipeline = entity_pipeline( "Entity shadows", None, false, wgpu::CompareFunction::GreaterEqual, wgpu::DepthBiasState { - constant: 16, + constant: 128, slope_scale: 0.015, clamp: 0.0, }, alpha_blend, false, ); + // RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before + // translucent terrain, it cannot write depth: the water behind a + // glass item would fail the test and vanish (vanilla sorts these in + // its order independent transparency pass instead). + let translucent_item_pipeline = entity_pipeline( + "Translucent items", + None, + false, + wgpu::CompareFunction::GreaterEqual, + Default::default(), + alpha_blend, + false, + ); let cloud_pipeline = entity_pipeline( "Clouds", Some(wgpu::Face::Back), @@ -1865,7 +2018,29 @@ impl Renderer { alpha_blend, false, ); - let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3]; + // RenderPipelines.ENERGY_SWIRL: BlendFunction.ADDITIVE (one, one), no culling; no depth + // writes either: vanilla sorts the quads far to near, so every face passes the test. + let one_one = wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }; + let energy_swirl_pipeline = entity_pipeline_with( + "Energy swirls", + None, + false, + wgpu::CompareFunction::GreaterEqual, + Default::default(), + wgpu::BlendState { + color: one_one, + alpha: one_one, + }, + false, + "fs_energy_swirl", + wgpu::ColorWrites::ALL, + ); + // Attribute 7: 6 is the entity vertex buffer's glint UV. + let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3]; let lightning_vertex = [ entity_vertex[0].clone(), Some(wgpu::VertexBufferLayout { @@ -1953,22 +2128,23 @@ impl Renderer { multisample: Default::default(), fragment: Some(wgpu::FragmentState { module: &entity_shader, - entry_point: Some("fs_celestial"), + entry_point: Some("fs_sky_celestial"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: config.format.remove_srgb_suffix(), - // RenderPipelines.CELESTIAL: additive (src alpha, one). The - // alpha goes to 0: HDR output (hdr.wgsl) lets the sun and - // moon shine; nothing else reads the frame's alpha. + // RenderPipelines.CELESTIAL: additive (src alpha, one), with + // the colour premultiplied in fs_sky_celestial. Its alpha is + // how much a texel adds: HDR output (hdr.wgsl) lets the sun's + // and moon's discs shine; nothing else reads the frame's alpha. blend: Some(wgpu::BlendState { color: wgpu::BlendComponent { - src_factor: wgpu::BlendFactor::SrcAlpha, + src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add, }, alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::Zero, - dst_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, }), @@ -2316,6 +2492,11 @@ impl Renderer { let uploads = upload::Uploads::new(&device); let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK); let stars = stars::Stars::new(&device, config.format.remove_srgb_suffix()); + let entity_outline = entity_outline::EntityOutline::new( + &device, + (&entity_shader, &entity_layout, &entity_vertex), + config.format.remove_srgb_suffix(), + ); let panorama = panorama::Panorama::new( &device, &queue, @@ -2361,6 +2542,7 @@ impl Renderer { sky, dark_disc, lines, + lines_secondary, debug_lines, debug_lines_on_top, debug_line_buffer, @@ -2371,6 +2553,7 @@ impl Renderer { weather, crumbling_pipeline, shadow_blob_pipeline, + translucent_item_pipeline, water_mask_pipeline, cloud_pipeline, cloud_mesh: Default::default(), @@ -2379,6 +2562,7 @@ impl Renderer { cloud_uniform, cloud_group, additive_pipeline, + energy_swirl_pipeline, lightning_pipeline, lightning: lightning::Renderer::new(), see_through_pipeline, @@ -2386,6 +2570,7 @@ impl Renderer { celestial_buffer, end_sky: end_sky::EndSky::default(), stars, + entity_outline, panorama, border_pipeline, border_group, @@ -2456,7 +2641,7 @@ impl Renderer { occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, hdr: None, - hdr_capable, + hdr_output, pack_post: None, native: None, pass_timing: pack_timing::PassTiming::logging(), @@ -2482,6 +2667,18 @@ impl Renderer { present_modes, }) } + /// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black + /// secondary stroke first when high contrast is on. + fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) { + pass.set_vertex_buffer(0, self.outline.slice(..)); + if high_contrast { + pass.set_pipeline(&self.lines_secondary); + pass.draw(0..4, 0..edges); + } + pass.set_pipeline(&self.lines); + let first = if high_contrast { edges } else { 0 }; + pass.draw(0..4, first..first + edges); + } fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer { let indices: Vec = (0..quads) .flat_map(|q| { @@ -2990,24 +3187,24 @@ impl Renderer { } } /// The swapchain's configuration: `config` (what every pipeline draws in), or - /// scRGB while HDR output is on. + /// HDR10 or scRGB while HDR output is on. fn surface_config(&self) -> wgpu::SurfaceConfiguration { let mut config = self.config.clone(); - if self.hdr.is_some() { - config.format = hdr::FORMAT; - config.view_formats = vec![hdr::FORMAT]; - config.color_space = hdr::COLOR_SPACE; + if let Some(hdr) = &self.hdr { + config.format = hdr.output.format(); + config.view_formats = vec![hdr.output.format()]; + config.color_space = hdr.output.color_space(); config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT; } config } - /// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's + /// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's /// highlights expanded up to `peak` nits. Off where the surface can't present it. pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) { - let on = on && self.hdr_capable && self.surface.is_some(); - if on != self.hdr.is_some() { - self.hdr = - on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix())); + let output = self.hdr_output.filter(|_| on && self.surface.is_some()); + if output.is_some() != self.hdr.is_some() { + let format = self.config.format.remove_srgb_suffix(); + self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output)); self.configure_surface(); } if let Some(hdr) = &mut self.hdr @@ -3194,7 +3391,7 @@ impl Renderer { /// The Mipmap Levels option: block textures sample at most `levels` /// reduced copies (0: none). pub fn set_mipmap_levels(&mut self, levels: u32) { - let levels = sprite_mipmap_limit( + let levels = crate::sprites::sprite_mipmap_limit( levels, self.sprite_textures[0].width(), self.sprite_minimum_size, @@ -3475,7 +3672,7 @@ impl Renderer { frame.fog.map_or(frame.distance.min(512.0), |f| f.sky_end), frame.fog.map_or(2048.0, |f| f.cloud_end), if frame.dark_disc { 1.0 } else { 0.0 }, - 0.0, + frame.glint_alpha, ], end_portal_sprites: frame.end_portal_sprites, crumbling_sprite: frame.crumbling_sprite, @@ -3483,7 +3680,7 @@ impl Renderer { frame.shader_game_time, f32::from(frame.nether_lighting), frame.dynamic_lights.len().min(crate::dynamic_light::MAX) as f32, - 0.0, + frame.glint_ticks, ], outline_params: outline_params(self.config.width, self.config.height), dynamic_lights: { @@ -3954,7 +4151,26 @@ impl Renderer { ); let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize); if outline_edges > 0 { - let lines = outline_vertices(&frame.outline[..outline_edges], camera); + // The same per-line colour and pixel width layout as the gizmo lines. A high + // contrast outline is two strokes: the black one first, the bright one after it. + let edges = outline_vertices(&frame.outline[..outline_edges], camera); + let width = outline_width(self.config.width); + let stroke = |color: u32, width: f32| { + edges.iter().map(move |e| { + let mut line = [0.0; 8]; + line[..6].copy_from_slice(e); + line[6] = f32::from_bits(color); + line[7] = width; + line + }) + }; + let lines: Vec<[f32; 8]> = if frame.outline_high_contrast { + stroke(0xff00_0000, 7.0) + .chain(stroke(0xff57_ffe1, width)) + .collect() + } else { + stroke(0x6600_0000, width).collect() + }; self.uploads .buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines)); } @@ -3979,8 +4195,10 @@ impl Renderer { crumbling_quads, translucent_crumbling_quads, water_mask_quads, + swirl_quads, shadow_quads, translucent_particle_quads, + translucent_item_quads, ] = self .entity_batch .update(entities, frame.additive, frame.hand, frame.see_through); @@ -3989,8 +4207,10 @@ impl Renderer { + crumbling_quads + translucent_crumbling_quads + water_mask_quads + + swirl_quads + shadow_quads - + translucent_particle_quads; + + translucent_particle_quads + + translucent_item_quads; let cloud_quads = if let Some(input) = &frame.clouds { let (rebuilt, uniform) = crate::spans::time("clouds", || self.cloud_mesh.prepare(input)); @@ -4051,6 +4271,13 @@ impl Renderer { bytemuck::cast_slice(all_entities), ); } + self.entity_outline.prepare( + &self.device, + &mut self.uploads, + &mut encoder, + frame.glowing, + [self.config.width, self.config.height], + ); if let Some((rotation, brightness)) = frame.stars { self.stars.prepare( &mut self.uploads, @@ -4078,7 +4305,8 @@ impl Renderer { } else { lightning::QUADS }) - .max(weather_quads); + .max(weather_quads) + .max(frame.glowing.len() as u32 / 4); if max_quads > self.index_quads { self.index_quads = max_quads.next_power_of_two(); self.indices = Self::quad_indices(&self.device, self.index_quads); @@ -4513,6 +4741,17 @@ impl Renderer { pass.draw_indexed(0..entity_quads * 6, 0, 0..1); pass.set_bind_group(0, &self.group, &[]); } + if translucent_item_quads > 0 { + pass.set_pipeline(&self.translucent_item_pipeline); + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed( + 0..translucent_item_quads * 6, + ((world_quads - translucent_item_quads) * 4) as i32, + 0..1, + ); + pass.set_bind_group(0, &self.group, &[]); + } if !pack_draws_weather { self.weather.draw(&mut pass, &self.entity_group); } @@ -4530,7 +4769,11 @@ impl Renderer { pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); pass.draw_indexed( 0..shadow_quads * 6, - ((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32, + ((world_quads + - translucent_item_quads + - translucent_particle_quads + - shadow_quads) + * 4) as i32, 0..1, ); pass.set_bind_group(0, &self.group, &[]); @@ -4554,6 +4797,21 @@ impl Renderer { self.lightning.draw(&mut pass, &frame.lightning); pass.set_bind_group(0, &self.group, &[]); } + if swirl_quads > 0 { + pass.set_pipeline(&self.energy_swirl_pipeline); + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed( + 0..swirl_quads * 6, + ((entity_quads + + crumbling_quads + + translucent_crumbling_quads + + water_mask_quads) + * 4) as i32, + 0..1, + ); + pass.set_bind_group(0, &self.group, &[]); + } if additive_quads > 0 { pass.set_pipeline(&self.additive_pipeline); pass.set_bind_group(0, &self.entity_group, &[]); @@ -4562,9 +4820,7 @@ impl Renderer { pass.set_bind_group(0, &self.group, &[]); } if outline_edges > 0 && !frame.outline_after_terrain { - pass.set_pipeline(&self.lines); - pass.set_vertex_buffer(0, self.outline.slice(..)); - pass.draw(0..4, 0..outline_edges as u32); + self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast); } if debug_depth > 0 { pass.set_pipeline(&self.debug_lines); @@ -4611,7 +4867,8 @@ impl Renderer { pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); pass.draw_indexed( 0..translucent_particle_quads * 6, - ((world_quads - translucent_particle_quads) * 4) as i32, + ((world_quads - translucent_item_quads - translucent_particle_quads) * 4) + as i32, 0..1, ); pass.set_bind_group(0, &self.group, &[]); @@ -4619,9 +4876,7 @@ impl Renderer { // LevelRenderer.executeClassicTransparency: translucent targets' // outlines follow terrain, so its blending cannot wash them out. if outline_edges > 0 && frame.outline_after_terrain { - pass.set_pipeline(&self.lines); - pass.set_vertex_buffer(0, self.outline.slice(..)); - pass.draw(0..4, 0..outline_edges as u32); + self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast); } pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); // LevelRenderer: the world border after translucent terrain and weather. @@ -4808,6 +5063,13 @@ impl Renderer { 0..1, ); } + // GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand. + self.entity_outline.draw( + &mut encoder, + &view_target, + &self.entity_group, + &self.indices, + ); if !frame.screen.is_empty() { let attachments = [Some(wgpu::RenderPassColorAttachment { view: &view_target, @@ -5222,6 +5484,7 @@ mod tests { color: 0xffff_ffff, light: id, normal: [0; 4], + glint_uv: [0.0, 0.0], }; 4] }; let entities: Vec<_> = [ @@ -5240,7 +5503,7 @@ mod tests { 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, 1, 1]); + assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]); assert_eq!( vertices .as_chunks::<4>() @@ -5476,11 +5739,6 @@ mod tests { } } -pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 { - // Only the atlas native minimum determines how many resampling-only mips to skip. - let upscale = (size / minimum.max(1)).max(1).ilog2(); - requested.saturating_add(upscale).min(available - 1) -} #[cfg(test)] mod sprite_upload_tests { use super::*; @@ -5541,13 +5799,6 @@ mod sprite_upload_tests { } } #[test] - fn mipmap_option_keeps_real_16px_reductions() { - for levels in 0..=4 { - assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1); - assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels); - } - } - #[test] fn prepared_texture_uploads_preserve_layer_order_and_settings() { let size = 4; let layers = 5; diff --git a/crates/horizon-client/src/gfx/end_sky.rs b/crates/horizon-client/src/gfx/end_sky.rs index 15e9001fc..d74e053df 100644 --- a/crates/horizon-client/src/gfx/end_sky.rs +++ b/crates/horizon-client/src/gfx/end_sky.rs @@ -72,6 +72,8 @@ fn vertices(uv: [f32; 4]) -> Vec { layer: 2, color: 0xFF28_2828, light: 0, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/gfx/end_sky/tests.rs b/crates/horizon-client/src/gfx/end_sky/tests.rs index 61266003d..5cd2bcf7f 100644 --- a/crates/horizon-client/src/gfx/end_sky/tests.rs +++ b/crates/horizon-client/src/gfx/end_sky/tests.rs @@ -12,15 +12,15 @@ fn geometry_matches_pre_residency_vertex_snapshots() { for (uv, expected) in [ ( [0.0, 0.0, 1.0, 1.0], - "f7ee23457fc557b33ecf09c9afaf858f3d8907e108abf06055f627523f733536", + "82a9be3f3ed1f733017df2c81dc6dff163f67bf739c768e3fd0f90f00c7802f6", ), ( [0.125, 0.25, 0.375, 0.5], - "f74c4b8d23ec2b7c11b5752821d0733851bf11358f1b26f9448226ff62a6a318", + "05a058bd8e70cff900c8e1c81061892ed27866d86498ebe1e8ae5af4c3b770ef", ), ( [0.13, 0.27, 0.41, 0.59], - "f0aaa39c5b22168bdc7c6c329acb4d3598a2feb61ff7b6f2850b644682749b39", + "93064b9fd2e745da46b927b2625edc8a381bb8c6e20b0f0880e816c4c1745ec1", ), ] { let mut mesh = vertices(uv); @@ -31,10 +31,8 @@ fn geometry_matches_pre_residency_vertex_snapshots() { if n == 0.0 { 0.0 } else { n } }); } - assert_eq!( - format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))), - expected - ); + let hash = format!("{:x}", Sha256::digest(bytemuck::cast_slice(&mesh))); + assert_eq!(hash, expected); } } @@ -95,7 +93,7 @@ fn separate_draws_match_combined_celestial_pixels() { label: Some("End sky differential shader"), source: wgpu::ShaderSource::Wgsl(crate::gfx::entity_shader().into()), }); - let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("End sky differential pipeline"), layout: None, @@ -305,6 +303,8 @@ fn separate_draws_match_combined_celestial_pixels() { color: level * 0x01010101, light: 0, normal: [0; 4], + + glint_uv: [0.0, 0.0], }) .to_vec() }; diff --git a/crates/horizon-client/src/gfx/entity_batch.rs b/crates/horizon-client/src/gfx/entity_batch.rs index 8153699fc..f71545f2c 100644 --- a/crates/horizon-client/src/gfx/entity_batch.rs +++ b/crates/horizon-client/src/gfx/entity_batch.rs @@ -3,16 +3,27 @@ use super::Vertex; /// Entity materials by a quad's overlay (`Vertex::layer`), in draw order: /// ordinary entities, breaking cracks (11), translucent breaking cracks (12), -/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows -/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15, -/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`). -const MATERIALS: usize = 6; +/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls +/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14, +/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn +/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`) +/// and translucent item quads (32, drawn without depth writes). +const MATERIALS: usize = 8; /// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`). pub const WATER_MASK_OVERLAY: u32 = 13; +/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the +/// powered wither's armour). The light bytes carry the sprite's origin and the colour bytes its +/// size (`breaking::stage_light`), the UV the scrolled position in the sprite. +pub const ENERGY_SWIRL_OVERLAY: u32 = 34; /// The overlay of entity shadow quads (`RenderTypes.entityShadow`). pub const SHADOW_OVERLAY: u32 = 14; /// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`). pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15; +/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`, +/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31). +pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32; +/// A translucent item with an enchantment glint. +pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33; #[derive(Default)] pub(super) struct EntityBatch { @@ -43,8 +54,10 @@ impl EntityBatch { 11 => 1, 12 => 2, WATER_MASK_OVERLAY => 3, - SHADOW_OVERLAY => 4, - TRANSLUCENT_PARTICLE_OVERLAY => 5, + ENERGY_SWIRL_OVERLAY => 4, + SHADOW_OVERLAY => 5, + TRANSLUCENT_PARTICLE_OVERLAY => 6, + TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7, _ => 0, }; self.buckets[material][counts[material]] = index; @@ -96,12 +109,13 @@ mod tests { ) -> (Vec, [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, 14, 15].into_iter().enumerate() { + for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() { for quad in entities.as_chunks::<4>().0 { let material = match quad[0].layer { 11 => 11, 12 => 12, 13 => 13, + 34 => 34, 14 => 14, 15 => 15, _ => 0, @@ -130,6 +144,8 @@ mod tests { color: index as u32, light: (index * 3) as u32, normal: [1, 2, 3, 4], + + glint_uv: [0.0, 0.0], }) .collect() } diff --git a/crates/horizon-client/src/gfx/entity_outline.rs b/crates/horizon-client/src/gfx/entity_outline.rs new file mode 100644 index 000000000..748622019 --- /dev/null +++ b/crates/horizon-client/src/gfx/entity_outline.rs @@ -0,0 +1,311 @@ +//! The entity outline (`LevelRenderer.entityOutlineTarget`): the glowing entities' +//! silhouettes in their team colours, drawn without depth so walls do not hide them +//! (`RenderTypes.outline`), the `entity_outline` post chain over them (`entity_sobel`, then +//! `entity_outline_box_blur` across and up), and `LevelRenderer.blitEntityOutline` onto +//! the frame after the hand. +use super::Vertex; + +/// `entityOutlineTarget`'s `GpuFormat.RGBA8_UNORM`. +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +struct Targets { + size: [u32; 2], + /// `entityOutlineTarget`. + outline: wgpu::TextureView, + /// The chain's `swap` target. + swap: wgpu::TextureView, + /// What each pass reads: the outline (nearest), swap (bilinear), the outline + /// (bilinear), swap (nearest). + groups: [wgpu::BindGroup; 4], +} + +pub(super) struct EntityOutline { + silhouette: wgpu::RenderPipeline, + /// Sobel, blur across, blur up (into `FORMAT`) and the blended blit (onto the frame). + passes: [wgpu::RenderPipeline; 4], + layout: wgpu::BindGroupLayout, + nearest: wgpu::Sampler, + bilinear: wgpu::Sampler, + vertices: wgpu::Buffer, + capacity: u64, + quads: u32, + targets: Option, +} + +impl EntityOutline { + /// `entity` draws the silhouettes with the entity shader's vertex stage and + /// `fs_outline`; `format` is the frame's. + pub(super) fn new( + device: &wgpu::Device, + entity: ( + &wgpu::ShaderModule, + &wgpu::PipelineLayout, + &[Option], + ), + format: wgpu::TextureFormat, + ) -> Self { + let (entity_shader, entity_layout, entity_vertex) = entity; + // RenderPipelines.OUTLINE_NO_CULL: no depth test, the colour replaced. + let silhouette = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Entity outline silhouettes"), + layout: Some(entity_layout), + vertex: wgpu::VertexState { + module: entity_shader, + entry_point: Some("vs_entity"), + compilation_options: Default::default(), + buffers: entity_vertex, + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: entity_shader, + entry_point: Some("fs_outline"), + compilation_options: Default::default(), + targets: &[Some(FORMAT.into())], + }), + multiview_mask: None, + cache: None, + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Entity outline"), + source: wgpu::ShaderSource::Wgsl(include_str!("entity_outline.wgsl").into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Entity outline bindings"), + 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::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Entity outline"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let pass = |entry: &str, target: wgpu::ColorTargetState| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_quad"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some(entry), + compilation_options: Default::default(), + targets: &[Some(target)], + }), + multiview_mask: None, + cache: None, + }) + }; + // BlendFunction.ENTITY_OUTLINE_BLIT over the colour only (write mask 7). + let blit = wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::SrcAlpha, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Zero, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }), + write_mask: wgpu::ColorWrites::COLOR, + }; + let sampler = |label, filter| { + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some(label), + mag_filter: filter, + min_filter: filter, + ..Default::default() + }) + }; + Self { + silhouette, + passes: [ + pass("fs_sobel", FORMAT.into()), + pass("fs_blur_x", FORMAT.into()), + pass("fs_blur_y", FORMAT.into()), + pass("fs_blit", blit), + ], + layout, + nearest: sampler("Entity outline nearest", wgpu::FilterMode::Nearest), + bilinear: sampler("Entity outline bilinear", wgpu::FilterMode::Linear), + vertices: Self::vertex_buffer(device, 256), + capacity: 256, + quads: 0, + targets: None, + } + } + + fn vertex_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Entity outline vertices"), + size: capacity * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + + /// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline` + /// when there are any) and sizes the targets like the window. + pub(super) fn prepare( + &mut self, + device: &wgpu::Device, + uploads: &mut super::upload::Uploads, + encoder: &mut wgpu::CommandEncoder, + silhouettes: &[Vertex], + size: [u32; 2], + ) { + self.quads = (silhouettes.len() / 4) as u32; + if self.quads == 0 { + return; + } + let vertices = silhouettes.len() as u64; + if vertices > self.capacity { + self.capacity = vertices.next_power_of_two(); + self.vertices = Self::vertex_buffer(device, self.capacity); + } + uploads.buffer( + encoder, + &self.vertices, + 0, + bytemuck::cast_slice(silhouettes), + ); + if self.targets.as_ref().is_some_and(|t| t.size == size) { + return; + } + let texture = |label| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let outline = texture("Entity outline"); + let swap = texture("Entity outline swap"); + let group = |view: &wgpu::TextureView, sampler: &wgpu::Sampler| { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Entity outline pass"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + ], + }) + }; + let groups = [ + group(&outline, &self.nearest), + group(&swap, &self.bilinear), + group(&outline, &self.bilinear), + group(&swap, &self.nearest), + ]; + self.targets = Some(Targets { + size, + outline, + swap, + groups, + }); + } + + /// `LevelRenderer.executeOutline`, the post chain and `blitEntityOutline` onto `frame`. + pub(super) fn draw( + &self, + encoder: &mut wgpu::CommandEncoder, + frame: &wgpu::TextureView, + entity_group: &wgpu::BindGroup, + indices: &wgpu::Buffer, + ) { + let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else { + return; + }; + let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT); + { + let mut pass = begin(encoder, "Entity outline", &t.outline, clear); + pass.set_pipeline(&self.silhouette); + pass.set_bind_group(0, entity_group, &[]); + pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(0, self.vertices.slice(..)); + pass.draw_indexed(0..self.quads * 6, 0, 0..1); + } + // post_effect/entity_outline.json; its last pass only copies swap back to the + // outline target, so the blit reads swap. + let steps = [ + ("Entity outline sobel", &t.swap, clear), + ("Entity outline blur across", &t.outline, clear), + ("Entity outline blur up", &t.swap, clear), + ("Entity outline blit", frame, wgpu::LoadOp::Load), + ]; + for (i, (label, view, load)) in steps.into_iter().enumerate() { + let mut pass = begin(encoder, label, view, load); + pass.set_pipeline(&self.passes[i]); + pass.set_bind_group(0, &t.groups[i], &[]); + pass.draw(0..3, 0..1); + } + } +} + +fn begin<'e>( + encoder: &'e mut wgpu::CommandEncoder, + label: &str, + view: &wgpu::TextureView, + load: wgpu::LoadOp, +) -> wgpu::RenderPass<'e> { + let attachments = [Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load, + store: wgpu::StoreOp::Store, + }, + })]; + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(label), + color_attachments: &attachments, + ..Default::default() + }) +} diff --git a/crates/horizon-client/src/gfx/entity_outline.wgsl b/crates/horizon-client/src/gfx/entity_outline.wgsl new file mode 100644 index 000000000..bd723f11e --- /dev/null +++ b/crates/horizon-client/src/gfx/entity_outline.wgsl @@ -0,0 +1,60 @@ +// The entity_outline post chain (post_effect/entity_outline.json) over the silhouettes of +// the glowing entities, then RenderTarget.blitAndBlendToTexture onto the frame. +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; + +struct PostOut { + @builtin(position) clip: vec4, + @location(0) uv: vec2, +}; + +// screenquad.vsh, with v running down the rows as wgpu's textures do. +@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> PostOut { + let p = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + var out: PostOut; + out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0); + out.uv = vec2(p.x, 1.0 - p.y); + return out; +} + +fn tap(uv: vec2) -> vec4 { + return textureSampleLevel(source, source_sampler, uv, 0.0); +} + +// entity_sobel.fsh: alpha marks where coverage changes, the colour is the mean of the +// covered texels around. +@fragment fn fs_sobel(in: PostOut) -> @location(0) vec4 { + let texel = 1.0 / vec2(textureDimensions(source)); + let center = tap(in.uv); + let left = tap(in.uv - vec2(texel.x, 0.0)); + let right = tap(in.uv + vec2(texel.x, 0.0)); + let up = tap(in.uv - vec2(0.0, texel.y)); + let down = tap(in.uv + vec2(0.0, texel.y)); + let total = clamp(abs(center.a - left.a) + abs(center.a - right.a) + + abs(center.a - up.a) + abs(center.a - down.a), 0.0, 1.0); + let color = center.rgb * center.a + left.rgb * left.a + right.rgb * right.a + + up.rgb * up.a + down.rgb * down.a; + return vec4(color * 0.2, total); +} + +// entity_outline_box_blur.fsh at Radius 2: bilinear taps at -1.5 and 0.5 texels and half +// a tap at 2; the colour averaged, the alpha summed. +fn blur(uv: vec2, step: vec2) -> vec4 { + let blurred = tap(uv + step * -1.5) + tap(uv + step * 0.5) + tap(uv + step * 2.0) / 2.0; + return vec4(blurred.rgb / 2.5, blurred.a); +} + +// BlurDir (1, 0). +@fragment fn fs_blur_x(in: PostOut) -> @location(0) vec4 { + return blur(in.uv, vec2(1.0 / f32(textureDimensions(source).x), 0.0)); +} + +// BlurDir (0, 1): up the screen in GL's bottom-up textures, towards row 0 here. +@fragment fn fs_blur_y(in: PostOut) -> @location(0) vec4 { + return blur(in.uv, vec2(0.0, -1.0 / f32(textureDimensions(source).y))); +} + +// blit_screen.fsh, blended by RenderPipelines.ENTITY_OUTLINE_BLIT. +@fragment fn fs_blit(in: PostOut) -> @location(0) vec4 { + return tap(in.uv); +} diff --git a/crates/horizon-client/src/gfx/hdr.rs b/crates/horizon-client/src/gfx/hdr.rs index 11deed546..8b9df7af0 100644 --- a/crates/horizon-client/src/gfx/hdr.rs +++ b/crates/horizon-client/src/gfx/hdr.rs @@ -1,17 +1,50 @@ //! HDR output (Horizon setting, off by default): the frame renders exactly as //! without it, into an 8-bit target in vanilla's gamma space, and one pass -//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white, -//! expanding the world's highlights towards their peak brightness. The world -//! is copied aside before the hand and HUD so only it is expanded. +//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper +//! white, expanding the world's highlights towards their peak brightness. The +//! world is copied aside before the hand and HUD so only it is expanded. -/// The swapchain format and colour space HDR output presents in. -pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; -pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear; +/// The swapchain HDR output presents to. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Output { + /// HDR10: 10-bit BT.2020 with the PQ curve, the display's own signal. + Pq, + /// scRGB: linear 16-bit float, BT.709 primaries, 1.0 = 80 nits. + ScRgb, +} -/// Whether `caps` can present scRGB. -pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool { - caps.color_spaces(FORMAT) - .contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR) +impl Output { + pub fn format(self) -> wgpu::TextureFormat { + match self { + Self::Pq => wgpu::TextureFormat::Rgb10a2Unorm, + Self::ScRgb => wgpu::TextureFormat::Rgba16Float, + } + } + pub fn color_space(self) -> wgpu::SurfaceColorSpace { + match self { + Self::Pq => wgpu::SurfaceColorSpace::Bt2100Pq, + Self::ScRgb => wgpu::SurfaceColorSpace::ExtendedSrgbLinear, + } + } + fn space(self) -> wgpu::SurfaceColorSpaces { + match self { + Self::Pq => wgpu::SurfaceColorSpaces::BT2100_PQ, + Self::ScRgb => wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR, + } + } +} + +/// The HDR output `caps` can present: HDR10 where offered, else scRGB. +/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one. +pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option { + pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref()) +} +fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option { + [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")] + .into_iter() + .filter(|(_, name)| only.is_none_or(|o| o == *name)) + .map(|(output, _)| output) + .find(|o| caps.color_spaces(o.format()).contains(o.space())) } struct Targets { @@ -25,6 +58,7 @@ pub struct Hdr { layout: wgpu::BindGroupLayout, pipeline: wgpu::RenderPipeline, params: wgpu::Buffer, + pub output: Output, /// The frame's format: what every other pipeline draws in. format: wgpu::TextureFormat, targets: Option, @@ -36,7 +70,7 @@ pub struct Hdr { } impl Hdr { - pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self { + pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon HDR"), source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()), @@ -90,7 +124,7 @@ impl Hdr { entry_point: Some("fs_hdr"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { - format: FORMAT, + format: output.format(), blend: None, write_mask: wgpu::ColorWrites::ALL, })], @@ -100,7 +134,7 @@ impl Hdr { }); let params = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Horizon HDR parameters"), - size: 16, + size: 32, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); @@ -108,6 +142,7 @@ impl Hdr { layout, pipeline, params, + output, format, targets: None, debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"), @@ -198,6 +233,10 @@ impl Hdr { (self.peak / paper).max(1.0), f32::from(u8::from(world)), f32::from(u8::from(self.debug)), + f32::from(u8::from(self.output == Output::Pq)), + 0.0, + 0.0, + 0.0, ]; queue.write_buffer(&self.params, 0, bytemuck::cast_slice(¶ms)); let view = target.create_view(&Default::default()); @@ -220,3 +259,37 @@ impl Hdr { pass.draw(0..3, 0..1); } } + +#[cfg(test)] +mod tests { + use super::{Output, pick}; + use wgpu::{SurfaceColorSpaces as S, TextureFormat as F}; + + fn caps(offered: &[(F, S)]) -> wgpu::SurfaceCapabilities { + wgpu::SurfaceCapabilities { + format_capabilities: offered + .iter() + .map(|&(format, color_spaces)| wgpu::SurfaceFormatCapabilities { + format, + color_spaces, + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn hdr10_first_then_scrgb() { + let both = caps(&[ + (F::Bgra8UnormSrgb, S::SRGB), + (F::Rgb10a2Unorm, S::BT2100_PQ | S::BT2100_HLG), + (F::Rgba16Float, S::EXTENDED_SRGB_LINEAR), + ]); + assert_eq!(pick(&both, None), Some(Output::Pq)); + assert_eq!(pick(&both, Some("scrgb")), Some(Output::ScRgb)); + let scrgb = caps(&[(F::Rgba16Float, S::EXTENDED_SRGB_LINEAR)]); + assert_eq!(pick(&scrgb, None), Some(Output::ScRgb)); + assert_eq!(pick(&scrgb, Some("pq")), None); + assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None); + } +} diff --git a/crates/horizon-client/src/gfx/lightning.rs b/crates/horizon-client/src/gfx/lightning.rs index 251dbf00e..d979bb368 100644 --- a/crates/horizon-client/src/gfx/lightning.rs +++ b/crates/horizon-client/src/gfx/lightning.rs @@ -77,6 +77,8 @@ fn geometry(seed: i64, uv: [f32; 2]) -> Vec { layer: 2, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/gfx/lightning/tests/mod.rs b/crates/horizon-client/src/gfx/lightning/tests/mod.rs index f89f402de..66959ce54 100644 --- a/crates/horizon-client/src/gfx/lightning/tests/mod.rs +++ b/crates/horizon-client/src/gfx/lightning/tests/mod.rs @@ -60,6 +60,8 @@ fn reference(seed: i64, at: [f64; 3], camera: DVec3, uv: [f32; 2]) -> VecFloat32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4]; - let offsets = wgpu::vertex_attr_array![6=>Float32x3]; + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2]; + // Attribute 7: 6 is the entity vertex buffer's glint UV. + let offsets = wgpu::vertex_attr_array![7=>Float32x3]; let vertices = [Some(wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, diff --git a/crates/horizon-client/src/gfx/pack_entities.rs b/crates/horizon-client/src/gfx/pack_entities.rs index ab3c66c69..c6bcd8beb 100644 --- a/crates/horizon-client/src/gfx/pack_entities.rs +++ b/crates/horizon-client/src/gfx/pack_entities.rs @@ -405,12 +405,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer { } /// Whether a run holds only geometry a gbuffers program can draw from the shared atlas: no -/// celestial body, cloud, beam, End portal or crack (overlay codes Horizon's entity shader -/// draws in its own way; entity.wgsl). +/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity +/// shader draws in its own way; entity.wgsl). fn plain(run: &[Vertex]) -> bool { run.iter() .step_by(4) - .all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12)) + .all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34)) } /// The programs of entity runs, in the order they draw. diff --git a/crates/horizon-client/src/gfx/pack_weather.rs b/crates/horizon-client/src/gfx/pack_weather.rs index 3479450af..f6bef0219 100644 --- a/crates/horizon-client/src/gfx/pack_weather.rs +++ b/crates/horizon-client/src/gfx/pack_weather.rs @@ -135,6 +135,8 @@ impl PackWeather { color: v.color, light: v.light, normal: [0; 4], + + glint_uv: [0.0, 0.0], } })); if self.scratch.len() > self.capacity { diff --git a/crates/horizon-client/src/gizmos.rs b/crates/horizon-client/src/gizmos.rs index d4125710e..70e2b87f3 100644 --- a/crates/horizon-client/src/gizmos.rs +++ b/crates/horizon-client/src/gizmos.rs @@ -204,6 +204,19 @@ impl Gizmos { position[1] + f64::from(size.1), position[2] + half, ]; + self.entity_box(min, max, position, eye_height, living, view); + } + /// The same for an entity whose box is not centred on its position + /// (a painting or an item frame): `min` and `max` are the box. + pub fn entity_box( + &mut self, + min: [f64; 3], + max: [f64; 3], + position: [f64; 3], + eye_height: f32, + living: bool, + view: (f32, f32), + ) { self.cuboid(min, max, WHITE, STROKE_WIDTH); if living { let eye = min[1] + f64::from(eye_height); diff --git a/crates/horizon-client/src/graphics.rs b/crates/horizon-client/src/graphics.rs index 4f6f04bc0..fbf67f9ad 100644 --- a/crates/horizon-client/src/graphics.rs +++ b/crates/horizon-client/src/graphics.rs @@ -216,15 +216,19 @@ pub const INTEGRATED_LOD_DISTANCE: u32 = 128; pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool { info.device_type == wgpu::DeviceType::IntegratedGpu && !info.name.starts_with("Apple") } +/// A LOD distance as far terrain uses it on this adapter. +pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 { + let distance = distance.min(1024); + if lod_capped(info) { + distance.min(INTEGRATED_LOD_DISTANCE) + } else { + distance + } +} impl Graphics { /// `lod_distance` as far terrain uses it on this adapter. pub fn lod_distance_on(&self, info: &wgpu::AdapterInfo) -> u32 { - let distance = self.lod_distance.min(1024); - if lod_capped(info) { - distance.min(INTEGRATED_LOD_DISTANCE) - } else { - distance - } + lod_distance_capped(self.lod_distance, info) } } #[derive(Clone, Debug, PartialEq)] @@ -255,7 +259,7 @@ pub struct Graphics { pub resolution_scale: u32, /// FXAA over the world. pub fxaa: bool, - /// HDR output (scRGB), where the display path offers it. + /// HDR output (HDR10, or scRGB), where the display path offers it. pub hdr: bool, /// HDR paper white (where SDR white sits), in nits. pub hdr_paper_white: u32, diff --git a/crates/horizon-client/src/graphics_style.rs b/crates/horizon-client/src/graphics_style.rs index e02a0e1d9..62979903b 100644 --- a/crates/horizon-client/src/graphics_style.rs +++ b/crates/horizon-client/src/graphics_style.rs @@ -118,8 +118,9 @@ impl Style { (x, y, w): (f32, f32, f32), label: &Text, hover: bool, + active: bool, ) { - ui::button(batch, atlas, x, y, w, 20.0, label, true, hover); + ui::button(batch, atlas, x, y, w, 20.0, label, active, hover); } pub fn slider( &self, diff --git a/crates/horizon-client/src/graphics_ui.rs b/crates/horizon-client/src/graphics_ui.rs index 3c7a40e02..cb097c114 100644 --- a/crates/horizon-client/src/graphics_ui.rs +++ b/crates/horizon-client/src/graphics_ui.rs @@ -5,8 +5,7 @@ use crate::{ graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset}, graphics_style::{Cost, STYLE}, hud::{HudAtlas, HudBatch}, - options_ui::Opt, - settings::Settings, + settings::{Opt, Settings}, text::Text, ui, }; @@ -468,6 +467,10 @@ fn max_workers() -> u32 { } impl Item { + /// False for a setting whose system is not in Horizon yet (`Opt::available`). + fn available(self) -> bool { + !matches!(self, Item::Vanilla(o) if !o.available()) + } /// On/off settings and their value. pub fn switch(self, s: &Settings) -> Option { let g = &s.horizon; @@ -510,6 +513,9 @@ impl Item { } } pub fn set_slider(self, s: &mut Settings, v: f32) { + if !self.available() { + return; + } match self { Item::Vanilla(o) => o.set_slider(s, v), Item::Horizon(h) => h.set_slider(s, v), @@ -517,6 +523,9 @@ impl Item { self.touched(s); } pub fn cycle(self, s: &mut Settings, back: bool, framebuffer: [i32; 2]) { + if !self.available() { + return; + } match self { Item::Vanilla(o) => o.cycle(s, back, framebuffer), Item::Horizon(h) => h.cycle(s, back), @@ -689,6 +698,7 @@ impl GraphicsUi { }) .collect(); let mut index = 0; + let mut tooltip = None; for wd in &widgets { let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0) && (!wd.list || (cursor.1 >= top && cursor.1 < bottom)); @@ -700,6 +710,13 @@ impl GraphicsUi { continue; } let label = item.label(s); + let live = item.available(); + if hover && !live { + tooltip = Some(atlas.font.split( + &Text::literal("This feature is not yet available in Horizon."), + 170.0, + )); + } match item.slider(s) { Some(value) => STYLE.slider( batch, @@ -709,7 +726,14 @@ impl GraphicsUi { value, hover || self.dragging == Some(item), ), - None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover), + None => STYLE.button( + batch, + atlas, + (wd.x, wd.y, wd.w), + &label, + hover && live, + live, + ), } STYLE.description( batch, @@ -732,6 +756,7 @@ impl GraphicsUi { (wd.x, wd.y, wd.w), &Text::literal(label), hover, + true, ); } } @@ -751,6 +776,9 @@ impl GraphicsUi { Level::Advanced => "Graphics: Advanced", }; STYLE.title(batch, atlas, &Text::literal(title)); + if let Some(lines) = tooltip { + ui::tooltip(batch, atlas, &lines, cursor); + } } /// A press (or a drag with `drag`) at `(x, y)`. pub fn click( @@ -788,6 +816,8 @@ impl GraphicsUi { } return match wd.target { Target::Button(action) => vec![Action::ButtonClick, action], + // A disabled control does nothing: no sound, no preset change. + Target::Item(item) if !item.available() => Vec::new(), Target::Item(item) => { if item.slider(s).is_some() { self.dragging = Some(item); diff --git a/crates/horizon-client/src/hdr.wgsl b/crates/horizon-client/src/hdr.wgsl index bf6c27cca..ec2ae118a 100644 --- a/crates/horizon-client/src/hdr.wgsl +++ b/crates/horizon-client/src/hdr.wgsl @@ -1,6 +1,6 @@ // Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma -// space, decoded to linear light at the paper white and written to the scRGB -// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the +// space, decoded to linear light at the paper white and written to the swapchain: +// HDR10 (BT.2020, PQ) or scRGB (1.0 = 80 nits). Bright pixels of light sources that still show the // world as it was before the hand and HUD are expanded towards the peak. @group(0) @binding(0) var frame: texture_2d; @group(0) @binding(1) var world: texture_2d; @@ -14,6 +14,8 @@ struct Params { world: f32, // 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1). debug: f32, + // 1 for an HDR10 swapchain, 0 for scRGB. + pq: f32, }; @group(0) @binding(2) var params: Params; @@ -30,14 +32,28 @@ const STRENGTH: f32 = 0.7; // Whether `c` still shows the world pixel `w`: the same, or darkened evenly // across its channels, as the vignette and other screen-wide tints darken it. -// The hand, HUD and text replace pixels instead, so they never pass. +// The hand, HUD and text replace pixels instead, so they never pass. The +// channels are held to one factor, the ratio of their sums, so a channel at 0 +// stays 0; a step of 8-bit rounding is allowed on each. fn shows_world(c: vec3, w: vec3) -> bool { if all(c == w) { return true; } - let k = c / max(w, vec3(1.0 / 255.0)); - let mean = (k.r + k.g + k.b) / 3.0; - return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03)); + let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0); + return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0); +} + +// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first. +const BT709_TO_BT2020 = mat3x3( + vec3(0.6274, 0.0691, 0.0164), + vec3(0.3293, 0.9195, 0.0880), + vec3(0.0433, 0.0114, 0.8956), +); + +// SMPTE ST 2084: nits (up to 10000) to the PQ signal. +fn pq(nits: vec3) -> vec3 { + let y = pow(clamp(nits / 10000.0, vec3(0.0), vec3(1.0)), vec3(0.1593017578125)); + return pow((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3(78.84375)); } @vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { @@ -54,7 +70,10 @@ fn shows_world(c: vec3, w: vec3) -> bool { // The world's alpha is 0 on light-emitting blocks, the sun and the moon // (terrain.wgsl, the celestial pipeline): only those shine. if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) { - let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b))); + // Brightness of the world pixel, before the vignette darkens the frame + // toward its edges: a light source shines the same anywhere on screen. + let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2)); + let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b))); let lift = smoothstep(LIFT_START, LIFT_FULL, y); if lift > 0.0 { let top = max(light.r, max(light.g, light.b)); @@ -65,5 +84,9 @@ fn shows_world(c: vec3, w: vec3) -> bool { } } } - return vec4(light * params.paper, 1.0); + let scrgb = light * params.paper; + if params.pq > 0.5 { + return vec4(pq(BT709_TO_BT2020 * scrgb * 80.0), 1.0); + } + return vec4(scrgb, 1.0); } diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index 25c719bfb..28391c496 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -25,19 +25,7 @@ use std::{fs, io, path::Path}; pub(crate) type Attributes = LiveAttributes i32>>; pub(crate) type State = HurtState; -#[derive(Clone, Copy, Debug)] -pub struct HealthRules { - pub difficulty: Difficulty, - pub natural_regeneration: bool, -} -impl Default for HealthRules { - fn default() -> Self { - Self { - difficulty: Difficulty::Normal, - natural_regeneration: true, - } - } -} +pub use crate::world::HealthRules; pub struct HealthResources { pub damage: Catalog, pub ids: PlayerFloatIds, @@ -111,6 +99,12 @@ pub enum HealthEvent { amount: f32, }, Phase(&'static str), + /// `Player.hurtArmor`: a hit that doesn't bypass armor wears the worn + /// pieces (`LivingEntity.getDamageAfterArmorAbsorb`); the game applies it. + ArmorHurt { + source: DamageTypeId, + amount: f32, + }, /// `ServerLevel.broadcastDamageEvent`: the client's /// `LivingEntity.handleDamageEvent` plays the source's hurt sound. DamageBroadcast { @@ -139,6 +133,7 @@ impl HealthEvent { format!("record-damage {:08x}", amount.to_bits()) } Self::Phase(name) => name.into(), + Self::ArmorHurt { amount, .. } => format!("hurt-armor {:08x}", amount.to_bits()), Self::DamageBroadcast { .. } => "broadcast-damage".into(), Self::Criterion { .. } => "hurt-criterion".into(), } @@ -163,6 +158,12 @@ pub struct PlayerHealth { pub hurt: State, /// Client hurt animation; fully blocked hits only refresh the server's hurt state. pub hurt_animation_time: i32, + /// `Player.hurtDir` on a client: the yaw `ClientboundHurtAnimationPacket` + /// gave, which `GameRenderer.bobHurt` tilts the camera around. + pub hurt_dir: f32, + /// `ServerPlayer.hurtDir` of a hit the player's client hasn't been told + /// of yet (`indicateDamage`). + pub indicated_dir: Option, pub dead: bool, pub sync_velocity: bool, pub game_time: i64, @@ -213,6 +214,8 @@ impl PlayerHealth { last_stamp: 0, }, hurt_animation_time: 0, + hurt_dir: 0.0, + indicated_dir: None, dead: false, sync_velocity: false, game_time: 0, @@ -260,6 +263,18 @@ impl PlayerHealth { self.hurt.hurt_time = 10; self.hurt_animation_time = 10; } + /// `LivingEntity.animateHurt(yaw)` on a player: the tilt restarts around + /// `yaw` (`Player.animateHurt` keeps it as `hurtDir`). + pub fn animate_hurt_dir(&mut self, yaw: f32) { + self.animate_hurt(); + self.hurt_dir = yaw; + } + /// `ServerPlayer.indicateDamage`: the hit's direction relative to where + /// the player looks, kept for the packet to its client. + pub fn indicate_damage(&mut self, xd: f64, zd: f64, yaw: f32) { + use horizon_core::geometry::trig::{RAD_TO_DEG, atan2}; + self.indicated_dir = Some((atan2(zd, xd) * f64::from(RAD_TO_DEG) - f64::from(yaw)) as f32); + } /// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current /// health starts the hurt animation; `damageCooldownTime` (20 on a drop, /// 10 on a gain) is what lets `Hud.extractPlayerHealth` blink the hearts. @@ -295,6 +310,20 @@ impl PlayerMotion { source: DamageTypeId, amount: f32, blocking: Option, + ) -> Result { + self.hurt_caused_by(resources, rules, source, amount, blocking, false) + } + /// `hurt_blocking` from a source whose causing entity is a living + /// non-player (`living_cause`), which the `when_caused_by_living_non_player` + /// damage types scale with the difficulty. + pub fn hurt_caused_by( + &mut self, + resources: &HealthResources, + rules: HealthRules, + source: DamageTypeId, + amount: f32, + blocking: Option, + living_cause: bool, ) -> Result { if self.client_side { return Ok(BlockOutcome::default()); @@ -315,6 +344,7 @@ impl PlayerMotion { invulnerable, rules, source, + living_cause, events: &mut self.events, dead: &mut h.dead, sync_velocity: &mut h.sync_velocity, @@ -348,6 +378,13 @@ impl PlayerMotion { if outcome.hurt { h.hurt_animation_time = h.hurt.hurt_time; } + // `dealDefaultKnockback` ends in `indicateDamage` unless blocked: a + // hit with no direction of its own (`xd` and `zd` zero) is sent as + // the opposite of the player's yaw. A hit that has a direction + // replaces this (`mob_hits_player`). + if outcome.knockback && outcome.blocked <= 0.0 { + h.indicate_damage(0.0, 0.0, self.yaw); + } let after = h.health() + h.absorption(); if self.hurt_log.len() >= HURT_LOG { self.hurt_log.remove(0); @@ -390,6 +427,8 @@ pub(crate) struct Environment<'a> { pub(crate) invulnerable: bool, pub(crate) rules: HealthRules, pub(crate) source: DamageSource<'a, ()>, + /// The causing entity is a living non-player (a mob, or its projectile's owner). + pub(crate) living_cause: bool, pub(crate) events: &'a mut Vec, pub(crate) dead: &'a mut bool, pub(crate) sync_velocity: &'a mut bool, @@ -517,9 +556,14 @@ impl ServerDamageEnvironment for Environment<'_> { fn bypasses(&mut self, kind: Bypass) -> io::Result { Ok(self.source.bypasses(kind)) } - fn hurt_armor(&mut self, _: &mut Attributes, _: f32) -> io::Result<()> { + fn hurt_armor(&mut self, _: &mut Attributes, damage: f32) -> io::Result<()> { + // The worn armor lives in the game, which wears it from this event. + self.event(HealthEvent::ArmorHurt { + source: self.source.type_id(), + amount: damage, + }); Ok(()) - } // empty equipment + } fn has_resistance(&mut self) -> io::Result { Ok(self.status.resistance.is_some()) } @@ -604,8 +648,13 @@ impl ImmunityWorld for Environment<'_> { fn enchantment_immunity(&mut self) -> io::Result { Ok(false) } - fn damage_rule(&mut self, _: DamageRule) -> io::Result { - Ok(true) + fn damage_rule(&mut self, rule: DamageRule) -> io::Result { + Ok(match rule { + DamageRule::Drowning => self.rules.drowning_damage, + DamageRule::Fall => self.rules.fall_damage, + DamageRule::Fire => self.rules.fire_damage, + DamageRule::Freeze => self.rules.freeze_damage, + }) } fn changing_dimension(&mut self) -> io::Result { Ok(false) @@ -773,8 +822,13 @@ impl PlayerHurtWorld for Environment<'_> { Ok(()) } fn scales_with_difficulty(&mut self, _: &mut State) -> io::Result { - Ok(self.source.definition().scaling == Scaling::Always) - } // no causing entity + // DamageSource.scalesWithDifficulty. + Ok(match self.source.definition().scaling { + Scaling::Never => false, + Scaling::Always => true, + Scaling::LivingNonPlayer => self.living_cause, + }) + } fn difficulty(&mut self, _: &mut State) -> io::Result { Ok(self.rules.difficulty) } @@ -826,6 +880,31 @@ mod tests { assert!(!third.hurt && !third.knockback); } + /// #2141: an arrow that takes the last of the player's health must kill + /// them: `LivingEntity.hurt` leaves the health at zero and `Player.die` + /// follows. + #[test] + fn an_arrow_kills_a_player_at_one_health() { + 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(); + player.set_health(1.0, false).unwrap(); + let (health, rules) = (&resources.health, super::HealthRules::default()); + let id = health.damage.id("minecraft:arrow").unwrap(); + let hurt = player.hurt(health, rules, id, 5.0).unwrap(); + assert!(hurt, "the arrow must hurt"); + assert_eq!(player.health.health(), 0.0, "the health must be spent"); + assert!(player.health.dead_or_dying(), "the player must be dead"); + } + #[test] fn hurt_sounds_follow_damage_effects() { // DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING. diff --git a/crates/horizon-client/src/hud.rs b/crates/horizon-client/src/hud.rs index 2b6acda41..2b9bae9b5 100644 --- a/crates/horizon-client/src/hud.rs +++ b/crates/horizon-client/src/hud.rs @@ -67,6 +67,11 @@ pub struct HudAtlas { pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>, /// The default font (bitmap sheets in this atlas, unifont rasterized on demand). pub font: crate::font::Font, + /// `misc/enchanted_glint_item` as its own image: vanilla's `GlintSampler` + /// repeats it per pixel, which one sprite inside this atlas cannot. + pub glint: (Vec, u32, u32), + /// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`. + pub glint_armor: (Vec, u32, u32), white: Rect, } /// Atlas rows reserved for glyphs rasterized at run time. @@ -526,6 +531,8 @@ impl HudAtlas { } } let white = sprites["horizon/white"]; + let glint = Self::glint_texture(root, defaults, "enchanted_glint_item"); + let glint_armor = Self::glint_texture(root, defaults, "enchanted_glint_armor"); let font = crate::font::Font::load( root, &sprites, @@ -542,10 +549,22 @@ impl HudAtlas { translucent, flags, fire_animations, + glint, + glint_armor, font, white, }) } + /// `misc/.png` on its own, or a transparent texel if the resource + /// pack lacks it. + fn glint_texture(root: &Path, defaults: &Path, name: &str) -> (Vec, u32, u32) { + let relative = format!("assets/minecraft/textures/misc/{name}.png"); + let mut path = root.join(&relative); + if !path.exists() && defaults != root { + path = defaults.join(&relative); + } + decode(&path).map_or((vec![0; 4], 1, 1), |(w, h, pixels)| (pixels, w, h)) + } } #[repr(C)] #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] @@ -554,6 +573,9 @@ pub struct HudVertex { pub uv: [f32; 2], pub color: u32, pub depth: f32, + /// The `GLINT_TEXTURING` UV into the repeating glint texture, unused + /// unless `uv.x >= 2`. + pub glint_uv: [f32; 2], } /// The GUI Scale option (0 = auto). pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); @@ -569,6 +591,8 @@ pub struct HudBatch { pub blends: Vec<(std::ops::Range, Blend)>, /// The textures and time of the `Blend::EndPortal` runs. pub end_portal: EndPortalFill, + /// `options.glintStrength` for the glint pass (`GlintAlpha`). + pub glint_alpha: f32, /// Screen's first vertex: the preceding HUD is part of its blurred background. pub blur_at: u32, } @@ -620,6 +644,7 @@ impl HudBatch { patches: Vec::new(), blends: Vec::new(), end_portal: EndPortalFill::default(), + glint_alpha: 0.75, blur_at: 0, } } @@ -695,6 +720,26 @@ impl HudBatch { uv: uv[i], color, depth: 0.0, + glint_uv: [0.0, 0.0], + }); + } + } + /// A quad whose texture is the `GLINT_TEXTURING` sample at `glint_uv`; + /// `uv` is the item's atlas UV, which the shader masks by. + pub fn quad_glint( + &mut self, + p: [[f32; 2]; 4], + uv: [[f32; 2]; 4], + glint_uv: [[f32; 2]; 4], + color: u32, + ) { + for i in [0, 1, 2, 0, 2, 3] { + self.vertices.push(HudVertex { + pos: p[i], + uv: uv[i], + color, + depth: 0.0, + glint_uv: glint_uv[i], }); } } @@ -705,6 +750,7 @@ impl HudBatch { uv: uv[i], color, depth: p[i][2], + glint_uv: [0.0, 0.0], }); } } @@ -875,6 +921,7 @@ impl HudBatch { uv, color: c[i], depth: 0.0, + glint_uv: [0.0, 0.0], }); } } @@ -912,6 +959,8 @@ impl HudBatch { // StringDecomposer.iterateFormatted inside Font.drawInternal: // `§` codes inside a span restyle what follows and are not drawn. let text = text.resolved(); + // Language.getVisualOrder: shaped and reordered for right-to-left text. + let text = crate::bidi::visual(&text); let font = &atlas.font; let alpha = (color >> 24) as u8; let mut glyphs = Vec::new(); @@ -1053,6 +1102,8 @@ impl HudRenderer { device: &wgpu::Device, format: wgpu::TextureFormat, view: &wgpu::TextureView, + glint_view: &wgpu::TextureView, + glint_sampler: &wgpu::Sampler, ) -> Result { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Horizon HUD"), @@ -1097,6 +1148,22 @@ impl HudRenderer { ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, + wgpu::BindGroupLayoutEntry { + binding: 3, + 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: 4, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, ], }); let group = device.create_bind_group(&wgpu::BindGroupDescriptor { @@ -1115,6 +1182,14 @@ impl HudRenderer { binding: 2, resource: wgpu::BindingResource::Sampler(&sampler), }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(glint_view), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::Sampler(glint_sampler), + }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { @@ -1122,7 +1197,8 @@ impl HudRenderer { bind_group_layouts: &[Some(&layout)], ..Default::default() }); - let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32]; + let attributes = + wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2]; let create = |label, blend, fragment_entry, depth: bool| { device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), @@ -1337,7 +1413,12 @@ impl HudRenderer { &self.uniform, 0, bytemuck::bytes_of(&HudUniform::new( - [size[0] as f32, size[1] as f32, batch.scale, 0.0], + [ + size[0] as f32, + size[1] as f32, + batch.scale, + batch.glint_alpha, + ], batch.end_portal, )), ); @@ -1787,6 +1868,7 @@ mod tests { patches: Vec::new(), blends: Vec::new(), end_portal: EndPortalFill::default(), + glint_alpha: 0.75, blur_at: 0, }; for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] { diff --git a/crates/horizon-client/src/hud.wgsl b/crates/horizon-client/src/hud.wgsl index ebb6dc2cb..435f7f062 100644 --- a/crates/horizon-client/src/hud.wgsl +++ b/crates/horizon-client/src/hud.wgsl @@ -7,28 +7,35 @@ struct Hud { @group(0) @binding(0) var hud: Hud; @group(0) @binding(1) var atlas: texture_2d; @group(0) @binding(2) var atlas_sampler: sampler; +// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler). +@group(0) @binding(3) var glint_tex: texture_2d; +@group(0) @binding(4) var glint_sampler: sampler; struct Out { @builtin(position) clip: vec4, @location(0) uv: vec2, @location(1) color: vec4, // projection_from_position(gl_Position): the End portal's texProj0. @location(2) proj: vec4, + @location(3) glint_uv: vec2, } -@vertex fn vs_main(@location(0) pos: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) depth: f32) -> Out { +@vertex fn vs_main(@location(0) pos: vec2, @location(1) uv: vec2, @location(2) color: vec4, @location(3) depth: f32, @location(4) glint_uv: vec2) -> Out { let pixels = pos * hud.size.z; var out: Out; out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0); out.uv = uv; out.color = color; out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw); + out.glint_uv = glint_uv; return out; } @fragment fn fs_main(in: Out) -> @location(0) vec4 { - // u >= 2: flat vertex colour masked by the texture's alpha (enchantment glint). + // u >= 2: the GLINT_TEXTURING sample over the item, masked by the item's + // texture alpha (item.fsh's ALPHA_CUTOUT and GLINT path). if in.uv.x >= 2.0 { - let m = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a * in.color.a; - if m < 0.004 { discard; } - return vec4(in.color.rgb, m); + let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a; + if mask < 0.1 { discard; } + let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv); + return vec4(glint.rgb * glint.rgb, mask); } let c = textureSample(atlas, atlas_sampler, in.uv) * in.color; if c.a < 0.004 { discard; } diff --git a/crates/horizon-client/src/hud_overlays.rs b/crates/horizon-client/src/hud_overlays.rs index ebd860e08..8af2c2a0d 100644 --- a/crates/horizon-client/src/hud_overlays.rs +++ b/crates/horizon-client/src/hud_overlays.rs @@ -107,10 +107,11 @@ pub struct EffectIcon { impl From<&crate::effects::EffectInstance> for EffectIcon { fn from(effect: &crate::effects::EffectInstance) -> Self { - let def = crate::brewing::effect(effect.id); + let def = horizon_client_core::potions::effect(effect.id); Self { id: effect.id.to_owned(), - beneficial: def.is_some_and(|d| d.category == crate::brewing::Category::Beneficial), + beneficial: def + .is_some_and(|d| d.category == horizon_client_core::potions::Category::Beneficial), color: def.map_or(0, |d| d.color as i32), ambient: effect.ambient, duration: effect.duration, @@ -191,6 +192,53 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec { out } +/// The saving indicator: `Hud.tickAutosaveIndicator` fades a value towards 1 +/// while the server is saving and `extractSavingIndicator` draws the text. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SavingIndicator { + last: f32, + value: f32, +} +impl SavingIndicator { + /// `Hud.tickAutosaveIndicator`: `Mth.lerp(0.2F, value, saving ? 1 : 0)`. + pub fn tick(&mut self, saving: bool) { + self.last = self.value; + self.value += (if saving { 1.0 } else { 0.0 } - self.value) * 0.2; + } + /// `Hud.extractSavingIndicator` without the option check: the text in the + /// bottom right corner. + pub fn draw( + &self, + w: i32, + h: i32, + partial: f32, + font: &Font, + label: Text, + background: u32, + ) -> Vec { + let mut out = Vec::new(); + if self.value <= 0.0 && self.last <= 0.0 { + return out; + } + let fade = (self.last + (self.value - self.last) * partial).clamp(0.0, 1.0); + let alpha = (255.0 * fade).floor() as u32; + if alpha > 0 { + let label_width = width(font, &label); + let color = crate::hud_layout::argb(alpha, 255, 255, 255); + text_with_backdrop( + &mut out, + label, + w - label_width - 5, + h - 9 - 5, + label_width, + color, + background, + ); + } + out + } +} + /// Titles and the action bar: `Hud.title`, `subtitle`, `titleTime`, the /// fade times and `overlayMessageString` / `overlayMessageTime`. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/horizon-client/src/hunger.rs b/crates/horizon-client/src/hunger.rs index 15eac144f..34f414d50 100644 --- a/crates/horizon-client/src/hunger.rs +++ b/crates/horizon-client/src/hunger.rs @@ -151,6 +151,7 @@ impl FoodWorld for TickWorld<'_> { .damage .source::<()>(id, None, None, None) .expect("validated starvation source"), + living_cause: false, events: self.events, dead: self.dead, sync_velocity: self.sync_velocity, diff --git a/crates/horizon-client/src/item_data.rs b/crates/horizon-client/src/item_data.rs index ac624e85b..1bd80437f 100644 --- a/crates/horizon-client/src/item_data.rs +++ b/crates/horizon-client/src/item_data.rs @@ -52,6 +52,8 @@ pub struct StackData { pub pot_decorations: Option; 4]>>, /// `minecraft:bundle_contents`. pub bundle_contents: Vec, + /// `BundleContents.selectedItem`: runtime only, never saved or sent. + pub bundle_selected: BundleSelected, /// `minecraft:suspicious_stew_effects`: (effect id, duration). pub stew_effects: Vec<(Arc, i32)>, /// `minecraft:map_post_processing`: 0 lock, 1 scale. @@ -60,6 +62,20 @@ pub struct StackData { /// max stack size...), kept as their saved values in key order. pub other: Vec<(String, Tag)>, } +/// A bundle's selected item index (-1: none). Like vanilla's +/// `BundleContents.equals` it is left out when stacks are compared. +#[derive(Clone, Copy, Debug)] +pub struct BundleSelected(pub i32); +impl Default for BundleSelected { + fn default() -> Self { + Self(-1) + } +} +impl PartialEq for BundleSelected { + fn eq(&self, _: &Self) -> bool { + true + } +} /// The components `components_tag` writes from modelled fields. const MODELLED: &[&str] = &[ "minecraft:banner_patterns", @@ -127,6 +143,9 @@ pub struct WrittenBook { pub author: String, pub generation: u8, pub pages: Vec, + /// Each page's component when it is not a plain string, so that its + /// click events survive; empty or one per page. + pub page_tags: Vec, } impl StackData { pub fn is_empty(&self) -> bool { @@ -198,6 +217,19 @@ impl ItemStack { pub fn has_any_enchantments(&self) -> bool { !self.enchantments().is_empty() || !self.stored_enchantments().is_empty() } + /// `ItemStack.hasFoil`: an explicit `minecraft:enchantment_glint_override` + /// component wins, then the item's default override (`item_glint`), then a + /// non-empty `minecraft:enchantments` component (`Item.isFoil` is + /// `ItemStack.isEnchanted`; stored book enchantments do not count). + pub fn has_foil(&self, item_glint: bool) -> bool { + if let Some(Tag::Byte(flag)) = self + .data() + .and_then(|d| d.other("minecraft:enchantment_glint_override")) + { + return *flag != 0; + } + item_glint || !self.enchantments().is_empty() + } pub fn repair_cost(&self) -> u32 { self.data().map_or(0, |d| d.repair_cost) } @@ -293,6 +325,21 @@ fn read_levels(t: Option<&Tag>) -> Levels { } /// A text component as stored by vanilla: a plain string or `{"text": ...}`. /// A `Filterable`: the value itself or `{raw: value}`. +/// `ComponentSerialization.CODEC`'s short form: a plain text component with +/// no style and no siblings is written as its string. +fn plain_component(t: &Tag) -> Tag { + if let Tag::Compound(c) = t + && let Some(text @ Tag::String(_)) = c.get(&"text".into()) + && c.iter().all(|(k, v)| { + k.to_string_lossy() == "text" + || (k.to_string_lossy() == "type" + && matches!(v, Tag::String(s) if s.to_string_lossy() == "text")) + }) + { + return text.clone(); + } + t.clone() +} fn filterable_text(t: &Tag) -> Option { match t.get("raw") { Some(raw) => read_text(raw), @@ -305,7 +352,9 @@ fn read_text(t: &Tag) -> Option { // A component's root `text` may be empty with the visible words in // `extra` (every legacy display name ViaVersion produces looks like // this), so flatten the whole component instead of reading one key. - Tag::Compound(_) => Some(crate::entity_nbt::name_text(t).plain()), + // ComponentSerialization.CODEC also takes a list (its first element + // styling the rest), as servers behind ViaVersion send book pages. + Tag::Compound(_) | Tag::List(_) => Some(crate::entity_nbt::name_text(t).plain()), _ => None, } } @@ -522,11 +571,14 @@ pub fn components_tag(damage: u32, data: Option<&StackData>) -> Option { )); } if let Some(w) = &d.written { - let pages = w - .pages - .iter() - .map(|p| raw(Tag::String(p.as_str().into()))) - .collect(); + let pages = if w.page_tags.len() == w.pages.len() { + w.page_tags.iter().cloned().map(raw).collect() + } else { + w.pages + .iter() + .map(|p| raw(Tag::String(p.as_str().into()))) + .collect() + }; entries.push(( "minecraft:written_book_content", Tag::compound({ @@ -707,6 +759,13 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option pages.iter().filter_map(filterable_text).collect(), _ => Vec::new(), }, + page_tags: match b.get("pages") { + Some(Tag::List(pages)) => pages + .iter() + .map(|p| plain_component(p.get("raw").unwrap_or(p))) + .collect(), + _ => Vec::new(), + }, }), fireworks: c .get("minecraft:fireworks") @@ -791,6 +850,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option i64 { + (f64::from(millis) * f64::from(speed) * 8.0) as i64 + } + /// The scroll clock modulo the common period of both offsets (110000 and + /// 30000 divide 330000), for `globals.time.w`: the entity shader derives + /// `ARMOR_ENTITY_GLINT_TEXTURING` from it. + pub fn scroll_ticks(millis: f32, speed: f32) -> f32 { + (Self::ticks(millis, speed) % 330_000) as f32 + } + /// `TextureTransform.setupGlintTexturing(scale)` applied to the model UV: + /// translation(-o0, o1) * rotateZ(PI/18) * scale. The result repeats: + /// the shader samples the dedicated glint texture with vanilla's + /// repeating `GlintSampler`, so the UVs stay unwrapped. + pub fn uv(&self, model: [f32; 2]) -> [f32; 2] { + let ticks = Self::ticks(self.millis, self.speed); + let offset0 = (ticks % 110_000) as f32 / 110_000.0; + let offset1 = (ticks % 30_000) as f32 / 30_000.0; + let angle = std::f32::consts::PI / 18.0; + let (sin, cos) = angle.sin_cos(); + [ + model[0] * self.scale * cos - model[1] * self.scale * sin - offset0, + model[0] * self.scale * sin + model[1] * self.scale * cos + offset1, + ] + } } /// Which glint render type a quad uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -159,7 +206,7 @@ impl ItemRenderState { let m = pose * layer.matrix; match &layer.geometry { LayerGeometry::Quads(baked) => { - submit_quads(baked, layer, self.display, m, sprites, sink); + submit_quads(renderer, baked, layer, self.display, m, sprites, sink); } LayerGeometry::Special { model, argument } => special::submit( renderer, @@ -203,6 +250,7 @@ pub fn argb(color: i32) -> u32 { /// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer. fn submit_quads( + renderer: &ItemRenderer, baked: &BakedItemModel, layer: &LayerRender, display: DisplayContext, @@ -216,6 +264,8 @@ fn submit_quads( FoilType::Special => Foil::Special, }; let _ = display; + // Quads of one layer mostly share a sprite: look its translucency up once. + let mut last: Option<(&str, bool)> = None; for q in &baked.quads { let Some(r) = sprites .get(sprite_name(&q.material.sprite)) @@ -239,13 +289,25 @@ fn submit_quads( let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p))); let uv = q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]); + let translucent = match last { + Some((sprite, translucent)) if sprite == q.material.sprite => translucent, + _ => { + let translucent = renderer + .sprites + .translucent(sprite_name(&q.material.sprite)); + last = Some((&q.material.sprite, translucent)); + translucent + } + }; sink.quad(ItemQuad { pos, uv, + foil_uv: q.uv, normal: transform_normal(m, direction_vector(q.facing)), color: argb(color), foil, light_emission: q.light_emission, + translucent: translucent || q.material.force_translucent, }); } } @@ -260,6 +322,9 @@ struct FileSprites { root: PathBuf, permutations: Vec, cache: Mutex>>, + /// Sprites with a pixel that is neither fully transparent nor opaque + /// (`Transparency.hasTranslucent`), filled in as sprites load. + translucent: Mutex>, } impl SpriteLookup for FileSprites { fn mask(&self, sprite: &str) -> Option { @@ -283,6 +348,13 @@ fn argb_pixels(rgba: &[u8]) -> Vec { .collect() } impl FileSprites { + fn translucent(&self, sprite: &str) -> bool { + let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied(); + known(self).unwrap_or_else(|| { + self.mask(sprite); + known(self).unwrap_or(false) + }) + } fn png(&self, sprite: &str, directory: &str) -> PathBuf { crate::atlas_sources::texture_path(&self.root, sprite, directory) } @@ -303,6 +375,10 @@ impl FileSprites { (w, h, pixels) } }; + let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254)); + if let Ok(mut cache) = self.translucent.lock() { + cache.insert(sprite.to_owned(), translucent); + } let alpha: Vec = argb.iter().map(|p| (p >> 24) as u8).collect(); let animation = fs::read_to_string(png.with_extension("png.mcmeta")) .ok() @@ -452,6 +528,7 @@ impl ItemRenderer { root: root.to_path_buf(), permutations, cache: Mutex::default(), + translucent: Mutex::default(), }, baked: Mutex::default(), extents: Mutex::default(), @@ -653,6 +730,7 @@ pub fn tag_json(tag: &Tag) -> Value { /// The resolver's view of a client stack: its item, count and component patch. pub fn stack_view(stack: &crate::items::ItemStack) -> StackView { let mut view = StackView::new(&stack.item, stack.count as i32); + view.bundle_selected = stack.data().map_or(-1, |d| d.bundle_selected.0); if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) { for (k, v) in &c { let key = k.to_string_lossy(); @@ -679,6 +757,8 @@ pub struct WorldSink<'a> { pub light: u32, /// The level's diffuse lights; `None` leaves the colour unshaded. pub lights: Option<(Vec3, Vec3)>, + /// The `GLINT_TEXTURING` sample for foil quads (`None` leaves them unglinted). + pub glint: Option, } impl ItemSink for WorldSink<'_> { fn quad(&mut self, q: ItemQuad) { @@ -693,8 +773,19 @@ impl ItemSink for WorldSink<'_> { } else { self.light }; - let overlay = if q.foil == Foil::None { 0 } else { 7 }; - for (p, uv) in q.pos.iter().zip(q.uv) { + let glint = if q.foil == Foil::None { + None + } else { + self.glint + }; + let overlay = match (glint.is_some(), q.translucent) { + (false, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY, + (false, false) => 0, + (true, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY, + (true, false) => 7, + }; + for (i, (p, uv)) in q.pos.iter().zip(q.uv).enumerate() { + let glint_uv = glint.map_or([0.0, 0.0], |glint| glint.uv(q.foil_uv[i])); self.out.push(Vertex { normal: [0; 4], pos: p.to_array(), @@ -702,6 +793,7 @@ impl ItemSink for WorldSink<'_> { layer: overlay, color, light, + glint_uv, }); } } @@ -758,6 +850,33 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64 mod tests { use super::*; + /// A bad item model surfaces only once items.json has loaded, so a broken + /// items.json still reports itself first. + #[test] + fn item_model_errors_wait_for_the_item_definitions() -> crate::Result<()> { + let root = + std::env::temp_dir().join(format!("horizon-item-model-errors-{}", std::process::id())); + let model = root.join("assets/horizon/models/INVALID.json"); + fs::create_dir_all(model.parent().unwrap())?; + fs::write(model, "{}")?; + let library = crate::model_library::ParsedModels::load(&root).into_item_library(); + assert!(library.is_err()); + fs::write(root.join("items.json"), "{")?; + let load = || { + ItemRenderer::load_with_library(&root, library.clone()) + .err() + .unwrap() + .to_string() + }; + let broken_items = load(); + fs::write(root.join("items.json"), "{}")?; + let bad_model = load(); + let _ = fs::remove_dir_all(&root); + assert!(!broken_items.starts_with("item models:")); + assert!(bad_model.starts_with("item models:")); + Ok(()) + } + #[test] fn rendered_amounts_follow_the_count_bands() { let bands: Vec = [0, 1, 2, 16, 17, 32, 33, 48, 49, 64] diff --git a/crates/horizon-client/src/item_render/gui.rs b/crates/horizon-client/src/item_render/gui.rs index ff25fff1d..a99969679 100644 --- a/crates/horizon-client/src/item_render/gui.rs +++ b/crates/horizon-client/src/item_render/gui.rs @@ -5,7 +5,7 @@ //! drawn far to near, and clipped to the item's 16x16 slot unless the model //! is oversized in the GUI (`GuiItemAtlas` draws each item into a scissored //! 16x16 slot; oversized items get a picture-in-picture of their bounds). -use super::{Foil, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost}; +use super::{Foil, Glint, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost}; use crate::hud::{HudBatch, Rect}; use glam::{EulerRot, Mat4, Vec2, Vec3}; use std::{collections::HashMap, f32::consts::PI, sync::Arc}; @@ -43,6 +43,7 @@ pub fn items_3d_lights() -> (Vec3, Vec3) { struct Point { p: Vec2, uv: [f32; 2], + glint_uv: [f32; 2], } fn lerp(a: Point, b: Point, t: f32) -> Point { Point { @@ -51,6 +52,10 @@ fn lerp(a: Point, b: Point, t: f32) -> Point { a.uv[0] + (b.uv[0] - a.uv[0]) * t, a.uv[1] + (b.uv[1] - a.uv[1]) * t, ], + glint_uv: [ + a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t, + a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t, + ], } } /// Sutherland-Hodgman against one half plane `dot(p, n) <= limit`. @@ -97,37 +102,25 @@ pub fn lit_color(q: &ItemQuad, lights: (Vec3, Vec3)) -> u32 { let c = |v: u8| (f32::from(v) * light) as u8; crate::hud::rgba(c(r), c(g), c(b), a) } -/// The glint's moving sheen at a GUI position (a stand-in for the -/// `GLINT_TEXTURING`-scrolled `enchanted_glint_item` texture added on top). -fn glint_color(p: Vec2, time: f32) -> u32 { - let d = ((p.x + p.y) / 32.0 - time * 0.35).rem_euclid(1.0); - let band = (1.0 - (d - 0.5).abs() * 6.0).max(0.0); - crate::hud::rgba( - (170.0 + 70.0 * band) as u8, - (90.0 + 110.0 * band) as u8, - 255, - (45.0 + 110.0 * band) as u8, - ) -} - -fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: bool) { - let shift = if glint { 2.0 } else { 0.0 }; +fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint: Option<&Glint>) { for i in 1..fan.len().saturating_sub(1) { let (a, b, c) = (fan[0], fan[i], fan[i + 1]); let corners = [a, b, c, c]; - // `HudBatch.quad` draws (0, 1, 2) and (0, 2, 3): the second is empty. - batch.quad( - corners.map(|v| v.p.to_array()), - corners.map(|v| [v.uv[0] + shift, v.uv[1]]), - color(a.p), - ); - if glint { - // Per-vertex colours are flat per quad in the HUD batch: use the - // fan centre for the sheen. - let n = batch.vertices.len(); - for v in &mut batch.vertices[n - 6..] { - v.color = color(Vec2::from(v.pos)); - } + if let Some(glint) = glint { + let start = batch.vertices.len(); + batch.quad_glint( + corners.map(|v| v.p.to_array()), + corners.map(|v| [v.uv[0] + 2.0, v.uv[1]]), + corners.map(|v| glint.uv(v.glint_uv)), + u32::MAX, + ); + batch.blend_since(start, crate::hud::Blend::Additive); + } else { + batch.quad( + corners.map(|v| v.p.to_array()), + corners.map(|v| v.uv), + color(a.p), + ); } } } @@ -144,6 +137,8 @@ pub fn draw( x: f32, y: f32, time: f32, + glint_speed: f32, + glint_alpha: f32, ) { if state.is_empty() { return; @@ -162,7 +157,18 @@ pub fn draw( return; } let start = batch.vertices.len(); - draw_now(state, renderer, sprites, host, batch, x, y, time); + draw_now( + state, + renderer, + sprites, + host, + batch, + x, + y, + time, + glint_speed, + glint_alpha, + ); if let Some(key) = key && let Ok(mut drawn) = renderer.gui_drawn.lock() { @@ -236,6 +242,8 @@ fn draw_now( x: f32, y: f32, time: f32, + glint_speed: f32, + glint_alpha: f32, ) { let pose = Mat4::from_translation(Vec3::new(x + 8.0, y + 8.0, 0.0)) * Mat4::from_scale(Vec3::new(16.0, -16.0, 16.0)); @@ -256,7 +264,16 @@ fn draw_now( } else { [x, y, x + 16.0, y + 16.0] }; - draw_quads(&quads, lights, batch, rect, time); + let glint = sprites + .contains_key("misc/enchanted_glint_item") + .then_some(Glint { + speed: glint_speed, + alpha: glint_alpha, + millis: time * 1000.0, + scale: 8.0, + }); + batch.glint_alpha = glint_alpha; + draw_quads(&quads, lights, batch, rect, glint.as_ref()); } /// Lights, culls, sorts far to near and clips `quads` (GUI positions) to @@ -266,7 +283,7 @@ fn draw_quads( lights: (Vec3, Vec3), batch: &mut HudBatch, rect: [f32; 4], - time: f32, + glint: Option<&Glint>, ) { for face in depth::sort(quads) { let q = &quads[face.quad]; @@ -276,6 +293,7 @@ fn draw_quads( .map(|v| Point { p: v.pos.truncate(), uv: v.uv, + glint_uv: v.glint_uv, }) .collect(); let color = lit_color(q, lights); @@ -285,9 +303,9 @@ fn draw_quads( if fan.len() < 3 { continue; } - emit(batch, &fan, |_| color, false); + emit(batch, &fan, |_| color, None); if q.foil != Foil::None { - emit(batch, &fan, |p| glint_color(p, time), true); + emit(batch, &fan, |_| color, glint); } } } @@ -342,7 +360,7 @@ pub fn draw_banner_result( items_flat_lights(), batch, [x0, y0, x0 + 20.0, y0 + 40.0], - 0.0, + None, ); } @@ -354,10 +372,12 @@ mod tests { ItemQuad { pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))), uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], + foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]], normal: Vec3::Z, color, foil: Foil::None, light_emission: 0, + translucent: false, } } @@ -394,7 +414,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); assert_eq!(color_at(&batch, Vec2::new(4.0, 0.5)), Some(u32::MAX)); } @@ -410,7 +430,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); assert_eq!( color_at(&batch, Vec2::new(4.0, 2.0)), @@ -442,7 +462,7 @@ mod tests { (Vec3::Z, Vec3::Z), &mut batch, [0.0, 0.0, 16.0, 16.0], - 0.0, + None, ); for y in [0.5, 6.0] { assert_eq!( @@ -468,6 +488,7 @@ mod tests { let p = |x: f32, y: f32| Point { p: Vec2::new(x, y), uv: [x, y], + glint_uv: [0.0, 0.0], }; let fan = clip_triangle( [p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)], diff --git a/crates/horizon-client/src/item_render/gui/depth.rs b/crates/horizon-client/src/item_render/gui/depth.rs index 9fecfdee7..aaaa0c55d 100644 --- a/crates/horizon-client/src/item_render/gui/depth.rs +++ b/crates/horizon-client/src/item_render/gui/depth.rs @@ -10,6 +10,7 @@ use glam::{Vec3, Vec4}; pub(super) struct Corner { pub pos: Vec3, pub uv: [f32; 2], + pub glint_uv: [f32; 2], } pub(super) struct Face { @@ -38,6 +39,9 @@ fn split(face: Face, distances: &[f32]) -> (Face, Face) { let corner = Corner { pos: a.pos.lerp(b.pos, t), uv: std::array::from_fn(|k| a.uv[k] + (b.uv[k] - a.uv[k]) * t), + glint_uv: std::array::from_fn(|k| { + a.glint_uv[k] + (b.glint_uv[k] - a.glint_uv[k]) * t + }), }; back.push(corner); front.push(corner); @@ -73,6 +77,7 @@ pub(super) fn sort(quads: &[ItemQuad]) -> Vec { corners: std::array::from_fn::<_, 4, _>(|k| Corner { pos: q.pos[k], uv: q.uv[k], + glint_uv: q.foil_uv[k], }) .to_vec(), }) diff --git a/crates/horizon-client/src/item_render/special.rs b/crates/horizon-client/src/item_render/special.rs index d42378db1..6a1d049af 100644 --- a/crates/horizon-client/src/item_render/special.rs +++ b/crates/horizon-client/src/item_render/special.rs @@ -115,10 +115,12 @@ fn draw( sprite.v0 + (sprite.v1 - sprite.v0) * v[4], ] }), + foil_uv: poly.map(|v| [v[3], v[4]]), normal, color, foil, light_emission: 0, + translucent: false, }); } for (child_name, child) in &part.children { @@ -498,7 +500,7 @@ pub(super) fn submit( let skin = (!argument.is_null()) .then(|| host.player_skin(argument)) .flatten() - .unwrap_or_else(|| "entity/player/wide/steve".to_owned()); + .unwrap_or_else(|| "entity/player/slim/steve".to_owned()); part("player", &none, &skin, white, Foil::None); } SpecialKind::Trident => { diff --git a/crates/horizon-client/src/item_sync.rs b/crates/horizon-client/src/item_sync.rs index fc35e15e7..01d90246f 100644 --- a/crates/horizon-client/src/item_sync.rs +++ b/crates/horizon-client/src/item_sync.rs @@ -229,6 +229,17 @@ fn profile_tag(p: &Profile) -> Tag { if let Some(name) = &p.name { entries.push(("name", Tag::String(name.as_str().into()))); } + if let Some(id) = p.id { + entries.push(( + "id", + Tag::IntArray(vec![ + (id >> 96) as i32, + (id >> 64) as i32, + (id >> 32) as i32, + id as i32, + ]), + )); + } entries.push(("properties", Tag::List(properties))); Tag::compound(entries) } diff --git a/crates/horizon-client/src/item_tooltip.rs b/crates/horizon-client/src/item_tooltip.rs index 88f01439e..63a19b114 100644 --- a/crates/horizon-client/src/item_tooltip.rs +++ b/crates/horizon-client/src/item_tooltip.rs @@ -108,6 +108,32 @@ fn modifier_line( ) } +/// `Attribute.getStyle`: the colour of a line for an attribute whose value +/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning +/// time and fall damage are negative, every other attribute is positive. +fn attribute_color(attribute: &str, rises: bool) -> u32 { + let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255)); + match attribute { + "burning_time" | "fall_damage_multiplier" => { + if rises { + red + } else { + blue + } + } + "gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => { + rgba(170, 170, 170, 255) + } + _ => { + if rises { + blue + } else { + red + } + } + } +} + /// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player /// attack damage/speed bases. Recipe search has no player context. pub fn attribute_lines( @@ -150,10 +176,8 @@ pub fn attribute_lines( ) { let (line, color) = if base.is_some() { (format!(" {line}"), rgba(0, 170, 0, 255)) - } else if *amount > 0.0 { - (line, rgba(85, 85, 255, 255)) } else { - (line, rgba(255, 85, 85, 255)) + (line, attribute_color(path, *amount > 0.0)) }; lines.push((line, color)); } @@ -231,6 +255,235 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str { } } +fn is_beehive(path: &str) -> bool { + matches!(path, "beehive" | "bee_nest") +} + +/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat +/// horn without the component has the item's default, the ponder horn. +pub fn instrument_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:instrument") { + return None; + } + let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) { + Some(Tag::String(id)) => id.to_string_lossy(), + // An inline instrument carries its own description. + Some(inline) => { + return inline + .get("description") + .map(|d| crate::entity_nbt::name_text(d).plain()); + } + None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(), + None => return None, + }; + let key = format!("instrument.{}", id.replacen(':', ".", 1)); + Some(translate(&key).unwrap_or(key)) +} + +/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without +/// the component has the item's default, none). +pub fn bees_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:bees") { + return None; + } + let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) { + Some(Tag::List(bees)) => bees.len(), + Some(_) => return None, + None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0, + None => return None, + }; + let text = + translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into()); + Some( + text.replacen("%s", &bees.to_string(), 1) + .replacen("%s", "3", 1), + ) +} + +/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive +/// item without the component has the item's default, 0). +pub fn honey_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:block_state") { + return None; + } + let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) { + Some(Tag::Compound(state)) => { + match state.get(&horizon_world::nbt::JavaString::from("honey_level")) { + Some(Tag::String(level)) => level.to_string_lossy().parse::().ok()?, + _ => return None, + } + } + Some(_) => return None, + None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0, + None => return None, + }; + let text = + translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into()); + Some( + text.replacen("%s", &level.to_string(), 1) + .replacen("%s", "5", 1), + ) +} + +/// `WrittenBookContent.addToTooltip`: "by " and the generation. +pub fn written_book_lines( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Vec { + let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned()); + let Some(book) = stack + .data() + .and_then(|d| d.written.as_ref()) + .filter(|_| display.shows("minecraft:written_book_content")) + else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if !book.author.trim().is_empty() { + lines.push( + crate::text::format_translation( + &text("book.byAuthor"), + &[crate::text::Text::literal(&book.author)], + ) + .plain(), + ); + } + lines.push(text(&format!("book.generation.{}", book.generation))); + lines +} + +/// `JukeboxPlayable.addToTooltip`: the song's description. +pub fn jukebox_line( + stack: &ItemStack, + info: Option<&ItemInfo>, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + if !display.shows("minecraft:jukebox_playable") { + return None; + } + let song = match stack + .data() + .and_then(|d| d.other("minecraft:jukebox_playable")) + { + Some(Tag::String(song)) => song.to_string_lossy(), + // An inline song carries its own description. + Some(inline) => { + return inline + .get("description") + .map(|d| crate::entity_nbt::name_text(d).plain()); + } + None => info?.jukebox_song.clone()?, + }; + let key = format!("jukebox_song.{}", song.replacen(':', ".", 1)); + Some(translate(&key).unwrap_or(key)) +} + +/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced +/// tooltips the colour's hex code. +pub fn dyed_line( + stack: &ItemStack, + display: &TooltipDisplay, + advanced: bool, + translate: &dyn Fn(&str) -> Option, +) -> Option<(String, bool)> { + let rgb = stack.data()?.dyed_color?; + if !display.shows("minecraft:dyed_color") { + return None; + } + let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned()); + Some(if advanced { + let hex = format!("#{:06X}", rgb & 0xFF_FFFF); + (text("item.color").replacen("%s", &hex, 1), false) + } else { + (text("item.dyed"), true) + }) +} + +/// `ItemStack.UNBREAKABLE_TOOLTIP`. +pub fn unbreakable_line( + stack: &ItemStack, + display: &TooltipDisplay, + translate: &dyn Fn(&str) -> Option, +) -> Option { + (stack.data()?.other("minecraft:unbreakable").is_some() + && display.shows("minecraft:unbreakable")) + .then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into())) +} + +/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When +/// Applied:" and the modifiers of the potion's effects (blue for a gain, red +/// for a loss); nothing for a potion whose effects add none. +pub fn potion_modifier_lines( + stack: &ItemStack, + translate: &dyn Fn(&str) -> Option, +) -> Vec<(String, u32)> { + if !is_potion_item(&stack.item) { + return Vec::new(); + } + let applied: Vec<(String, u32)> = stack + .potion() + .and_then(crate::brewing::potion) + .into_iter() + .flat_map(|p| p.effects.iter()) + .filter_map(|(effect, _, amplifier)| { + let (attribute, amount, operation) = effect_modifier(effect)?; + let amount = amount * (*amplifier + 1) as f64; + let line = modifier_line(translate, attribute, amount, operation, false)?; + let color = if amount > 0.0 { + rgba(85, 85, 255, 255) + } else { + rgba(255, 85, 85, 255) + }; + Some((line, color)) + }) + .collect(); + if applied.is_empty() { + return applied; + } + let mut lines = vec![ + (String::new(), u32::MAX), + ( + translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()), + rgba(170, 0, 170, 255), + ), + ]; + lines.extend(applied); + lines +} + +/// `ItemStack.components.size()`: the item's default components with the +/// stack's own changes applied. +pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize { + let mut keys: std::collections::HashSet = info + .map(|i| i.components.iter().cloned().collect()) + .unwrap_or_default(); + if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data()) + { + for key in patch.keys() { + let key = key.to_string_lossy(); + match key.strip_prefix('!') { + Some(removed) => keys.remove(removed), + None => keys.insert(key), + }; + } + } + keys.len() +} + /// The lines of `stack` below its name that its components give, in /// `ItemStack.addDetailsToTooltip` order. pub fn detail_lines( @@ -265,19 +518,9 @@ pub fn detail_lines( )) )); } - // BeehiveBlock: the block state components of the item. - if path == "beehive" { - lines.push( - text("container.beehive.bees") - .replacen("%s", "0", 1) - .replacen("%s", "3", 1), - ); - lines.push( - text("container.beehive.honey") - .replacen("%s", "0", 1) - .replacen("%s", "5", 1), - ); - } + let display = TooltipDisplay::for_stack(stack); + lines.extend(bees_line(stack, &display, translate)); + lines.extend(honey_line(stack, &display, translate)); let data = stack.data(); // PotDecorations.addToTooltip. if let Some(sherds) = data @@ -303,32 +546,13 @@ pub fn detail_lines( .into_iter() .map(|l| l.0), ); - let applied: Vec = stack - .potion() - .and_then(crate::brewing::potion) - .into_iter() - .flat_map(|p| p.effects.iter()) - .filter_map(|(effect, _, amplifier)| { - let (attribute, amount, operation) = effect_modifier(effect)?; - modifier_line( - translate, - attribute, - amount * (*amplifier + 1) as f64, - operation, - false, - ) - }) - .collect(); - if !applied.is_empty() { - lines.push(String::new()); - lines.push(text("potion.whenDrank")); - lines.extend(applied); - } - } - // JukeboxPlayable.addToTooltip: the song's description. - if path.starts_with("music_disc_") { - lines.push(text(&format!("item.minecraft.{path}.desc"))); + lines.extend( + potion_modifier_lines(stack, translate) + .into_iter() + .map(|l| l.0), + ); } + lines.extend(jukebox_line(stack, info, &display, translate)); // Trim.addToTooltip. if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) { let short = |s: &str| s.trim_start_matches("minecraft:").to_owned(); @@ -342,10 +566,7 @@ pub fn detail_lines( text(&format!("trim_material.minecraft.{}", short(material))) )); } - // DyedItemColor.addToTooltip (without advanced tooltips). - if data.is_some_and(|d| d.dyed_color.is_some()) { - lines.push(text("item.dyed")); - } + lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0)); if let Some(info) = info { lines.extend( attribute_lines(&info.modifiers, None, translate) diff --git a/crates/horizon-client/src/items.rs b/crates/horizon-client/src/items.rs index b4092a201..10f1c72ad 100644 --- a/crates/horizon-client/src/items.rs +++ b/crates/horizon-client/src/items.rs @@ -113,6 +113,9 @@ pub struct ItemInfo { pub swappable: bool, /// `equippable.dispensable` (dispensers may put it on an entity). pub equip_dispensable: bool, + /// `equippable.damage_on_hurt`: the item wears when its wearer is hurt + /// (false for the elytra). + pub equip_damage_on_hurt: bool, /// `equippable.allowed_entities` resolved to entity type ids; None allows all. pub equip_allowed: Option>, pub equip_sound: Option, @@ -128,6 +131,8 @@ pub struct ItemInfo { pub block_transformer: Option, /// Has a default `bundle_contents` (the bundles, `BundleItem`). pub bundle: bool, + /// The ids of the item's default components (its prototype's keys). + pub components: Vec, } /// `Item.Properties.craftRemainder` (not a data component, so not in the /// default-component report): what an item leaves in the grid when crafted with. @@ -400,6 +405,36 @@ impl Items { self.get(&stack.item).is_some_and(|i| i.fire_resistant) } + /// `ItemStack.canBeHurtBy` for the cactus damage type: a `damage_resistant` + /// component naming `minecraft:cactus`, or a damage type tag that holds it. + /// (`#minecraft:is_fire`, as on netherite gear, does not.) + pub fn resists_cactus(&self, stack: &ItemStack) -> bool { + const CACTUS_TAGS: [&str; 5] = [ + "#minecraft:bypasses_shield", + "#minecraft:no_knockback", + "#minecraft:panic_environmental_causes", + "#minecraft:panic_causes", + "#minecraft:sulfur_cube_with_block_immune_to", + ]; + let Some((_, v)) = stack.data().and_then(|data| { + data.other + .iter() + .find(|(key, _)| key == "minecraft:damage_resistant") + }) else { + return false; + }; + match v.get("types") { + Some(horizon_world::nbt::Tag::String(id)) => id + .to_string() + .is_ok_and(|id| id == "minecraft:cactus" || CACTUS_TAGS.contains(&id.as_str())), + Some(horizon_world::nbt::Tag::List(ids)) => ids.iter().any(|t| { + matches!(t, horizon_world::nbt::Tag::String(id) + if id.to_string().is_ok_and(|id| id == "minecraft:cactus")) + }), + _ => false, + } + } + pub fn load(root: &Path, registry: &BlockRegistry, facts: &BlockFacts) -> Result { let mut block_for_item: HashMap<&str, BlockStateId> = HashMap::new(); for (i, s) in facts.states.iter().enumerate() { @@ -596,6 +631,9 @@ impl Items { equip_dispensable: c["minecraft:equippable"]["dispensable"] .as_bool() .unwrap_or(true), + equip_damage_on_hurt: c["minecraft:equippable"]["damage_on_hurt"] + .as_bool() + .unwrap_or(true), equip_allowed: match &c["minecraft:equippable"]["allowed_entities"] { Value::String(spec) => Some(entity_spec(&entity_tags, spec)), Value::Array(list) => Some( @@ -614,6 +652,10 @@ impl Items { cushion_color: c["minecraft:cushion/color"].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(), + components: c + .as_object() + .map(|c| c.keys().cloned().collect()) + .unwrap_or_default(), crafting_remainder: CRAFT_REMAINDERS .iter() .find(|(item, _)| *item == name) @@ -834,6 +876,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -898,10 +941,26 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, ) -> Vec { - self.block_drops_exploded(registry, state, held, dynamic, random, sequences, None) + self.block_drops_exploded( + registry, state, held, dynamic, random, sequences, None, false, + ) + } + /// `block_drops_with` for a block a player breaks (`Block.playerDestroy`): + /// the player is the loot context's `this` entity, so snow and chorus + /// flower tables see an entity. + pub fn block_drops_by_player( + &self, + registry: &BlockRegistry, + state: BlockStateId, + held: Option<&ItemStack>, + dynamic: &[ItemStack], + random: &mut impl RandomSource, + ) -> Vec { + self.block_drops_exploded(registry, state, held, dynamic, random, None, None, true) } /// `block_drops_full` for a block an explosion destroys: `explosion` - /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`). + /// is its radius when the explosion decays drops (`DESTROY_WITH_DECAY`); + /// `breaker` is whether an entity (the player) broke the block. #[allow(clippy::too_many_arguments)] pub fn block_drops_exploded( &self, @@ -912,8 +971,10 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion: Option, + breaker: bool, ) -> Vec { - let drops = self.block_drops_raw(registry, state, held, random, sequences, explosion); + let drops = + self.block_drops_raw(registry, state, held, random, sequences, explosion, breaker); if !drops.iter().any(|d| d.item.starts_with(DYNAMIC)) { return drops; } @@ -928,6 +989,7 @@ impl Items { }) .collect() } + #[allow(clippy::too_many_arguments)] fn block_drops_raw( &self, registry: &BlockRegistry, @@ -936,6 +998,7 @@ impl Items { random: &mut impl RandomSource, sequences: Option<(&mut crate::random_sequences::RandomSequences, i64)>, explosion_radius: Option, + breaker: bool, ) -> Vec { let Some(block) = registry.state(state) else { return vec![]; @@ -969,6 +1032,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; return ctx.evaluate(table, random); } @@ -979,6 +1043,7 @@ impl Items { held, killed_by_player: false, explosion_radius, + this_entity: breaker, }; ctx.evaluate(table, random) } @@ -1002,6 +1067,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1034,6 +1100,7 @@ impl Items { held: None, killed_by_player: false, explosion_radius: None, + this_entity: false, }; match table.get("random_sequence").and_then(Value::as_str) { Some(sequence) => ctx.evaluate(table, sequences.get(sequence, seed)), @@ -1059,6 +1126,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1081,6 +1149,7 @@ impl Items { held, killed_by_player, explosion_radius: None, + this_entity: false, }; ctx.evaluate(table, random) } @@ -1122,6 +1191,8 @@ struct LootContext<'a> { killed_by_player: bool, /// `LootContextParams.EXPLOSION_RADIUS`: an explosion that decays drops. explosion_radius: Option, + /// `LootContextParams.THIS_ENTITY` is set (a player broke the block). + this_entity: bool, } impl LootContext<'_> { /// Level of an enchantment on the tool / killer's weapon. @@ -1282,6 +1353,15 @@ impl LootContext<'_> { }; random.next_float() < chance } + // `this` with no checks (snow, chorus flower) holds when an entity + // broke the block (LootItemEntityPropertyCondition needs the entity). + "minecraft:entity_properties" + if self.this_entity + && c["entity"].as_str() == Some("this") + && c["predicate"].as_object().is_none_or(|p| p.is_empty()) => + { + true + } // Entity flags, equipment and damage-source predicates: none hold // for an unenchanted player kill without fire or lightning. "minecraft:entity_properties" | "minecraft:damage_source_properties" => false, @@ -1667,6 +1747,7 @@ mod tests { held, killed_by_player: block.is_empty(), explosion_radius: None, + this_entity: false, } } diff --git a/crates/horizon-client/src/keybinds.rs b/crates/horizon-client/src/keybinds.rs index 7575fb953..2047b9117 100644 --- a/crates/horizon-client/src/keybinds.rs +++ b/crates/horizon-client/src/keybinds.rs @@ -2,7 +2,6 @@ //! defaults, `key_:key.keyboard.x` persistence, display names, and the //! translation of rebound physical keys back to the default keys the game //! logic listens for. -use crate::text::Text; use std::collections::BTreeMap; use winit::{event::MouseButton, keyboard::KeyCode}; @@ -225,40 +224,7 @@ pub fn mouse_button(name: &str) -> Option { _ => return None, }) } -/// InputConstants.Key.getDisplayName: one-character keys as the upper-case -/// character, others by translation. -pub fn display_name(key: &str) -> Text { - if let Some(rest) = key.strip_prefix("key.keyboard.") - && rest.chars().count() == 1 - { - let c = rest.to_uppercase(); - return Text::literal(c); - } - let symbol = match key { - "key.keyboard.minus" => Some("-"), - "key.keyboard.equal" => Some("="), - "key.keyboard.left.bracket" => Some("["), - "key.keyboard.right.bracket" => Some("]"), - "key.keyboard.backslash" => Some("\\"), - "key.keyboard.semicolon" => Some(";"), - "key.keyboard.apostrophe" => Some("'"), - "key.keyboard.grave.accent" => Some("`"), - "key.keyboard.comma" => Some(","), - "key.keyboard.period" => Some("."), - "key.keyboard.slash" => Some("/"), - _ => None, - }; - if let Some(s) = symbol { - return Text::literal(s); - } - if let Some(n) = key.strip_prefix("key.mouse.") - && n.parse::().is_ok() - && !crate::lang::has(key) - { - return Text::translate("key.mouse", &[Text::literal(n)]); - } - Text::translate(key, &[]) -} +pub use horizon_client_core::key_display::display_name; /// Current bindings by mapping name. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index 122d37bcd..9ca20b894 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> { "sculk_catalyst" => "minecraft:sculk_catalyst", "sculk_shrieker" => "minecraft:sculk_shrieker", "crafter" => "minecraft:crafter", + "potent_sulfur" => "minecraft:potent_sulfur", "chiseled_bookshelf" => "minecraft:chiseled_bookshelf", "trial_spawner" => "minecraft:trial_spawner", "vault" => "minecraft:vault", diff --git a/crates/horizon-client/src/lan/commands.rs b/crates/horizon-client/src/lan/commands.rs index 71b7fa68d..c3e462348 100644 --- a/crates/horizon-client/src/lan/commands.rs +++ b/crates/horizon-client/src/lan/commands.rs @@ -86,6 +86,18 @@ impl CommandTree { /// The ClientboundCommandsPacket for a player with `level` permissions. pub fn packet(&self, level: u8, dedicated: bool, builtin: &Builtin) -> Commands { + self.packet_with(level, dedicated, builtin, &Value::Null) + } + + /// [`CommandTree::packet`] with more commands after vanilla's: `extra` + /// is a root in the report's format (server mods' commands). + pub fn packet_with( + &self, + level: u8, + dedicated: bool, + builtin: &Builtin, + extra: &Value, + ) -> Commands { let mut nodes = vec![Node { name: String::new(), kind: 0, @@ -99,16 +111,18 @@ impl CommandTree { let suggestions: HashMap<&str, &'static str> = SUGGESTIONS.iter().copied().collect(); let mut paths: HashMap, usize> = HashMap::new(); paths.insert(Vec::new(), 0); - fill( - &self.root, - 0, - &mut Vec::new(), - level, - dedicated, - &suggestions, - &mut nodes, - &mut paths, - ); + for root in [&self.root, extra] { + fill( + root, + 0, + &mut Vec::new(), + level, + dedicated, + &suggestions, + &mut nodes, + &mut paths, + ); + } // enumerateNodes: breadth first from the root, each node's children // (in registration order) then its redirect target. let mut ids = vec![None; nodes.len()]; @@ -365,4 +379,33 @@ mod tests { ); assert!(op.nodes[3].restricted); } + + #[test] + fn server_mods_commands_follow_vanillas() { + let report: Value = serde_json::from_str( + r#"{"type":"root","children":{"me":{"type":"literal","children":{"action":{"type":"argument","parser":"minecraft:message","executable":true}}}}}"#, + ) + .unwrap(); + let mods: Value = serde_json::from_str( + r#"{"type":"root","children":{"beacon":{"type":"literal","executable":true,"children":{ + "height":{"type":"argument","parser":"brigadier:integer","properties":{"min":1,"max":64},"executable":true}}}}}"#, + ) + .unwrap(); + let tree = CommandTree { root: report }; + let packet = tree.packet_with(0, true, &Builtin::default(), &mods); + let names: Vec<_> = packet.nodes.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["", "me", "beacon", "action", "height"]); + let beacon = &packet.nodes[2]; + assert!(beacon.executable && !beacon.restricted); + assert_eq!(beacon.children, [4]); + let height = packet.nodes[4].parser.as_ref().unwrap(); + assert_eq!(height.1, vec![3, 0, 0, 0, 1, 0, 0, 0, 64]); + // No mods: vanilla's tree as it is. + assert_eq!( + tree.packet_with(0, true, &Builtin::default(), &Value::Null) + .nodes + .len(), + 3 + ); + } } diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index f2dcf701d..99b70e065 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -791,6 +791,16 @@ fn login_and_configure( }, }), )?; + // ServerConfigurationPacketListenerImpl.startConfiguration: links, only + // when there are any (MinecraftServer.serverLinks is empty elsewhere). + if !shared.config.server_links.is_empty() { + send( + c, + cfg::Clientbound::ServerLinks(cfg::ServerLinks { + links: shared.config.server_links.clone(), + }), + )?; + } send( c, cfg::Clientbound::UpdateEnabledFeatures(cfg::UpdateEnabledFeatures { diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 002f95cbb..59f01552e 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -36,6 +36,30 @@ impl Hub { /// ClientboundSoundPacket for a sound event name (an inline event when /// the registry does not know it). + pub fn sound_packet( + &self, + name: &str, + at: [f64; 3], + volume: f32, + pitch: f32, + source: i32, + seed: i64, + ) -> C { + let sound = match self.sync.id("minecraft:sound_event", name) { + Some(id) => (id, None), + None => (-1, Some((name.to_owned(), None))), + }; + C::Sound(play::Sound { + sound, + source, + x: (at[0] * 8.0) as i32, + y: (at[1] * 8.0) as i32, + z: (at[2] * 8.0) as i32, + volume, + pitch, + seed, + }) + } pub fn sound( &self, name: &str, @@ -45,20 +69,7 @@ impl Hub { source: i32, except: Option, ) { - let sound = match self.sync.id("minecraft:sound_event", name) { - Some(id) => (id, None), - None => (-1, Some((name.to_owned(), None))), - }; - let packet = C::Sound(play::Sound { - sound, - source, - x: (at[0] * 8.0) as i32, - y: (at[1] * 8.0) as i32, - z: (at[2] * 8.0) as i32, - volume, - pitch, - seed: self.ticks as i64, - }); + let packet = self.sound_packet(name, at, volume, pitch, source, self.ticks as i64); self.broadcast_near(at, f64::from(volume.max(1.0)) * 16.0, &packet, except); } @@ -120,12 +131,13 @@ impl Hub { ); } - /// `ServerPlayer.crit`: send the victim's animation to the players tracking - /// the attacker and the attacker themselves (`sendToTrackingPlayersAndSelf`). - pub fn critical_hit(&self, attacker: i32, target: i32) { + /// `ServerPlayer.crit` (`magicCrit` when `magic`): send the victim's + /// animation to the players tracking the attacker and the attacker + /// themselves (`sendToTrackingPlayersAndSelf`). + pub fn critical_hit(&self, attacker: i32, target: i32, magic: bool) { let body = C::Animate(play::Animate { id: target, - action: 1, + action: if magic { 2 } else { 1 }, }) .encode(); for g in self.guests.iter().filter(|g| { @@ -421,6 +433,42 @@ impl Hub { drops } + /// `AbstractContainerMenu.setSelectedBundleItemIndex` on the guest's open + /// menu: a container's own slots, or the inventory's. A bundle's selection + /// lives on the server's stack until the click that takes from it. + pub fn select_bundle_item(&mut self, conn: u32, slot: usize, index: i32, world: &mut World) { + let Some(g) = self.guests.iter_mut().find(|g| g.conn == conn) else { + return; + }; + let open = g.menu.as_ref().map(|m| (m.size, m.slots)); + let mut items = match open { + Some((size, source)) if slot < size => source.read(world, g), + _ => None, + }; + let stack = match open { + Some((size, _)) if slot < size => items + .as_mut() + .and_then(|items| items.get_mut(slot)) + .and_then(Option::as_mut), + Some((size, _)) => g + .inventory + .get_mut(player_slot(slot - size)) + .and_then(Option::as_mut), + None => match slot { + 9..=35 => g.inventory.get_mut(slot).and_then(Option::as_mut), + 36..=44 => g.inventory.get_mut(slot - 36).and_then(Option::as_mut), + 45 => g.offhand.as_mut(), + _ => None, + }, + }; + if let Some(stack) = stack { + crate::bundle::toggle_selected(stack, index); + } + if let (Some((_, source)), Some(items)) = (open, items) { + source.write(world, g, items); + } + } + /// A click on the inventory menu (window 0) the game applied itself /// (the crafting grid): the client is corrected like after any click. pub fn inventory_clicked( diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index ae3089006..920052b5a 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -611,8 +611,9 @@ pub struct Viewer<'a> { } /// One entity as this tick's range checks read it: where it is -/// horizontally, its id, its type's tracking range in chunks and the chunk -/// it is in (an index into the tick's chunks). +/// horizontally, its id, its tracking range in blocks (scaled by +/// `entity-broadcast-range-percentage`) and the chunk it is in (an index +/// into the tick's chunks). #[derive(Clone, Copy)] struct Probe { x: f64, @@ -627,7 +628,7 @@ struct Probe { /// each of the tick's chunks whether the client has it (0: not looked up /// yet, 1: no, 2: yes), so each chunk is looked up once per client. fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { - let range = f64::from(p.range.min(v.view_distance) * 16); + let range = f64::from(p.range.min(v.view_distance * 16)); let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z); dx * dx + dz * dz <= range * range && { let held = &mut held[p.chunk as usize]; @@ -641,6 +642,9 @@ fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { /// One level's tracked entities (ChunkMap.entityMap). #[derive(Default)] pub struct Entities { + /// `entity-broadcast-range-percentage` (None: 100), which scales every + /// type's tracking range (MinecraftServer.getScaledTrackingDistance). + pub range_percent: Option, states: crate::fast_hash::FxHashMap, /// The level tick, stamped on every entity still in the level. tick: u32, @@ -732,7 +736,9 @@ impl Entities { x: e.position[0], z: e.position[2], id: e.id, - range: s.range, + range: self + .range_percent + .map_or(s.range * 16, |p| p * (s.range * 16) / 100), chunk, }); } diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 7e43cf04e..9f83e1457 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -2,7 +2,12 @@ //! player state (inventory, health, game mode) that the host's gameplay code //! operates on when it acts for them. use super::{Joined, PlayerSender, entity::Tracker}; -use crate::{items::ItemStack, motion::PlayerMotion}; +use crate::{ + items::ItemStack, + motion::PlayerMotion, + pose::{Dimensions, Pose}, +}; +use horizon_core::geometry::{Aabb, Vec3}; use horizon_protocol::{ item::Stack, packets::{configuration::ClientInformation, play}, @@ -407,8 +412,41 @@ impl Guest { } f } + /// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose + /// (swimming, crawling, crouching) once its server tick has run. + pub fn pose(&self) -> Pose { + if self.sleeping { + Pose::Sleeping + } else if self.fall_flying { + Pose::FallFlying + } else if let Some(m) = &self.motion { + m.pose + } else if self.sneaking && !self.flying { + Pose::Crouching + } else { + Pose::Standing + } + } + /// `Avatar.POSES` for the guest's pose, with its scale attribute. + pub fn dimensions(&self) -> Dimensions { + let pose = self.pose(); + match &self.motion { + Some(m) if m.pose == pose => m.dimensions, + _ => pose.dimensions(1.0), + } + } + /// `Entity.getBoundingBox`. + pub fn bounds(&self) -> Aabb { + let [x, y, z] = self.position; + let d = self.dimensions(); + let half = f64::from(d.width) / 2.0; + Aabb::new( + Vec3::new(x - half, y, z - half), + Vec3::new(x + half, y + f64::from(d.height), z + half), + ) + } pub fn eye_height(&self) -> f64 { - if self.sneaking { 1.27 } else { 1.62 } + f64::from(self.dimensions().eye_height) } /// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest: /// its block interaction range is 4.5, 5 in creative. @@ -631,11 +669,14 @@ fn network_stack( title: w.title.clone(), author: w.author.clone(), generation: i32::from(w.generation), - pages: w - .pages - .iter() - .map(|p| horizon_protocol::text::literal(p)) - .collect(), + pages: if w.page_tags.len() == w.pages.len() { + w.page_tags.clone() + } else { + w.pages + .iter() + .map(|p| horizon_protocol::text::literal(p)) + .collect() + }, resolved: true, }, ), diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index b1a8cbc62..12967d778 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -21,7 +21,7 @@ use crate::{ use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape}; use horizon_protocol::ids::play::clientbound as ids; use horizon_protocol::{ - entity_data::{Value, serializer}, + entity_data::{Particle, ParticleData, Value, serializer}, packets::play::{self, Clientbound as C, Serverbound as S}, text::{literal, translatable}, }; @@ -43,6 +43,7 @@ pub mod game_event { pub const RAIN_LEVEL: u8 = 7; pub const THUNDER_LEVEL: u8 = 8; pub const GUARDIAN_ELDER_EFFECT: u8 = 10; + pub const IMMEDIATE_RESPAWN: u8 = 11; pub const LEVEL_CHUNKS_LOAD_START: u8 = 13; } @@ -87,6 +88,29 @@ pub struct HostState { pub living_flags: i8, /// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`). pub bed: Option<[i32; 3]>, + pub body: BodyFlags, +} + +/// What the other players see of a player's body besides sneaking and +/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`. +#[derive(Clone, Copy, Debug, Default)] +pub struct BodyFlags { + /// `Entity.isFallFlying`: gliding with elytra. + pub gliding: bool, + /// `Entity.isSwimming` (the swimming flag). + pub swimming: bool, + /// The swimming pose of a player crawling under a block. + pub crawling: bool, + /// The crouching pose, held without sneaking under a low ceiling. + pub crouching: bool, + /// `LivingEntity.isAutoSpinAttack`: Riptide's spin. + pub spin_attack: bool, + /// `Entity.isOnFire`. + pub on_fire: bool, + /// `Entity.isInvisible`: the invisibility effect. + pub invisible: bool, + /// The glowing effect. + pub glowing: bool, } /// Everything the hub needs from the game for one tick. @@ -149,6 +173,8 @@ pub struct Hub { /// The `player_movement_check` and `elytra_movement_check` game rules, /// set by the game before each tick. pub movement_checks: (bool, bool), + /// The `immediate_respawn` game rule, as the players were last told. + immediate_respawn: bool, /// An integrated server's owner (`MinecraftServer.isSingleplayerOwner`), /// whose moves are never checked. pub singleplayer_owner: Option, @@ -168,6 +194,8 @@ pub struct Hub { level_sync: std::collections::HashMap, /// Operators and their permission levels (dedicated servers). pub ops: std::collections::HashMap, + /// `entity-broadcast-range-percentage` (dedicated servers). + pub entity_range_percent: i32, chat_type: i32, dimension_type: i32, clock_ids: Vec, @@ -177,6 +205,9 @@ pub struct Hub { entities: std::collections::HashMap, /// What one tick of entity tracking sends each guest, kept between ticks. entity_out: Vec>>, + /// Server mods' commands, a root in the commands report's format, sent + /// after vanilla's; the key the game last set them for. + mod_commands: (serde_json::Value, Option), } fn string(s: &str) -> Tag { @@ -324,6 +355,7 @@ impl Hub { guests: Vec::new(), sync, ticks: 0, + immediate_respawn: false, movement_checks: (true, true), singleplayer_owner: None, riding: Default::default(), @@ -332,6 +364,7 @@ impl Hub { ticking: (20.0, false), stepping: 0, ops: Default::default(), + entity_range_percent: 100, weather: (false, 0.0, 0.0), level_sync: Default::default(), chat_type, @@ -341,6 +374,7 @@ impl Hub { entity_types: Default::default(), entities: Default::default(), entity_out: Vec::new(), + mod_commands: (serde_json::Value::Null, None), teams: Vec::new(), } } @@ -399,6 +433,17 @@ impl Hub { g.sender.send(body.clone()); } } + /// MinecraftServer.onGameRuleChanged: every player is told when + /// `immediate_respawn` changes; a joining player's Login carries it. + pub fn set_immediate_respawn(&mut self, on: bool) { + if self.immediate_respawn != on { + self.immediate_respawn = on; + self.broadcast(&C::GameEvent(play::GameEvent { + event: game_event::IMMEDIATE_RESPAWN, + param: if on { 1.0 } else { 0.0 }, + })); + } + } /// PlayerList.setViewDistance: every player is told the new radius, /// and each one's chunks follow it, up to what they asked for /// (ChunkMap.getPlayerViewDistance). @@ -633,7 +678,7 @@ impl Hub { chunk_radius: self.config.view_distance, simulation_distance: self.config.simulation_distance, reduced_debug_info: false, - show_death_screen: true, + show_death_screen: !self.immediate_respawn, limited_crafting: false, spawn: self.spawn_info(mode), // MinecraftServer.usesAuthentication, enforceSecureProfile. @@ -849,13 +894,36 @@ impl Hub { } } + /// The key [`Hub::set_mod_commands`] last had: the game compares it + /// with its server mods' each tick. + pub fn mod_commands_key(&self) -> Option { + self.mod_commands.1 + } + /// Server mods' commands changed: every player gets their tree again. + pub fn set_mod_commands(&mut self, key: u64, commands: serde_json::Value) { + let resend = self.mod_commands.0 != commands; + self.mod_commands = (commands, Some(key)); + if resend { + for i in 0..self.guests.len() { + if self.guests[i].placed { + self.send_commands(i); + } + } + } + } + /// Commands.sendCommands: the command tree for this player. fn send_commands(&self, i: usize) { let g = &self.guests[i]; let packet = match &self.sync.commands { Some(tree) => { let dedicated = self.config.dedicated && !self.config.integrated; - tree.packet(g.permission, dedicated, &self.sync.builtin) + tree.packet_with( + g.permission, + dedicated, + &self.sync.builtin, + &self.mod_commands.0, + ) } None => play::Commands { nodes: vec![play::CommandNode { @@ -1500,6 +1568,49 @@ impl Hub { // ---- entities ------------------------------------------------------ + /// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES + /// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as + /// (effect, ambient). The entries are left out when there is nothing to + /// show, which is their default. + pub fn effect_meta<'a>( + &self, + effects: impl IntoIterator, + ) -> Vec<(u8, i32, Value)> { + let mut particles = Vec::new(); + let mut all_ambient = true; + for (effect, ambient) in effects { + let effect = effect.trim_start_matches("minecraft:"); + // MobEffect.particleFactory: the omens have their own particles, + // the others an entity effect in their colour (faint if ambient). + let (name, data) = match effect { + "trial_omen" | "raid_omen" => (effect, None), + _ => { + let Some(def) = crate::brewing::effect(effect) else { + continue; + }; + let alpha = if ambient { 38 } else { 255 }; + let argb = (alpha << 24) | def.color as i32; + ("entity_effect", Some(ParticleData::Color(argb))) + } + }; + let Some(id) = + self.registry_id("minecraft:particle_type", &format!("minecraft:{name}")) + else { + continue; + }; + particles.push(Particle { id, data }); + all_ambient &= ambient; + } + if particles.is_empty() { + return Vec::new(); + } + let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))]; + if all_ambient { + meta.push((11, serializer::BOOLEAN, Value::Bool(true))); + } + meta + } + fn player_state( &self, id: i32, @@ -1507,21 +1618,29 @@ impl Hub { position: [f64; 3], rot: (f32, f32, f32), on_ground: bool, - flags: (bool, bool, bool, bool), + flags: (bool, bool, bool), + body: BodyFlags, living_flags: i8, bed: Option<[i32; 3]>, skin: u8, health: f32, equipment: Vec<(u8, Option)>, attributes: Vec, + effects: &[EffectInstance], ) -> EntityState { - let (sneaking, sprinting, gliding, sleeping) = flags; - let shared = shared_flags(sneaking, sprinting, gliding); + let (sneaking, sprinting, sleeping) = flags; + let shared = shared_flags(sneaking, sprinting, &body); + // Player.getDesiredPose (and updatePlayerPose's crawl): sleeping, + // swimming, gliding, Riptide's spin, crouching. let pose = if sleeping { 2 - } else if gliding { + } else if body.swimming || body.crawling { + 3 + } else if body.gliding { 1 - } else if sneaking { + } else if body.spin_attack { + 4 + } else if sneaking || body.crouching { 5 } else { 0 @@ -1547,6 +1666,14 @@ impl Hub { if skin != 0 { meta.push((16, serializer::BYTE, Value::Byte(skin as i8))); } + meta.extend( + self.effect_meta( + effects + .iter() + .filter(|e| e.visible) + .map(|e| (e.id, e.ambient)), + ), + ); EntityState { id, uuid, @@ -1559,7 +1686,11 @@ impl Hub { velocity: [0.0; 3], data: 0, meta, - defaults: Vec::new(), + // The effect swirls go back to none. + defaults: vec![ + (10, serializer::PARTICLES, Value::Particles(Vec::new())), + (11, serializer::BOOLEAN, Value::Bool(false)), + ], equipment, attributes, precise: false, @@ -1607,13 +1738,15 @@ impl Hub { h.position, (h.yaw, h.pitch, h.head_yaw), h.on_ground, - (h.sneaking, h.sprinting, false, h.bed.is_some()), + (h.sneaking, h.sprinting, h.bed.is_some()), + h.body, h.living_flags, h.bed, 0x7F, h.health, equipment, h.attributes.clone(), + &[], )); } let placed: Vec = (0..self.guests.len()) @@ -1639,7 +1772,8 @@ impl Hub { g.position, (g.yaw, g.pitch, g.head_yaw), g.on_ground, - (g.sneaking, g.sprinting, g.fall_flying, g.sleeping), + (g.sneaking, g.sprinting, g.sleeping), + guest_body(g), g.living_flags, g.bed, g.info.model_customisation, @@ -1649,6 +1783,7 @@ impl Hub { .as_ref() .map(|m| super::attributes::syncable(&m.attributes)) .unwrap_or_default(), + &g.effects.list, ) }) .collect(); @@ -1678,6 +1813,7 @@ impl Hub { out, }) .collect(); + level.range_percent = Some(self.entity_range_percent); let (types, sync) = (&mut self.entity_types, &self.sync); level.tick(&entities, &self.riding, &mut viewers, |kind| { entity_type(types, sync, kind) @@ -1798,7 +1934,7 @@ impl Hub { // 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); + let shared = shared_flags(g.sneaking, g.sprinting, &guest_body(g)); if shared != g.sent_shared { g.sent_shared = shared; g.send(&C::SetEntityData(play::SetEntityData { @@ -1947,6 +2083,7 @@ impl Hub { | S::PlayerLoaded(_) | S::ChunkBatchReceived(_) | S::ClientInformation(_) + | S::RecipeBookChangeSettings(_) | S::ClientTickEnd(_) | S::KeepAlive(_) => false, S::MovePlayerPos(p) => self.moved_far(i, [p.x, p.y, p.z]), @@ -2013,6 +2150,15 @@ impl Hub { g.max_unacknowledged = 10; } S::ClientTickEnd(_) => self.guests[i].position_this_tick = false, + // ServerGamePacketListenerImpl.handleRecipeBookChangeSettingsPacket. + S::RecipeBookChangeSettings(p) => { + if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize) + { + let settings = &mut self.guests[i].recipe_book; + settings.open[book.index()] = p.open; + settings.filtering[book.index()] = p.filtering; + } + } S::ClientInformation(info) => { let g = &mut self.guests[i]; g.view_distance = @@ -2043,6 +2189,20 @@ impl Hub { S::Chat(c) => self.handle_chat(i, c, events), S::ChatAck(p) => self.chat_ack(i, p.offset, events), S::ChatSessionUpdate(p) => self.chat_session_update(i, p, events), + // handlePlayerAction (a dig starting, stopping or finishing) is the + // game's, run after this pass. A move queued behind it is checked + // against the block the player just broke and falls through, so it + // would be sent back; it waits for the next tick, when the block is + // gone, as vanilla handles the two packets in order. + S::PlayerAction(p) if p.action <= 3 => { + events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p)))); + if matches!( + self.guests[i].inbox.front(), + Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_)) + ) { + break; + } + } other => events.push(HostEvent::Packet(conn, Box::new(other))), } } @@ -2134,11 +2294,16 @@ impl Hub { packets = 1; } // shouldCheckPlayerMovement (never the single-player owner). - // The server holds no velocity for a guest (getDeltaMovement), - // so nothing is expected. if !owner && check && (!g.fall_flying || elytra_check) { let limit = if g.fall_flying { 300.0 } else { 100.0 }; - if moved > limit * f64::from(packets) { + // `movedDist - expectedDist` (getDeltaMovement): the velocity + // the server last simulated for the guest is expected + // movement, not a violation — a landing glide keeps its speed. + let expected = g.motion.as_ref().map_or(0.0, |m| { + let v = m.velocity; + v.x * v.x + v.y * v.y + v.z * v.z + }); + if moved - expected > limit * f64::from(packets) { events.push(HostEvent::Warn(format!( "{} moved too quickly! {},{},{}", g.name, @@ -2194,18 +2359,8 @@ impl Hub { } // getBoundingBox: the box of the body's pose (swimming and crawling // are 0.6 tall), so a swimmer under a ceiling isn't sent back. - let (half, height) = if g.fall_flying { - (0.3, 0.6) - } else if let Some(m) = &g.motion { - ( - f64::from(m.dimensions.width) / 2.0, - f64::from(m.dimensions.height), - ) - } else if g.sneaking { - (0.3, 1.5) - } else { - (0.3, 1.8) - }; + let d = g.dimensions(); + let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height)); let at = |p: [f64; 3]| { Aabb::new( Vec3::new(p[0] - half, p[1], p[2] - half), @@ -2578,16 +2733,44 @@ 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 { +/// What the others see of a guest's body, from its server-side state. +fn guest_body(g: &Guest) -> BodyFlags { + let m = g.motion.as_deref(); + BodyFlags { + gliding: g.fall_flying, + swimming: m.is_some_and(|m| m.swimming), + crawling: m.is_some_and(|m| m.pose == crate::pose::Pose::Swimming && !m.swimming), + crouching: m.is_some_and(|m| m.pose == crate::pose::Pose::Crouching), + spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0), + on_fire: m.is_some_and(|m| m.fire_ticks > 0), + invisible: g.effects.get("invisibility").is_some(), + glowing: g.effects.get("glowing").is_some(), + } +} + +/// Entity.DATA_SHARED_FLAGS_ID of a player: on fire 0x01, crouching 0x02, +/// sprinting 0x08, swimming 0x10, invisible 0x20, glowing 0x40, gliding 0x80. +fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 { let mut shared = 0i8; + if body.on_fire { + shared |= 0x01; + } if sneaking { shared |= 0x02; } if sprinting { shared |= 0x08; } - if gliding { + if body.swimming { + shared |= 0x10; + } + if body.invisible { + shared |= 0x20; + } + if body.glowing { + shared |= 0x40; + } + if body.gliding { shared |= -128; } shared diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 859bafa23..641e45b1b 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -5,7 +5,7 @@ //! menu are returned for the game to drop. use super::guest::Guest; use crate::{ - bundle::{Contents, can_be_in_bundle}, + bundle::{Contents, can_be_in_bundle, toggle_selected}, items::{ItemStack, Items}, world::World, }; @@ -514,7 +514,10 @@ fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bo } } (carried, _) => { + // toggleSelectedItem(-1), then the click goes on. view.guest.carried = carried; + toggle_selected(&mut bundle, -1); + view.set(i, Some(bundle)); return false; } } diff --git a/crates/horizon-client/src/lan/mod.rs b/crates/horizon-client/src/lan/mod.rs index 3e9b93cac..34c465ffa 100644 --- a/crates/horizon-client/src/lan/mod.rs +++ b/crates/horizon-client/src/lan/mod.rs @@ -142,6 +142,10 @@ pub struct NetConfig { /// server.properties `resource-pack`: offered to every player during /// configuration (a dedicated server's; MinecraftServer.getServerResourcePack). pub resource_pack: Option, + /// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`): + /// sent in configuration when non-empty + /// (ServerConfigurationPacketListenerImpl.startConfiguration). + pub server_links: Vec, } impl NetConfig { /// MinecraftServer.enforceSecureProfile. @@ -168,6 +172,7 @@ impl NetConfig { online: None, log: None, resource_pack: None, + server_links: Vec::new(), } } } diff --git a/crates/horizon-client/src/landing.rs b/crates/horizon-client/src/landing.rs index 6fb1f7580..629d8c042 100644 --- a/crates/horizon-client/src/landing.rs +++ b/crates/horizon-client/src/landing.rs @@ -324,10 +324,13 @@ impl BlockLandingWorld for MotionWorld<'_, '_, '_> { type DamageSource = LandingDamage; type Sound = SceneSound; fn is_server_level(&self) -> bool { - true + // A remote client's world is a client level: it never tramples, + // the server's block change and teleport do it (FarmlandBlock.fallOn + // and TurtleEggBlock.fallOn check `level instanceof ServerLevel`). + !self.player.client_side } fn is_client_side(&mut self) -> Result { - Ok(false) + Ok(self.player.client_side) } fn random_float(&mut self) -> Result { Ok(self.scene.random.next_float()) diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index ebfbdea58..4082c0b0c 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -15,17 +15,16 @@ pub mod auto_jump; pub mod banner; pub mod beehive; pub mod benchmark; +pub mod bidi; pub mod biome; pub mod block_entities; pub mod block_entity_anim; pub mod block_entity_item; -pub mod block_entity_nbt; pub mod block_entity_render; pub mod block_transform; pub mod blocks; pub mod border_render; pub mod boss_overlay; -pub mod bossbar; pub mod breaking; pub mod brewing; pub mod bundle; @@ -38,10 +37,8 @@ pub mod clipboard; pub mod clock; pub mod clouds; pub mod collisions; -pub mod command; pub mod command_block; pub mod command_storage; -pub mod component; pub mod confirm_screen; pub mod container_screen; pub mod containers; @@ -71,7 +68,6 @@ pub mod extras; pub mod extras_hud; pub mod falling_block; pub mod fast_hash; -pub mod fastutil_order; pub mod fire_render; pub mod first_person; pub mod flatgen; @@ -79,8 +75,8 @@ pub mod fog; pub mod font; pub mod friends_screen; pub mod game_mode_switcher; +pub mod game_rules_screen; pub mod gamepad; -pub mod gamerules; pub mod gfx; pub mod gizmos; pub mod gossip; @@ -109,7 +105,6 @@ pub mod keybinds; pub mod keymapping; pub mod lan; pub mod landing; -pub mod lang; pub mod level_loading; pub mod light; pub mod locator_bar; @@ -131,7 +126,6 @@ pub mod mouse; pub mod moved_extras; pub mod multiplayer; pub mod music; -pub mod nbt_path; pub mod net; pub mod nether_portal; pub mod offline_name; @@ -160,7 +154,6 @@ pub mod recipe_book; pub mod recipe_book_screen; pub mod reconnect; pub mod save; -pub mod scoreboard; pub mod scoreboard_cmd; pub mod screen_draw; pub mod screen_effects; @@ -173,7 +166,6 @@ pub mod shader_pack_list; pub mod shadows; pub mod signs; pub mod skins; -pub mod snbt; pub mod sound_engine; pub mod sound_events; pub mod spans; @@ -186,7 +178,6 @@ pub mod structure_blocks; pub mod tab_list; pub mod telemetry; pub mod testgen; -pub mod text; pub mod text_display; pub mod tick_chunk_membership; pub mod ticket_levels; @@ -206,4 +197,15 @@ pub mod world; pub mod world_spawn; pub mod worldborder; pub mod zip; -pub type Result = std::result::Result>; +pub use horizon_client_core::Result; +pub use horizon_client_core::block_entity_nbt; +pub use horizon_client_core::bossbar; +pub use horizon_client_core::component; +pub use horizon_client_core::fastutil_order; +pub use horizon_client_core::gamerules; +pub use horizon_client_core::lang; +pub use horizon_client_core::nbt_path; +pub use horizon_client_core::scoreboard; +pub use horizon_client_core::snbt; +pub use horizon_client_core::text; +pub use horizon_command as command; diff --git a/crates/horizon-client/src/light.rs b/crates/horizon-client/src/light.rs index 6cb4b65b1..c8313c3d7 100644 --- a/crates/horizon-client/src/light.rs +++ b/crates/horizon-client/src/light.rs @@ -17,7 +17,7 @@ use crate::fast_hash::FxHashMap; use crate::{ blocks::BlockFacts, - chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, index}, + chunk::{Chunk, HEIGHT, Light, MIN_Y, SECTIONS, Section, index}, }; use horizon_core::geometry::{Direction, ShapeOp, VoxelShape}; use horizon_world::registry::{BlockRegistry, BlockStateId}; @@ -565,43 +565,130 @@ pub fn update_light_layers>( /// Resident chunks the incremental relighter reads and writes. pub trait LightWorld { - fn state(&self, x: i32, y: i32, z: i32) -> Option; - /// Packed light (sky << 4 | block), None outside loaded chunks. - fn light(&self, x: i32, y: i32, z: i32) -> Option; - fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8); + /// Section `s` of chunk (cx, cz), None unless the chunk is loaded and lit. + fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc
>; + /// Replace the light of a section `section` returned. + fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light); } impl LightWorld for HashMap<(i32, i32), Arc, S> { - fn state(&self, x: i32, y: i32, z: i32) -> Option { + fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc
> { + self.get(&(cx, cz)) + .filter(|c| c.lit) + .map(|c| &c.sections[s]) + } + fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light) { + if let Some(chunk) = self.get_mut(&(cx, cz)) { + Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]).light = light; + } + } +} +/// The sections one relight touches, held for the whole flood fill so each +/// cell access is an array index instead of a chunk lookup. Light a section +/// gets written is copied out on the first write and stored back by `finish`. +struct Region<'w> { + world: &'w mut dyn LightWorld, + slots: Vec, + /// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit. + index: FxHashMap, + last: (Pos, u32), +} +struct Slot { + key: Pos, + /// The world's section; its light is the value before this relight. + section: Arc
, + light: Option>, +} +const NONE: u32 = u32::MAX; +impl<'w> Region<'w> { + fn new(world: &'w mut dyn LightWorld) -> Self { + Self { + world, + slots: Vec::new(), + index: FxHashMap::default(), + last: ((i32::MIN, 0, 0), NONE), + } + } + #[inline] + fn slot(&mut self, x: i32, y: i32, z: i32) -> Option { if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) { return None; } - self.get(&(x >> 4, z >> 4)) - .filter(|c| c.lit) - .map(|c| c.get(x & 15, y, z & 15)) - } - fn light(&self, x: i32, y: i32, z: i32) -> Option { - if !(MIN_Y..MIN_Y + HEIGHT).contains(&y) { - return None; + let key = (x >> 4, (y - MIN_Y) >> 4, z >> 4); + if self.last.0 != key { + self.last = (key, self.load(key)); } - self.get(&(x >> 4, z >> 4)) - .filter(|c| c.lit) - .map(|c| c.light(x & 15, y, z & 15)) + (self.last.1 != NONE).then_some(self.last.1 as usize) } - fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) { - let Some(chunk) = self.get_mut(&(x >> 4, z >> 4)) else { + fn load(&mut self, key: Pos) -> u32 { + if let Some(&slot) = self.index.get(&key) { + return slot; + } + let slot = match self.world.section(key.0, key.1 as usize, key.2) { + Some(section) => { + self.slots.push(Slot { + key, + section: section.clone(), + light: None, + }); + (self.slots.len() - 1) as u32 + } + None => NONE, + }; + self.index.insert(key, slot); + slot + } + fn state(&mut self, x: i32, y: i32, z: i32) -> Option { + let slot = self.slot(x, y, z)?; + Some(self.slots[slot].section.get(index(x, y, z))) + } + /// Packed light (sky << 4 | block), None outside loaded, lit chunks. + fn light(&mut self, x: i32, y: i32, z: i32) -> Option { + let slot = self.slot(x, y, z)?; + let slot = &self.slots[slot]; + let i = index(x, y, z); + Some(match &slot.light { + Some(light) => light[i], + None => slot.section.light(i), + }) + } + /// State and packed light of a cell, with a single section lookup. + fn cell(&mut self, x: i32, y: i32, z: i32) -> Option<(u32, u8)> { + let slot = self.slot(x, y, z)?; + let slot = &self.slots[slot]; + let i = index(x, y, z); + let light = match &slot.light { + Some(light) => light[i], + None => slot.section.light(i), + }; + Some((slot.section.get(i), light)) + } + /// Rewrite a cell's packed light; nothing outside loaded, lit chunks. + fn update_light(&mut self, x: i32, y: i32, z: i32, f: impl FnOnce(u8) -> u8) { + let Some(slot) = self.slot(x, y, z) else { return; }; - let s = ((y - MIN_Y) >> 4) as usize; + let slot = &mut self.slots[slot]; + let light = slot.light.get_or_insert_with(|| match &slot.section.light { + Light::Uniform(v) => Box::new([*v; 4096]), + Light::Full(a) => a.clone(), + }); let i = index(x, y, z); - if chunk.sections[s].light(i) == packed { - return; - } - let section = Arc::make_mut(&mut Arc::make_mut(chunk).sections[s]); - if let Light::Uniform(v) = section.light { - section.light = Light::Full(Box::new([v; 4096])); - } - if let Light::Full(a) = &mut section.light { - a[i] = packed; + light[i] = f(light[i]); + } + /// Store back the light of every section whose values changed. + fn finish(self) { + for slot in self.slots { + let Some(light) = slot.light else { + continue; + }; + if (0..4096).all(|i| slot.section.light(i) == light[i]) { + continue; + } + // Drop the clone first so the world's section isn't copied. + drop(slot.section); + let (cx, s, cz) = slot.key; + self.world + .set_section_light(cx, s as usize, cz, Light::Full(light)); } } } @@ -690,25 +777,23 @@ impl LightChanges { } } } -fn level(world: &impl LightWorld, sky: bool, p: Pos) -> Option { +fn level(world: &mut Region, sky: bool, p: Pos) -> Option { world .light(p.0, p.1, p.2) .map(|v| if sky { v >> 4 } else { v & 15 }) } -fn set_level(world: &mut impl LightWorld, sky: bool, p: Pos, value: u8) { - let Some(old) = world.light(p.0, p.1, p.2) else { - return; - }; - let packed = if sky { - (old & 0x0F) | value << 4 - } else { - (old & 0xF0) | value - }; - world.set_light(p.0, p.1, p.2, packed); +fn set_level(world: &mut Region, sky: bool, p: Pos, value: u8) { + world.update_light(p.0, p.1, p.2, |old| { + if sky { + (old & 0x0F) | value << 4 + } else { + (old & 0xF0) | value + } + }); } /// `ChunkSkyLightSources`: the Y of the upper block of the highest occluded /// edge in the column (every block at or above it is a sky source). -pub fn lowest_sky_source(world: &impl LightWorld, tables: &LightTables, x: i32, z: i32) -> i32 { +fn lowest_sky_source(world: &mut Region, tables: &LightTables, x: i32, z: i32) -> i32 { let mut y = MIN_Y + HEIGHT - 1; let Some(mut top) = world.state(x, y, z) else { return MIN_Y; @@ -739,34 +824,52 @@ pub fn relight( force: bool, changed: &mut LightChanges, ) { - relight_layer(world, tables, false, positions.to_vec(), force, changed); + let mut region = Region::new(world); + relight_region(&mut region, tables, sky_light, positions, force, changed); + region.finish(); +} +fn relight_region( + world: &mut Region, + tables: &LightTables, + sky_light: bool, + positions: &[Pos], + force: bool, + changed: &mut LightChanges, +) { + crate::spans::time("lt_block", || { + relight_layer(world, tables, false, positions.to_vec(), force, changed) + }); if sky_light { let mut seeds = positions.to_vec(); - let mut columns: HashMap<(i32, i32), i32> = HashMap::new(); - for &(x, _, z) in positions { - if columns.contains_key(&(x, z)) { - continue; - } - let source = lowest_sky_source(world, tables, x, z); - columns.insert((x, z), source); - // Cells whose source status changed: non-15 at or above the new - // lowest source, and old 15s (sources) below it. - for y in source..MIN_Y + HEIGHT { - if level(world, true, (x, y, z)).is_some_and(|l| l != 15) { + let mut columns: FxHashMap<(i32, i32), i32> = FxHashMap::default(); + crate::spans::time("lt_columns", || { + for &(x, _, z) in positions { + if columns.contains_key(&(x, z)) { + continue; + } + let source = lowest_sky_source(world, tables, x, z); + columns.insert((x, z), source); + // Cells whose source status changed: non-15 at or above the new + // lowest source, and old 15s (sources) below it. + for y in source..MIN_Y + HEIGHT { + if level(world, true, (x, y, z)).is_some_and(|l| l != 15) { + seeds.push((x, y, z)); + } + } + let mut y = source - 1; + while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) { seeds.push((x, y, z)); + y -= 1; } } - let mut y = source - 1; - while y >= MIN_Y && level(world, true, (x, y, z)) == Some(15) { - seeds.push((x, y, z)); - y -= 1; - } - } - relight_layer(world, tables, true, seeds, force, changed); + }); + crate::spans::time("lt_sky", || { + relight_layer(world, tables, true, seeds, force, changed) + }); } } fn relight_layer( - world: &mut impl LightWorld, + world: &mut Region, tables: &LightTables, sky: bool, seeds: Vec, @@ -809,42 +912,40 @@ fn relight_layer( } } // Sources inside the cleared area light up again. - let mut sources: HashMap<(i32, i32), i32> = HashMap::new(); - for p in cleared { - let seed = if sky { - let lowest = *sources - .entry((p.0, p.2)) - .or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2)); - if p.1 >= lowest { 15 } else { 0 } - } else { - world - .state(p.0, p.1, p.2) - .map_or(0, |s| tables.cells[s as usize].emission) - }; - if seed > level(world, sky, p).unwrap_or(15) { - set_level(world, sky, p, seed); - changed.push(p); - increase.push_back(p); + let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default(); + crate::spans::time("lt_sources", || { + for p in cleared { + let seed = if sky { + let lowest = *sources + .entry((p.0, p.2)) + .or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2)); + if p.1 >= lowest { 15 } else { 0 } + } else { + world + .state(p.0, p.1, p.2) + .map_or(0, |s| tables.cells[s as usize].emission) + }; + if seed > level(world, sky, p).unwrap_or(15) { + set_level(world, sky, p, seed); + changed.push(p); + increase.push_back(p); + } } - } + }); while let Some(p) = increase.pop_front() { - let Some(l) = level(world, sky, p) else { + let Some((from, packed)) = world.cell(p.0, p.1, p.2) else { continue; }; + let l = if sky { packed >> 4 } else { packed & 15 }; if l <= 1 { continue; } - let Some(from) = world.state(p.0, p.1, p.2) else { - continue; - }; for (d, (ox, oy, oz)) in OFFSETS.iter().enumerate() { let q = (p.0 + ox, p.1 + oy, p.2 + oz); - let Some(to) = world.state(q.0, q.1, q.2) else { - continue; - }; - let Some(ql) = level(world, sky, q) else { + let Some((to, packed)) = world.cell(q.0, q.1, q.2) else { continue; }; + let ql = if sky { packed >> 4 } else { packed & 15 }; let next = l.saturating_sub(tables.cells[to as usize].dampening.max(1)); if next > ql && !tables.blocked(from as usize, to as usize, d) { set_level(world, sky, q, next); @@ -915,10 +1016,10 @@ mod tests { lit.into_iter().collect() } fn sky(world: &HashMap<(i32, i32), Arc>, x: i32, y: i32, z: i32) -> u8 { - world.light(x, y, z).unwrap() >> 4 + world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) >> 4 } fn block(world: &HashMap<(i32, i32), Arc>, x: i32, y: i32, z: i32) -> u8 { - world.light(x, y, z).unwrap() & 15 + world[&(x >> 4, z >> 4)].light(x & 15, y, z & 15) & 15 } #[test] @@ -954,6 +1055,108 @@ mod tests { assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light"); } #[test] + fn relight_keeps_unchanged_and_unlit_sections() { + let mut w = world(|_| {}); + Arc::make_mut(w.get_mut(&(1, 0)).unwrap()).lit = false; + let before = w.clone(); + let same = |w: &HashMap<(i32, i32), Arc>, key| { + w[&key] + .sections + .iter() + .zip(&before[&key].sections) + .all(|(a, b)| Arc::ptr_eq(a, b)) + }; + // Cleared and lit back to the same values: nothing is stored. + relight( + &mut w, + &tables(), + true, + &[(8, 5, 8)], + false, + &mut LightChanges::default(), + ); + assert!(w.keys().all(|&key| same(&w, key))); + // Light reaching an unlit chunk isn't written into it. + Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(15, 0, 8, TORCH); + relight( + &mut w, + &tables(), + true, + &[(15, 0, 8)], + false, + &mut LightChanges::default(), + ); + assert!(same(&w, (1, 0))); + assert_eq!(block(&w, 14, 0, 8), 13); + } + #[test] + fn random_border_relights_match_full_relight() { + // Edits around every border of chunk (0, 0), at the top and bottom of + // the world, in batches; a held clone of the chunk must not change. + let mut w = world(|_| {}); + let held = w[&(0, 0)].clone(); + let mut rng = 0x9e37_79b9_u32; + let mut next = |n: usize| { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + rng as usize % n + }; + let xz = [-2, -1, 0, 1, 7, 14, 15, 16, 17]; + let ys = [ + MIN_Y, + MIN_Y + 1, + -2, + -1, + 0, + 1, + 15, + 16, + MIN_Y + HEIGHT - 2, + MIN_Y + HEIGHT - 1, + ]; + for _ in 0..60 { + let positions: Vec = (0..1 + next(6)) + .map(|_| (xz[next(xz.len())], ys[next(ys.len())], xz[next(xz.len())])) + .collect(); + for &(x, y, z) in &positions { + let state = [TORCH, SLAB, AIR, STONE][next(4)]; + Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state); + } + relight( + &mut w, + &tables(), + true, + &positions, + false, + &mut LightChanges::default(), + ); + } + // Every chunk, the outer ring lit as `world` lit it (no neighbours + // past the 3x3). + for (&(cx, cz), chunk) in &w { + let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| { + std::array::from_fn(|dx| { + w.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1)) + .map(|c| &**c) + }) + }); + for (si, s) in light_chunk(®ion, &tables(), true).iter().enumerate() { + for i in 0..4096 { + assert_eq!( + s.get(i), + chunk.sections[si].light(i), + "chunk {cx},{cz} section {si} index {i}" + ); + } + } + } + let fresh = world(|_| {}); + for (a, b) in held.sections.iter().zip(&fresh[&(0, 0)].sections) { + assert!((0..4096).all(|i| a.light(i) == b.light(i))); + } + } + #[test] fn incremental_relight_matches_full_relight() { let mut w = world(|c| { c.set(8, 0, 8, TORCH); @@ -1319,4 +1522,37 @@ mod tests { "fixture heap payload {payload} bytes; old worker clones it plus 24 section Arcs; old application clones current payload and 98304 returned light bytes. New worker clones zero section payload; new application clones current payload only when shared." ); } + /// Relight cost for torches placed and removed near chunk borders with + /// slabs flickering around them (fluids next to lava, in the game). + /// `cargo test --profile fast -p horizon-client relight_bench -- --ignored --nocapture` + #[test] + #[ignore] + fn relight_bench() { + let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect(); + let t = tables(); + let mut rng = 0x2545_f491_u32; + let mut next = |n: i32| { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + (rng % n as u32) as i32 + }; + let start = std::time::Instant::now(); + let mut changed = LightChanges::default(); + for round in 0..400 { + let state = [TORCH, SLAB, AIR][round % 3]; + let positions: Vec = (0..32) + .map(|_| (next(32) - 8, next(12), next(32) - 8)) + .collect(); + for &(x, y, z) in &positions { + Arc::make_mut(w.get_mut(&(x >> 4, z >> 4)).unwrap()).set(x & 15, y, z & 15, state); + } + relight(&mut w, &t, true, &positions, false, &mut changed); + } + println!( + "relight_bench: {:?}, {} sections", + start.elapsed(), + changed.sections().count() + ); + } } diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index 3b2b8fd31..1aeeec1e1 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -288,6 +288,9 @@ pub struct Menu { pub player_sounds: Vec<&'static str>, /// Recipes whose result was taken (`awardUsedRecipes`), for the caller. pub crafted: Vec, + /// Potions taken from a brewing stand's bottle slots + /// (`PotionSlot.onTake`), for the caller. + pub brewed: Vec, /// `LivingEntity.onEquipItem` calls that change the item: the /// `EquipmentSlot` name and the new stack, in order, for the caller. pub equipped: Vec<(&'static str, Option)>, @@ -683,6 +686,7 @@ impl Menu { sounds: Vec::new(), player_sounds: Vec::new(), crafted: Vec::new(), + brewed: Vec::new(), equipped: Vec::new(), merchant: None, stone: Stonecutter::default(), @@ -1504,7 +1508,17 @@ impl Menu { } /// `ResultSlot.onTake` and the other `Slot.onTake` overrides. - fn on_take(&mut self, env: &Env, s: usize) { + fn on_take(&mut self, env: &Env, s: usize, taken: &ItemStack) { + if self.slots[s].role == Role::Potion { + // BrewingStandMenu.PotionSlot.onTake: a stack with potion contents. + if matches!( + &*taken.item, + "minecraft:potion" | "minecraft:splash_potion" | "minecraft:lingering_potion" + ) { + self.brewed.push(taken.clone()); + } + return; + } if self.slots[s].role == Role::MerchantResult { self.merchant_take(env); return; @@ -1671,7 +1685,7 @@ impl Menu { max_amount: u32, ) -> Option { let mut result = self.try_remove(env, s, amount, max_amount)?; - self.on_take(env, s); + self.on_take(env, s, &result); if self.slots[s].role == Role::CartoResult { Self::map_crafted(env, &mut result); } @@ -2346,7 +2360,7 @@ impl Menu { return None; } if after_take || matches!(self.kind, Kind::Inventory | Kind::Crafting) { - self.on_take(env, index); + self.on_take(env, index, &clicked); } // What the result slot's move left over is dropped. if (index == 0 && matches!(self.kind, Kind::Inventory | Kind::Crafting)) @@ -2594,7 +2608,7 @@ impl Menu { }; if let Some(mut taken) = self.try_remove(env, s, amount, u32::MAX) { self.carried = Some(taken.clone()); - self.on_take(env, s); + self.on_take(env, s, &taken); if self.slots[s].role == Role::CartoResult { // onTake's onCraftedBy edits the carried stack. Self::map_crafted(env, &mut taken); @@ -2628,7 +2642,7 @@ impl Menu { if let Some(c) = self.carried.as_mut() { c.count += taken.count; } - self.on_take(env, s); + self.on_take(env, s, &taken); if self.slots[s].role == Role::CartoResult { // onCraftedBy edits the taken stack, not the carried one. Self::map_crafted(env, &mut taken); @@ -2656,7 +2670,7 @@ impl Menu { if self.may_pickup(env, target) { self.containers[PLAYER][inv_slot] = Some(taken.clone()); self.set_by_player(env, target, None); - self.on_take(env, target); + self.on_take(env, target, &taken); if self.slots[target].role == Role::CartoResult && let Some(stack) = self.containers[PLAYER][inv_slot].as_mut() { @@ -2684,14 +2698,14 @@ impl Menu { let part = split(&mut source, max); self.containers[PLAYER][inv_slot] = Some(source); self.set_by_player(env, target, Some(part)); - self.on_take(env, target); + self.on_take(env, target, &taken); if !self.inventory_add(env, &mut taken) { self.drop_stack(&taken); } } else { - self.containers[PLAYER][inv_slot] = Some(taken); + self.containers[PLAYER][inv_slot] = Some(taken.clone()); self.set_by_player(env, target, Some(source)); - self.on_take(env, target); + self.on_take(env, target, &taken); } } } @@ -3211,4 +3225,64 @@ pub(crate) mod tests { assert_eq!(menu.slot(0), stack("dirt", 1).as_ref()); }); } + + /// #1737: a Shift+Right-Click quick move in a chest (a double chest's + /// 54 slots) moves the stack and leaves the menu's state clean, so the + /// next quick move still moves its stack and the inventory still + /// answers. + #[test] + fn a_chest_quick_move_keeps_the_next_one_working() { + with_menu(Kind::Chest(6), |env, menu| { + menu.creative = false; + // The hotbar holds two stacks; the chest is empty. + menu.set_slot(env, 81, stack("cobblestone", 64)); + menu.set_slot(env, 82, stack("dirt", 32)); + // Shift+Right-Click on the hotbar: the stack goes into the chest. + menu.click(env, 81, 1, Input::QuickMove).unwrap(); + assert_eq!(menu.slot(81), None); + assert_eq!(menu.slot(0).map(|s| s.count), Some(64)); + // Nothing is left on the cursor and no quick craft is running. + assert_eq!(menu.carried, None); + assert_eq!(menu.quick_status, 0); + // The next quick move still works. + menu.click(env, 82, 1, Input::QuickMove).unwrap(); + assert_eq!(menu.slot(82), None); + assert_eq!(menu.slot(1).map(|s| s.count), Some(32)); + assert_eq!(menu.carried, None); + assert_eq!(menu.quick_status, 0); + }); + } + + /// #1832: a potion taken from the creative inventory and put in the + /// hotbar keeps its `PotionContents` (the brew), as vanilla's + /// `PotionContents.createItemStack` builds it: the item tab's stack is + /// the real one, and the hotbar slot holds it as it is. + #[test] + fn a_creative_potion_keeps_its_contents_in_the_hotbar() { + let snbt = r#"{components:{"minecraft:potion_contents":{potion:"minecraft:swiftness"}},count:1,id:"minecraft:potion"}"#; + let potion = crate::item_data::parse_stack(snbt).unwrap().unwrap(); + assert_eq!(potion.potion(), Some("minecraft:swiftness")); + // The creative screen hands the stack over... + let mut carried = None; + crate::creative::click_display( + &mut carried, + Some(&potion), + |_: &ItemStack| 1, + 0, + Input::Pickup, + ); + assert_eq!( + carried.as_ref().and_then(|s| s.potion()), + Some("minecraft:swiftness") + ); + // ... and the hotbar slot keeps it: it is not the uncraftable + // potion, which is a `minecraft:potion` with no contents at all. + with_menu(Kind::ItemPicker, |env, menu| { + menu.carried = carried; + menu.click(env, 45, 0, Input::Pickup).unwrap(); + let in_hand = menu.slot(45).expect("the hotbar slot"); + assert_eq!(in_hand.potion(), Some("minecraft:swiftness")); + assert_eq!(&*in_hand.item, "minecraft:potion"); + }); + } } diff --git a/crates/horizon-client/src/menu/bundle.rs b/crates/horizon-client/src/menu/bundle.rs index fba4a488e..78a52655a 100644 --- a/crates/horizon-client/src/menu/bundle.rs +++ b/crates/horizon-client/src/menu/bundle.rs @@ -1,7 +1,7 @@ //! `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}; +use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle, toggle_selected}; impl Menu { /// The carried stack's `overrideStackedOnOther`, then the clicked @@ -88,8 +88,11 @@ impl Menu { } } (carried, _) => { - // toggleSelectedItem(-1): Horizon keeps no selected item. + // toggleSelectedItem(-1), then the click goes on. self.carried = carried; + toggle_selected(&mut bundle, -1); + let slot = self.slots[s]; + self.containers[slot.container][slot.index] = Some(bundle); return false; } } diff --git a/crates/horizon-client/src/mesher.rs b/crates/horizon-client/src/mesher.rs index 48d2f08bd..7594e7e31 100644 --- a/crates/horizon-client/src/mesher.rs +++ b/crates/horizon-client/src/mesher.rs @@ -38,6 +38,8 @@ pub struct Vertex { pub light: u32, /// RGBA8_SNORM face normal; w = -127 for two-sided lighting, 0 to keep baked/unlit colour. pub normal: [i8; 4], + /// The `GLINT_TEXTURING` atlas UV for a foil item quad; zero elsewhere. + pub glint_uv: [f32; 2], } /// A section vertex: vanilla's `DefaultVertexFormat.BLOCK` (position, colour, /// UV0, UV2) with the sprite's array layer beside the light, in 24 bytes diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 00c21633e..f4ab3f834 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -25,6 +25,7 @@ pub mod creaking; pub mod creaking_heart; pub mod cube; pub mod data; +pub mod doors; pub mod drops; pub mod enderman; pub mod equine; @@ -49,6 +50,7 @@ pub mod nether; pub mod path; pub mod phantom; pub mod piglin; +pub mod polar_bear; pub mod rabbit; pub mod raider; pub mod ravager; @@ -64,10 +66,12 @@ pub mod spawning; pub mod species; pub mod strider; pub mod sulfur_cube; +pub mod sync; pub mod tame; pub mod trader; pub mod transport_items; pub mod trial_spawner; +pub mod turtle; pub mod undead; pub mod vex; pub mod villager; @@ -515,6 +519,8 @@ pub enum DamageKind { InFire, Lava, Drown, + /// `dry_out`: an axolotl out of water for too long. + DryOut, Magic, Sting, Cactus, @@ -522,6 +528,8 @@ pub enum DamageKind { InWall, Thorns, SonicBoom, + /// `freeze`: a fully frozen mob in powder snow. + Freeze, /// `fell_out_of_world`: below the world's floor. OutOfWorld, } @@ -573,7 +581,11 @@ impl Damage { pub fn causes_panic(&self) -> bool { !matches!( self.kind, - DamageKind::Fall | DamageKind::Drown | DamageKind::Generic | DamageKind::InWall + DamageKind::Fall + | DamageKind::Drown + | DamageKind::DryOut + | DamageKind::Generic + | DamageKind::InWall ) } /// DamageTypeTags.BYPASSES_ARMOR @@ -587,6 +599,7 @@ impl Damage { | DamageKind::InWall | DamageKind::Generic | DamageKind::SonicBoom + | DamageKind::Freeze | DamageKind::OutOfWorld ) } @@ -606,10 +619,12 @@ impl Damage { | DamageKind::InFire | DamageKind::Lava | DamageKind::Drown + | DamageKind::DryOut | DamageKind::Magic | DamageKind::Cactus | DamageKind::Generic | DamageKind::InWall + | DamageKind::Freeze | DamageKind::OutOfWorld ) } @@ -636,6 +651,11 @@ pub struct Attrs { pub struct Extra { /// Players: crouching and riding (HumanoidModel's poses). pub crouching: bool, + /// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which + /// sets its box (`Avatar.POSES`). + pub avatar_pose: crate::pose::Pose, + /// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise. + pub sprinting: bool, pub passenger: bool, /// AgeableMob.age: negative while a baby (a tadpole counts up to 24000). pub age: i32, @@ -665,6 +685,8 @@ pub struct Extra { pub chested: bool, /// The chest contents of a donkey or mule (15 slots). pub items: Vec>, + /// `Piglin.inventory` (`SimpleContainer(8)`): what it picked up and kept. + pub inventory: Vec, pub tame: bool, pub sitting: bool, /// The owner's UUID (tamed animals). @@ -715,6 +737,12 @@ pub struct Extra { pub vector: [f64; 3], /// Active status effects: (effect id, ticks left, amplifier). pub effects: Vec<(&'static str, i32, i32)>, + /// The effects above given with `hideParticles`: no swirls for them. + pub hidden_effects: Vec<&'static str>, + /// `LivingEntity.DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID` as a + /// server gave them: the swirls around it, and whether they are all faint. + pub effect_particles: Vec, + pub effect_ambient: bool, /// An invisible illusioner's mirror images. pub illusions: Option, /// A villager's claimed bed head and meeting bell. @@ -726,6 +754,8 @@ pub struct Extra { /// the spin the rope's torque gives it. pub leash: Option, pub leash_pending: Option, + /// The UUID of the player behind `Holder::Player` (host or guest). + pub leash_player: Option<[i32; 4]>, pub leash_spin: f64, /// More species scratch values. pub scratch: [i32; 4], @@ -759,8 +789,9 @@ pub struct Extra { pub drop_chances: Option>, /// `Mob.leftHanded`. pub left_handed: bool, - /// The sound variant: index in the species' `*_sound_variant` registry. - pub sound_variant: u8, + /// The sound variant: index in the species' `*_sound_variant` registry + /// (`finalize::sound_variants`); None is the default, `classic`. + pub sound_variant: Option, /// `Zombie.canBreakDoors`. pub can_break_doors: bool, /// A goat's `IsScreamingGoat` and the horns it lost (left, right). @@ -772,6 +803,8 @@ pub struct Extra { pub memories: Vec<(&'static str, i64, i64)>, /// A camel's `LAST_POSE_CHANGE_TICK`. pub pose_change_tick: i64, + /// `Entity.DATA_POSE` as a mob of a server has it (`sync::pose_id`). + pub pose: u8, /// The school leader a fish follows, and a tropical fish that swims alone. pub leader: Option, pub lone: bool, @@ -815,6 +848,9 @@ pub struct Mob { pub previous_swell: i32, pub ambient_timer: i32, pub fall_distance: f64, + /// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving + /// block has pushed this mob this tick (0.51 at most on any axis). + pub piston: horizon_world::piston_movement::PistonMovement, pub persistent: bool, /// `CustomName` (plain text) and `CustomNameVisible`. pub custom_name: Option, @@ -881,6 +917,18 @@ pub enum MobEvent { mob: u32, at: [f64; 3], }, + /// `LivingEntity.baseTick`'s drowning damage: entity event 67, the + /// clients' `makeDrownParticles`. + Drown { + mob: u32, + at: [f64; 3], + }, + /// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`. + EntityEvent { + mob: u32, + event: i8, + at: [f64; 3], + }, Sound(&'static str, [f64; 3], f32, f32), /// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the /// chest's opener count goes up or down (interaction range 3). @@ -904,6 +952,10 @@ pub enum MobEvent { slot: usize, stack: Option, }, + /// `InfestedMobEffect.onMobHurt`: the mob was hurt while infested. + Infested { + mob: u32, + }, /// `OfferFlowerGoal.stop`: the item goes to the target's antenna (saddle) /// slot if it is empty, as a guaranteed drop. Gift { @@ -961,6 +1013,12 @@ pub enum MobEvent { target: Who, velocity: [f64; 3], }, + /// `Entity.move(MoverType.SHULKER, delta)`: shift a target's position + /// (an opening shulker lifts what stands on its shell). + Shift { + target: Who, + delta: [f64; 3], + }, /// `EvokerWololoSpellGoal`: a blue sheep turns red. Wololo { mob: u32, @@ -973,7 +1031,17 @@ pub enum MobEvent { owner: u32, }, Shoot([f64; 3], [f64; 3]), - Explode([f64; 3], f32), + /// The mob the `Explode` right after this comes from + /// (`ServerExplosion.source`), and whether it is a charged creeper: the + /// exploding creeper is already out of the mob list when the game applies + /// the blast, so its victims' loot cannot look it up. + ExplosionSource { + mob: u32, + charged: bool, + }, + /// The centre, the radius and the kind of the mob blowing up (it may be + /// gone from the list by the time the blast is handled). + Explode([f64; 3], f32, Species), /// Melee or ranged damage to another mob. AttackMob { target: u32, @@ -1009,6 +1077,24 @@ pub enum MobEvent { pos: [i32; 3], open: bool, }, + /// `BreakDoorGoal.tick` at its end: the door is removed (`removeBlock`), + /// then level events 1021 and 2001 with the state that is left there. + BreakDoor { + pos: [i32; 3], + }, + /// `Level.destroyBlockProgress` for a mob's crack on a block (`-1` + /// clears it). + DestroyProgress { + mob: u32, + pos: [i32; 3], + progress: i32, + }, + /// `Level.levelEvent`: the game plays and sends the event. + LevelEvent { + id: i32, + pos: [i32; 3], + data: i32, + }, /// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock). BreakBlock { pos: [i32; 3], @@ -1019,8 +1105,11 @@ pub enum MobEvent { at: [f64; 3], count: u32, }, - /// Client particles about a mob's box (`particles::around_self`). + /// Client particles about a mob's box (`particles::around_self`) and the + /// entity event `event` that shows them, sent for mob `mob`. AroundSelf { + mob: u32, + event: i8, kind: &'static str, feet: [f64; 3], width: f64, @@ -1106,6 +1195,13 @@ pub enum MobEvent { partner: u32, child: Option<([i32; 3], Option)>, }, + /// `WorkAtComposter.useWorkstation`: a farmer at its composter makes + /// bread and composts seeds (the game applies it to the villager's + /// inventory and the block). + WorkAtComposter { + mob: u32, + pos: [i32; 3], + }, /// A villager asks whether enough neighbours want an iron golem. GolemCheck { mob: u32, @@ -1134,11 +1230,19 @@ pub enum MobEvent { target: Who, aggressive: bool, }, - /// Drop the loot of a gift table (chicken eggs, armadillo scutes). + /// Drop the loot of a gift table (chicken eggs, sniffer seeds) with the + /// mob as `this` (`LivingEntity.dropFromGiftLootTable`). GiftLoot { + mob: u32, table: &'static str, at: [f64; 3], }, + /// `Armadillo.customServerAiStep`: the armadillo sheds a scute. + ShedScute { + mob: u32, + at: [f64; 3], + pitch: f32, + }, /// A lead snaps or loses its holder: the lead item drops when asked. LeashBreak { mob: u32, @@ -1159,6 +1263,18 @@ pub enum MobEvent { DigParticles { at: [f64; 3], }, + /// `Zombie.hurtServer`: a zombie hurt with a target in sight (its own, + /// else the attacker) may call a reinforcement against it on Hard. + Reinforcements { + mob: u32, + target: Who, + }, + /// `LevelEvent.PARTICLES_DESTROY_BLOCK` (2001) at `pos` for the block at + /// `block`: a turtle's digging. + BlockBreakEffect { + pos: [i32; 3], + block: [i32; 3], + }, /// `Warden.applyDarknessAround`: darkness for survival players nearby. Darkness { at: [f64; 3], @@ -1231,6 +1347,8 @@ pub struct Seen { pub active_raid: bool, /// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`). pub opened_chest: Option<[i32; 3]>, + /// `isPassenger`. + pub passenger: bool, /// A villager's reputation of the player (`getPlayerReputation`). pub player_rep: i32, /// `isPushable` and pushed by others (`Entity.push(Entity)` moves it). @@ -1251,6 +1369,18 @@ pub struct Seen { /// `MAX_HEALTH`, `JUMP_STRENGTH` and `MOVEMENT_SPEED` base values (a /// horse's breeding stock). pub stats: [f64; 3], + /// `Wolf.isInSittingPose`. + pub sitting: bool, + /// `Entity.isVehicle` or `isPassenger`. + pub ridden: bool, + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, +} +impl Seen { + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } } impl Mob { pub fn burning_time(&self) -> f64 { @@ -1267,6 +1397,10 @@ impl Mob { .max((f64::from(ticks) * self.burning_time()).ceil() as i32); self.ticks_frozen = 0; } + /// `Entity.isFullyFrozen`. + pub fn is_fully_frozen(&self) -> bool { + self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE + } /// What other mobs see of this one this tick (`player`: the player's /// UUID, which villagers' reputation is keyed by). @@ -1306,6 +1440,7 @@ impl Mob { hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)), active_raid: raider::has_active_raid(m), opened_chest: m.extra.opened_chest, + passenger: m.extra.mount.is_some(), player_rep: match (villager, player) { (true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)), _ => 0, @@ -1323,6 +1458,14 @@ impl Mob { m.extra.vector[0], m.attrs.speed, ], + sitting: m.extra.sitting, + ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(), + effects: m + .extra + .effects + .iter() + .map(|e| e.0.trim_start_matches("minecraft:")) + .collect(), } } /// `EntityType.getCategory`'s name. @@ -1456,6 +1599,8 @@ pub struct PlayerView { pub off_hand: Option>, pub head: Option>, pub armor_cover: f32, + /// The Invisibility effect (`isInvisible`). + pub invisible: bool, pub spectator: bool, /// `PiglinAi.isWearingSafeArmor`: some golden armour. pub gold_armor: bool, @@ -1467,6 +1612,8 @@ pub struct PlayerView { pub yaw: f32, /// The Hero of the Village effect. pub hero: bool, + /// The active effects, without namespace (`slowness`, `poison`...). + pub effects: Vec<&'static str>, /// The player's UUID (villagers' gossip is keyed by it). pub uuid: [i32; 4], /// The mobs on the player's team (`isAlliedTo`): combat targeting @@ -1475,12 +1622,33 @@ pub struct PlayerView { /// A LAN guest: its own client moves it, and touching mobs push it there /// (`EntitySelector.pushableBy` on the client). pub remote: bool, + /// `isPassenger`. + pub passenger: bool, +} +impl PlayerView { + /// `LivingEntity.getVisibilityPercent` without the worn-skull scaling. + pub fn visibility_percent(&self) -> f64 { + let mut visibility = 1.0; + if self.sneaking { + visibility *= 0.8; + } + if self.invisible { + visibility *= 0.7 * f64::from(self.armor_cover).max(0.1); + } + visibility + } + /// `LivingEntity.hasEffect`, by id without namespace. + pub fn has_effect(&self, effect: &str) -> bool { + self.effects.contains(&effect) + } } #[derive(Clone)] pub struct MobWorld<'a> { pub world: &'a World, pub registry: &'a horizon_world::registry::BlockRegistry, pub facts: &'a BlockFacts, + /// Fluid states and flow facts, for the currents that push mobs. + pub fluid_tables: &'a crate::motion::FluidResources, pub movement: &'a horizon_world::movement_properties::BoundMovementProperties, pub collision: &'a BlockCollisionRegistry<'a>, pub data: &'a MobData, @@ -1503,6 +1671,8 @@ pub struct MobWorld<'a> { pub biomes: &'a crate::biome::Biomes, /// The `mob_griefing` game rule. pub mob_griefing: bool, + /// The `mob_drops` game rule (`shouldDropLoot`). + pub mob_drops: bool, /// `getCurrentDifficultyAt` near the player. pub difficulty: spawning::LocalDifficulty, /// The End (its dimension type's monster spawn light rules). @@ -1516,6 +1686,8 @@ pub struct MobWorld<'a> { pub teams: std::sync::Arc>, /// Item data (max stack sizes), when the host has it. pub items: Option<&'a crate::items::Items>, + /// `ChestBlockEntity.getOpenCount`: the players with each chest open. + pub lid_openers: &'a HashMap<(i32, i32, i32), i32>, } /// `Entity.getTeam` of the player and of the mobs on a team. #[derive(Clone, Debug, Default)] @@ -1569,6 +1741,7 @@ impl Mob { // Mob.ambientSoundTime starts at 0. ambient_timer: 0, fall_distance: 0.0, + piston: Default::default(), persistent: false, random, ai: Box::default(), @@ -1729,6 +1902,15 @@ impl Mob { { amount *= 0.75; } + // LivingEntity.hurtServer: `#freeze_hurts_extra_types` take five times. + if source.kind == DamageKind::Freeze + && matches!( + self.species(), + Species::Strider | Species::Blaze | Species::MagmaCube + ) + { + amount *= 5.0; + } let (worn, worn_toughness) = self.worn_armor(); if !source.bypasses_armor() && self.kind.armor + worn > 0.0 { // CombatRules.getDamageAfterAbsorb @@ -1760,6 +1942,10 @@ impl Mob { self.health -= amount; self.hurt_time = 10; } + // Animal.actuallyHurt: damage taken ends love mode (`resetLove`). + if drops::is_animal(self.species()) { + self.extra.in_love = 0; + } self.last_damage = Some((source, i64::from(self.age))); if let Some(who) = source.attacker { self.ai.last_hurt_by = Some(who); @@ -1786,7 +1972,7 @@ impl Mob { let dead = !self.alive(); let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }]; if !dead { - self.ambient_timer = -self.kind.ambient_interval; + self.ambient_timer = -self.ambient_interval(); } if !sound.is_empty() { let pitch = self.voice_pitch(); @@ -1797,6 +1983,15 @@ impl Mob { } } } + if self.has_effect("minecraft:infested") { + self.outbox.push(MobEvent::Infested { mob: self.id }); + } + // Squid.hurtServer: a squid hurt after a living entity hit it squirts ink. + if matches!(self.species(), Species::Squid | Species::GlowSquid) + && self.ai.last_hurt_by.is_some() + { + aquatic::spawn_ink(self); + } true } /// LivingEntity.knockback(power, xd, zd) towards the negative direction. @@ -1847,7 +2042,8 @@ impl Mob { /// Movement speed attribute bonus (baby zombies, sprinting modifiers). /// MOVEMENT_SPEED including effects (speed +20%/level, slowness -15%/level). pub fn movement_speed(&self) -> f64 { - let mut v = strider::movement_speed(self, self.attrs.speed) * self.speed_bonus(); + let mut v = strider::movement_speed(self, witch::speed(self, self.attrs.speed)) + * self.speed_bonus(); for (e, _, amp) in &self.extra.effects { match *e { "minecraft:speed" => v *= 1.0 + 0.2 * f64::from(amp + 1), @@ -1860,10 +2056,22 @@ impl Mob { pub fn has_effect(&self, effect: &str) -> bool { self.extra.effects.iter().any(|e| e.0 == effect) } + /// `LivingEntity.isCurrentlyGlowing`: the Glowing effect or the `Glowing` tag (on a + /// server's client, `glowing` holds shared flag 6 instead). + pub fn is_currently_glowing(&self) -> bool { + self.glowing || self.has_effect("minecraft:glowing") + } pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) { if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) { - e.1 = e.1.max(ticks); - e.2 = e.2.max(amplifier); + // MobEffectInstance.update: a stronger effect replaces with its + // own duration, an equal one only extends it; a weaker one is + // ignored (vanilla keeps it hidden behind the active one). + if amplifier > e.2 { + e.1 = ticks; + e.2 = amplifier; + } else if amplifier == e.2 && e.1 != -1 && (ticks == -1 || e.1 < ticks) { + e.1 = ticks; + } } else { self.extra.effects.push((effect, ticks, amplifier)); } @@ -1882,10 +2090,30 @@ impl Mob { let at = [self.position.x, self.eye_y(), self.position.z]; events.push(MobEvent::Sound(event, at, 1.0, 1.0)); } + /// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long + /// while it carries a passenger. + pub fn ambient_interval(&self) -> i32 { + let ridden = self.rider.is_some() || self.extra.rider.is_some(); + if self.species() == Species::HappyGhast && ridden { + self.kind.ambient_interval * 6 + } else { + self.kind.ambient_interval + } + } /// LivingEntity.getVoicePitch. pub fn voice_pitch(&mut self) -> f32 { + // HappyGhast.getVoicePitch: always 1.0, babies and all. + if self.species() == Species::HappyGhast { + return 1.0; + } + // ZombieVillager.getVoicePitch: babies are 2.0, not 1.5. + let baby = if self.species() == Species::ZombieVillager { + 2.0 + } else { + 1.5 + }; let pitch = (self.random.next_float() - self.random.next_float()) * 0.2 - + if self.is_baby() { 1.5 } else { 1.0 }; + + if self.is_baby() { baby } else { 1.0 }; // Bat.getVoicePitch: a little lower. if self.species() == Species::Bat { pitch * 0.95 @@ -1996,6 +2224,7 @@ pub fn render_model( layer: overlay, color: 0xFFFF_FFFF, light, + glint_uv: [0.0, 0.0], }); } } @@ -2130,7 +2359,7 @@ mod lighting_tests { #[test] fn packed_normal_uses_native_snorm_bytes_without_changing_vertex_offsets() { assert_eq!(std::mem::offset_of!(Vertex, normal), 32); - assert_eq!(std::mem::size_of::(), 36); + assert_eq!(std::mem::size_of::(), 44); let part = plane([0.0, 1.0, 0.0]); assert_eq!( part.polygons[0].vertex_normal(ModelNormals::new(Mat4::from_scale(Vec3::ZERO))), diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 2676c3a37..ad130ea68 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -106,6 +106,7 @@ pub enum Family { Strider, Bat, Bee, + PolarBear, } pub fn family(s: Species) -> Family { use Species as S; @@ -167,6 +168,7 @@ pub fn family(s: Species) -> Family { S::Strider => Family::Strider, S::Bat => Family::Bat, S::Bee => Family::Bee, + S::PolarBear => Family::PolarBear, _ => Family::Generic, } } @@ -183,6 +185,35 @@ pub fn register(m: &mut Mob) { if m.species() == Species::Rabbit { super::rabbit::register(m); } + if m.species() == Species::Frog { + // `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true). + // The speed attribute is 1.0, so the controller's scaling is + // what keeps a frog at a walking pace on land. + m.ai.mov.kind = super::control::MoveKind::SmoothSwimming { + max_turn_x: 85.0, + max_turn_y: 10.0, + in_water: 0.02, + outside: 0.1, + gravity: true, + }; + } + if m.species() == Species::Turtle { + super::turtle::register(m); + } + if m.species() == Species::Frog { + // `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true). + // The frog's movement speed is 1.0, so the controller's 0.1 + // outside-water speed is what keeps a walking frog at + // vanilla's pace; the plain ground controller walks ten + // times too fast (#2213). + m.ai.mov.kind = super::control::MoveKind::SmoothSwimming { + max_turn_x: 85.0, + max_turn_y: 10.0, + in_water: 0.02, + outside: 0.1, + gravity: true, + }; + } } Family::Inert => {} Family::Witch => super::witch::register(m), @@ -224,6 +255,7 @@ pub fn register(m: &mut Mob) { Family::Strider => super::strider::register(m), Family::Bat => super::bat::register(m), Family::Bee => super::bee::register(m), + Family::PolarBear => super::polar_bear::register(m), } // FloatGoal's constructor lets the navigation float, as do the // constructors of these brain-driven and flying mobs. A floater rises @@ -301,12 +333,30 @@ fn generic(m: &mut Mob) { m.ai.targets.add(1, HurtByTarget::new()); m.ai.targets.add(2, NearestTarget::player(true)); } else { + if m.kind.species == Species::Goat { + // GoatAi's idle and core behaviours (the ram and long jump + // activities are not here): panic 2.0, AnimalMakeLove, tempting + // with goat_food and baby follow at 1.25, then stroll. + g.add(0, Float); + g.add(1, Panic::new(2.0)); + g.add(2, Breed::new(1.0)); + g.add(3, Tempt::new(1.25, m.kind.food.clone(), false)); + g.add(4, FollowParent::new(1.25)); + g.add(5, Stroll::avoiding_water(1.0)); + g.add(6, LookAtPlayer::new(6.0)); + g.add(7, RandomLookAround::default()); + return; + } g.add(0, Float); let rabbit = m.kind.species == Species::Rabbit; g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 })); g.add(5, Stroll::avoiding_water(if rabbit { 0.6 } else { 1.0 })); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); + if m.kind.species == Species::Armadillo { + // Armadillo's constructor: pickNextScuteDropTime. + m.extra.egg_time = next_scute_time(m); + } } } /// Subclasses of `net.minecraft.world.entity.monster.Monster`. @@ -413,6 +463,7 @@ pub fn tick( super::axolotl::tick_animation(m); m.anim.end_tick(m.ai.swinging); tick_swing(m); + flower_contact_effects(m, w); if m.species() == Species::CopperGolem { // CopperGolem.tick: updateWeathering after the living tick. super::copper_golem::update_weathering(m, w, level_random, events); @@ -441,6 +492,36 @@ pub fn tick_swing(m: &mut Mob) { } m.anim.attack_anim = m.ai.swing_time as f32 / 6.0; } +/// `Entity.isInRain`: rain at the block position, or at the top of the box. +pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool { + let (x, y, z) = ( + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ); + let top = (m.position.y + f64::from(m.height())).floor() as i32; + w.raining_at(x, y, z) || w.raining_at(x, top, z) +} +/// `LivingEntity.baseTick` refills a breather's air by 4, then +/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills +/// it, anywhere else it runs down from the pre-tick value, and at -20 the +/// axolotl dries out. +fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) { + let before = m.air; + m.air = (m.air + 4).min(super::axolotl::MAX_AIR); + if m.no_ai { + return; + } + if m.fluids.in_water || in_rain(m, w) { + m.air = super::axolotl::MAX_AIR; + } else { + m.air = before - 1; + if m.air <= -20 { + m.air = 0; + m.damage(2.0, Damage::of(DamageKind::DryOut)); + } + } +} /// Entity.baseTick + LivingEntity.baseTick + Mob.baseTick. fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.was_eye_in_water = m.fluids.eye_in_water; @@ -459,8 +540,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { ); m.damage(fire, Damage::of(DamageKind::InFire)); } - // Fire: water extinguishes, lava ignites, burning hurts every second. - if m.fluids.in_water && m.fire_ticks > 0 { + // Fire: water or rain (Entity.isInRain) extinguishes, lava ignites, + // burning hurts every second. + if m.fire_ticks > 0 && (m.fluids.in_water || in_rain(m, w)) { m.fire_ticks = 0; events.push(MobEvent::Sound( "entity.generic.extinguish_fire", @@ -489,18 +571,25 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } m.first_tick = false; if m.alive() { - // LivingEntity.baseTick: suffocation inside full blocks. + // LivingEntity.baseTick: suffocation inside full blocks. Entity.isInWall + // is false under noPhysics, which Vex.tick holds while it ticks. let (ex, ey, ez) = ( m.position.x.floor() as i32, m.eye_y().floor() as i32, m.position.z.floor() as i32, ); let at_eye = w.facts.get(w.world.block(ex, ey, ez)); - if at_eye.full_collision && at_eye.solid_render && m.width() < 1.0 { + if at_eye.full_collision + && at_eye.solid_render + && m.width() < 1.0 + && m.species() != Species::Vex + { m.damage(1.0, Damage::of(DamageKind::InWall)); } // Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs). - if m.kind.flags.needs_water { + if m.species() == Species::Axolotl { + axolotl_air_supply(m, w); + } else if m.kind.flags.needs_water { if !m.fluids.in_water { m.air -= 1; if m.air == -20 { @@ -514,17 +603,20 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.air -= 1; if m.air == -20 { m.air = 0; + events.push(MobEvent::Drown { + mob: m.id, + at: [m.position.x, m.position.y, m.position.z], + }); m.damage(2.0, Damage::of(DamageKind::Drown)); } } else if m.air < 300 { m.air = (m.air + 4).min(300); } - if m.kind.flags.water_sensitive - && (m.fluids.in_water || w.raining && w.can_see_sky(ex, ey, ez)) - { + if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) { m.damage(1.0, Damage::of(DamageKind::Drown)); } + freeze_tick(m, w); } tick_effects(m); if m.species() == Species::Illusioner { @@ -547,13 +639,64 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { let t = m.ambient_timer; m.ambient_timer += 1; if roll < t { - m.ambient_timer = -m.kind.ambient_interval; + m.ambient_timer = -m.ambient_interval(); play_ambient(m, w, events); } } // Entity.baseTick ends with Leashable.tickLeash. super::leash::tick(m, w, events); } +/// `LivingEntity.aiStep`'s "freezing" with `InsideBlockEffectType.FREEZE`: +/// powder snow builds `ticks_frozen` up, anywhere else it melts twice as +/// fast, and a fully frozen mob takes one freeze damage every 40 ticks. +fn freeze_tick(m: &mut Mob, w: &MobWorld) { + let in_snow = in_powder_snow(m, w); + if !in_snow && m.ticks_frozen == 0 { + return; + } + let can_freeze = can_freeze(m, w); + if in_snow && can_freeze { + m.ticks_frozen = + (m.ticks_frozen + 1).min(crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE); + } else { + m.ticks_frozen = (m.ticks_frozen - 2).max(0); + } + if m.age.is_multiple_of(40) && m.is_fully_frozen() && can_freeze { + m.damage(1.0, Damage::of(DamageKind::Freeze)); + } +} +/// Whether the mob's box (shrunk by `1.0E-5`, as in `Entity.checkInsideBlocks`) +/// touches powder snow. +fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool { + let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5)); + let (min, max) = (b.min_position(), b.max_position()); + for x in min.x.floor() as i32..=max.x.floor() as i32 { + for y in min.y.floor() as i32..=max.y.floor() as i32 { + for z in min.z.floor() as i32..=max.z.floor() as i32 { + if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" { + return true; + } + } + } + } + false +} +/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no +/// worn piece in `#freeze_immune_wearables`. +fn can_freeze(m: &Mob, w: &MobWorld) -> bool { + if w.data + .entity_has("minecraft:freeze_immune_entity_types", &m.kind.id) + { + return false; + } + let immune = w + .items + .and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables")); + ![&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet] + .into_iter() + .flatten() + .any(|item| immune.is_some_and(|t| t.contains(&**item))) +} /// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`. pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec) { play_ambient(m, w, events); @@ -641,7 +784,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & } // AbstractHorse.aiStep: the tail swishes now and then (moveTail). if super::equine::is_equine(m.species()) { - let _ = m.random.next_int_bound(200); + super::equine::tail_tick(m); } // Monster.aiStep: updateNoActionTime (Raider: always +2). if is_monster(m.species()) @@ -668,6 +811,9 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & // serverAiStep has no goals, so it only falls and is pushed. let immobile = (m.species() == Species::Ravager && super::ravager::immobile(m)) || m.species() == Species::Mannequin; + if m.species() == Species::Piglin && m.alive() { + super::piglin::celebrate(m, w); + } if m.alive() && !immobile { server_ai_step(m, w, level_random, events); } else { @@ -712,6 +858,14 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & // setAge at the age boundary: refreshDimensions. if m.extra.age >= 0 { m.fudge_position_after_size_change(w, bw, bh); + // Turtle.ageBoundaryReached: a grown turtle drops a scute. + if m.species() == Species::Turtle { + events.push(MobEvent::GiftLoot { + mob: m.id, + table: "minecraft:gameplay/turtle_grow", + at: [m.position.x, m.position.y, m.position.z], + }); + } } } else if m.extra.age > 0 { m.extra.age -= 1; @@ -746,7 +900,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & if m.kind.flags.burns_in_day && m.alive() && sun_burn_tick(m, w) && m.extra.head.is_none() { m.ignite_for_ticks(160); } - species_ai_step(m, w, events); + species_ai_step(m, w, level_random, events); } impl Mob { /// LivingEntity.isPushable (and the push it takes from `Entity.push(Entity)`): @@ -847,7 +1001,7 @@ fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool { m.position.z.floor() as i32, ); let br = w.brightness(x, y, z); - let wet = m.fluids.in_water || w.raining && w.can_see_sky(x, y, z); + let wet = m.fluids.in_water || in_rain(m, w); br > 0.5 && m.random.next_float() * 30.0 < (br - 0.4) * 2.0 && !wet && w.can_see_sky(x, y, z) } /// Mob.serverAiStep @@ -930,9 +1084,31 @@ fn server_ai_step( } m.jump_control_tick(); } +/// `Armadillo.pickNextScuteDropTime`: 5 to 10 minutes. +fn next_scute_time(m: &mut Mob) -> i32 { + m.random.next_int_bound(20 * 60 * 5).unwrap_or(0) + 20 * 60 * 5 +} +/// `Armadillo.customServerAiStep`: the scute timer keeps counting down and is +/// only reset once `shouldDropLoot` (not a baby, `mob_drops` on) lets it shed. +fn armadillo_server_ai_step(m: &mut Mob, mob_drops: bool, events: &mut Vec) { + if !m.alive() { + return; + } + m.extra.egg_time -= 1; + if m.extra.egg_time <= 0 && !m.is_baby() && mob_drops { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + events.push(MobEvent::ShedScute { + mob: m.id, + at: [m.position.x, m.position.y, m.position.z], + pitch, + }); + m.extra.egg_time = next_scute_time(m); + } +} fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { match m.species() { Species::Rabbit => super::rabbit::server_ai_step(m), + Species::Armadillo => armadillo_server_ai_step(m, cx.w.mob_drops, cx.events), Species::Sheep => {} Species::Zombie | Species::Husk @@ -944,6 +1120,7 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { | Species::Parched | Species::WitherSkeleton | Species::ZombifiedPiglin => undead::custom_server_ai_step(m, cx), + Species::Vindicator => super::illager::vindicator_server_ai_step(m), Species::Enderman => super::enderman::custom_server_ai_step(m, cx), Species::Blaze => super::nether::blaze_server_ai_step(m, cx), Species::ElderGuardian => super::guardian::elder_server_ai_step(m, cx), @@ -955,7 +1132,12 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { _ => {} } } -fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +fn species_ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if family(m.species()) == Family::Aquatic { aquatic::ai_step(m, w, events); } @@ -978,6 +1160,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { Species::Wither => super::wither::ai_step(m, w, events), Species::Allay => super::allay::ai_step(m), Species::Bee => super::bee::ai_step(m, w), + Species::PolarBear => super::polar_bear::ai_step(m, w), Species::Breeze => super::breeze::ai_step(m), Species::Sniffer => super::sniffer::ai_step(m, events), Species::HappyGhast => super::happy_ghast::ai_step(m, w), @@ -988,7 +1171,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Shulker => { super::shulker::tick(m, w, events); - super::shulker::ai_step(m, w, events); + super::shulker::ai_step(m, w, level_random, events); } Species::SnowGolem => super::golem::snow_ai_step(m, w, events), Species::CopperGolem => super::copper_golem::ai_step(m, w, events), @@ -999,6 +1182,7 @@ fn species_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events), Species::Chicken => farm::chicken_ai_step(m, w, events), + Species::Turtle => super::turtle::ai_step(m, w, events), Species::Sheep if m.extra.anim_ticks > 0 => { m.extra.anim_ticks -= 1; } @@ -1017,6 +1201,19 @@ pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool { } true } +/// `Mob.requiresCustomPersistence` and its overrides: a passenger or a leashed +/// mob, a raider in a raid, an enderman carrying a block, a tame nautilus, a +/// sulfur cube holding a block or let out of a bucket. The spawn caps do not +/// count these mobs either (`NaturalSpawner.createState`). +pub fn requires_custom_persistence(m: &Mob) -> bool { + super::riders::keeps_alive(m) + || m.extra.leash.is_some() + || super::raider::state(m).is_some_and(|s| s.raid_id.is_some()) + || m.species() == Species::Enderman && m.extra.carried.is_some() + || super::nautilus::is_nautilus(m.species()) && m.extra.tame + || m.species() == Species::SulfurCube + && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0) +} /// Mob.checkDespawn, before the tick (players are the only despawn /// reference). Returns false when the mob is discarded. fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { @@ -1032,12 +1229,7 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool { return true; }; let d = m.distance_sqr(p); - // requiresCustomPersistence: raiders in a raid, a tame nautilus, a sulfur - // cube holding a block or let out of a bucket. - let persistent = m.persistent - || super::nautilus::is_nautilus(m.species()) && m.extra.tame - || m.species() == Species::SulfurCube - && (super::sulfur_cube::has_body(m) || m.extra.scratch[3] != 0); + let persistent = m.persistent || requires_custom_persistence(m); // removeWhenFarAway: animals, villagers, golems, patrols and raiders, // zombie villagers being cured and ridden mobs stay. let creature = m.kind.category == "creature"; @@ -1324,6 +1516,11 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) { Species::Warden => super::warden::hurt(m, source), Species::Wolf => super::tame::after_hurt(m, source), Species::Nautilus | Species::ZombieNautilus => super::nautilus::after_hurt(m, source), + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin => super::undead::after_hurt(m, source), // NeutralMob: an iron golem hit by the player stays angry a while. // PiglinAi.wasHurtBy: drop what is being admired, no admiring for // a while after a player's hit, and anger. @@ -1464,30 +1661,89 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec = Vec::new(); + for at in [m.previous, m.position] { + let cells = |lo: f64, hi: f64| lo.floor() as i32..=hi.floor() as i32; + for x in cells(at.x - half, at.x + half) { + for y in cells(at.y + 1.0e-5, at.y + height) { + for z in cells(at.z - half, at.z + half) { + let p = [x, y, z]; + if visited.contains(&p) { + continue; + } + match w.block_name(x, y, z) { + "minecraft:wither_rose" => { + // isInvulnerableTo(wither): the invulnerable time + // after a hit; WitherBoss and WitherSkeleton + // refuse the effect (`canBeAffected`). + if m.invulnerable <= 0 + && !m.invulnerable_flag + && !matches!(m.species(), Species::Wither | Species::WitherSkeleton) + { + m.add_effect("minecraft:wither", 40, 0); + } + } + "minecraft:open_eyeblossom" + if m.species() == Species::Bee + && super::bee::attracts_bees(w, p) + && !m.has_effect("minecraft:poison") => + { + m.add_effect("minecraft:poison", 25, 0); + } + _ => continue, + } + visited.push(p); + } + } + } + } +} + /// MobEffect ticking for the effects mobs receive (poison, wither, regeneration). fn tick_effects(m: &mut Mob) { let mut effects = std::mem::take(&mut m.extra.effects); for (effect, ticks, amp) in &mut effects { let interval = |base: i32| (base >> (*amp).max(0)).max(1); + // MobEffectInstance.tickServer: an infinite (-1) effect times its + // ticks by the mob's tickCount and never counts down. + let infinite = *ticks == -1; + let count = if infinite { m.age as i32 } else { *ticks }; match *effect { "minecraft:poison" - if *ticks % interval(25) == 0 && !m.kind.flags.undead && m.health > 1.0 => + if count % interval(25) == 0 && !m.kind.flags.undead && m.health > 1.0 => { m.damage(1.0, Damage::of(DamageKind::Magic)); } - "minecraft:wither" if *ticks % interval(40) == 0 => { + "minecraft:wither" if count % interval(40) == 0 => { m.damage(1.0, Damage::of(DamageKind::Magic)); } "minecraft:regeneration" - if *ticks % interval(50) == 0 && !m.kind.flags.undead && m.alive() => + if count % interval(50) == 0 && !m.kind.flags.undead && m.alive() => { m.health = (m.health + 1.0).min(m.attrs.max_health); } _ => {} } - *ticks -= 1; + if !infinite { + *ticks -= 1; + } } - effects.retain(|e| e.1 > 0); + effects.retain(|e| e.1 > 0 || e.1 == -1); + m.extra + .hidden_effects + .retain(|h| effects.iter().any(|e| e.0 == *h)); m.extra.effects = effects; } @@ -1568,8 +1824,20 @@ pub fn step_sound( S::Husk => quiet("entity.husk.step"), S::Drowned => quiet("entity.drowned.step"), S::ZombieVillager => quiet("entity.zombie_villager.step"), - S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)), - S::Camel => Some(("entity.camel.step", 1.0, 1.0)), + // Camel.playStepSound and CamelHusk.playStepSound: sand and concrete + // powder have their own step. + S::CamelHusk | S::Camel => { + let sand = w.has_tag( + "minecraft:camel_sand_step_sound_blocks", + &w.facts.get(state).name, + ); + Some(match (m.species(), sand) { + (S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0), + (S::Camel, false) => ("entity.camel.step", 1.0, 1.0), + (_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0), + (_, false) => ("entity.camel_husk.step", 0.4, 1.0), + }) + } S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)), // CopperGolem.playStepSound: its oxidation level's step. S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)), @@ -1648,3 +1916,41 @@ mod despawn_tests { } } } + +#[cfg(test)] +mod frog_tests { + use super::*; + use crate::mobs::MobTypes; + use crate::mobs::control::MoveKind; + use std::sync::Arc; + + /// `Frog()`: `new SmoothSwimmingMoveControl<>(this, 85, 10, 0.02F, 0.1F, true)`. + /// The frog's movement speed is 1.0, so the controller's 0.1 outside-water + /// speed is what keeps a walking frog at vanilla's pace. The plain ground + /// controller walks it ten times too fast, which is the jitter, the + /// teleporting and the flying of #2213. + #[test] + fn a_frog_walks_at_the_vanilla_speed() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("entity-types.json").exists() { + return; + } + let data = MobTypes::load(&root).unwrap(); + let kind = Arc::new(data.types.get("minecraft:frog").unwrap().clone()); + assert_eq!(kind.speed, 1.0, "the frog's movement speed attribute"); + let mut m = Mob::new(1, kind, Vec3::new(0.0, 4.0, 0.0), 0.0); + register(&mut m); + assert_eq!( + m.ai.mov.kind, + MoveKind::SmoothSwimming { + max_turn_x: 85.0, + max_turn_y: 10.0, + in_water: 0.02, + outside: 0.1, + gravity: true, + } + ); + } +} diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index b81f224ae..a4c27960e 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -197,7 +197,8 @@ pub fn state(m: &Mob, alpha: f32) -> State { let walking = m.alive() && !m.extra.passenger; State { pos: if walking { - m.limb_position - m.limb_speed * (1.0 - alpha) + // WalkAnimationState.position(partial): babies' legs move 3 times as far. + (m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 } } else { 0.0 }, @@ -248,18 +249,24 @@ pub enum ArmPose { pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) { humanoid(p, s, &Hum::new(ArmPose::Empty, ArmPose::Empty)); } -/// `AnimationUtils.swingWeaponDown` with the right arm as the main arm. -pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32) { +/// `AnimationUtils.swingWeaponDown`: the main arm swings down, the other +/// one hangs and bobs. +pub(super) fn swing_weapon_down(p: &mut Poses, swing: f32, age: f32, left_main: bool) { let attack2 = (swing * PI).msin(); let attack = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin(); + let (main, off) = if left_main { + ("left_arm", "right_arm") + } else { + ("right_arm", "left_arm") + }; p.zrot("right_arm", 0.0); p.zrot("left_arm", 0.0); p.yrot("right_arm", PI / 20.0); p.yrot("left_arm", -PI / 20.0); - p.xrot("right_arm", -1.884_955_8 + (age * 0.09).mcos() * 0.15); - p.xrot("left_arm", -0.0 + (age * 0.19).mcos() * 0.5); - p.get("right_arm")[3] += attack2 * 2.2 - attack * 0.4; - p.get("left_arm")[3] += attack2 * 1.2 - attack * 0.4; + p.xrot(main, -1.884_955_8 + (age * 0.09).mcos() * 0.15); + p.xrot(off, -0.0 + (age * 0.19).mcos() * 0.5); + p.get(main)[3] += attack2 * 2.2 - attack * 0.4; + p.get(off)[3] += attack2 * 1.2 - attack * 0.4; bob(p, "right_arm", age, 1.0); bob(p, "left_arm", age, -1.0); } @@ -286,6 +293,8 @@ pub struct Hum { pub using_item: bool, /// The arm the current swing moves (`Some(true)` for the right one). pub swing_right_arm: Option, + /// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it. + pub left_main: bool, } impl Hum { pub fn new(right: ArmPose, left: ArmPose) -> Self { @@ -303,6 +312,7 @@ impl Hum { swim: 0.0, using_item: false, swing_right_arm: None, + left_main: false, } } } @@ -315,7 +325,7 @@ impl ArmPose { } } /// `AnimationUtils.animateCrossbowHold`. -fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) { +pub(super) fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) { let (hold, shoot) = if in_right { ("right_arm", "left_arm") } else { @@ -327,7 +337,7 @@ fn crossbow_hold(p: &mut Poses, hx: f32, hy: f32, in_right: bool) { p.xrot(shoot, -1.5 + hx); } /// `AnimationUtils.animateCrossbowCharge`. -fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) { +pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: bool) { let (hold, pull) = if in_right { ("right_arm", "left_arm") } else { @@ -418,13 +428,17 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) { pose_arm(p, h.right, true, h); } } - // setupAttackAnimation (WHACK swing with the right arm). + // setupAttackAnimation (WHACK swing with the arm of the swinging hand). let hx = p.get("head")[3]; let swing = s.swing; + let swing_left = h.swing_right_arm.map_or(h.left_main, |right| !right); if swing > 0.0 && h.weapon_swing { - swing_weapon_down(p, swing, s.age); + swing_weapon_down(p, swing, s.age, h.left_main); } else if swing > 0.0 { - let body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2; + let mut body_y = (swing.sqrt() * PI * 2.0).msin() * 0.2; + if swing_left { + body_y = -body_y; + } p.yrot("body", body_y); let age_scale = if s.baby { 0.5 } else { 1.0 }; { @@ -444,7 +458,7 @@ pub(super) fn humanoid(p: &mut Poses, s: &State, h: &Hum) { let eased = 1.0 - (1.0 - swing).powi(4); let aa = (eased * PI).msin(); let bb = (swing * PI).msin() * -(hx - 0.7) * 0.75; - let r = p.get("right_arm"); + let r = p.get(if swing_left { "left_arm" } else { "right_arm" }); r[3] -= aa * 1.2 + bb; r[4] += body_y * 2.0; r[5] += (swing * PI).msin() * -0.4; @@ -652,6 +666,7 @@ pub fn setup( } else { Hum::new(main, ArmPose::Empty) }; + h.left_main = m.main_arm_left(); h.zombie = Some(m.ai.aggressive); h.holding = m.extra.main_hand.is_some(); h.crouching = m.extra.crouching; @@ -715,6 +730,7 @@ pub fn setup( h.swim = m.extra.swim.amount(s.partial); h.using_item = m.extra.using_item > 0; h.swing_right_arm = m.extra.swing_right_arm; + h.left_main = m.main_arm_left(); humanoid(p, s, &h); if m.ai.aggressive && !bow { // SkeletonModel melee pose. diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index bd0eb9c90..d928c34ee 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -625,8 +625,7 @@ fn targetable(m: &mut Mob, w: &MobWorld, who: Who) -> bool { // TargetingConditions.forNonCombat(): in range (scaled by the // player's visibility, at least 2) and in sight. let Some(p) = &w.player else { return false }; - let visibility = if p.sneaking { 0.8 } else { 1.0 }; - let r = (range * visibility).max(2.0); + let r = (range * p.visibility_percent()).max(2.0); p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who) } Who::Mob(id) => { diff --git a/crates/horizon-client/src/mobs/aquatic.rs b/crates/horizon-client/src/mobs/aquatic.rs index 3f86d8edc..59a73c605 100644 --- a/crates/horizon-client/src/mobs/aquatic.rs +++ b/crates/horizon-client/src/mobs/aquatic.rs @@ -24,6 +24,58 @@ const Z_BODY_ROT: usize = 3; const TENTACLE_SPEED: usize = 4; const ROTATE_SPEED: usize = 5; +/// `Squid.spawnInk`: the squirt sound and 30 ink particles shot from under +/// the squid, turned by its body rotation. +pub fn spawn_ink(m: &mut Mob) { + let sound = if m.species() == Species::GlowSquid { + "entity.glow_squid.squirt" + } else { + "entity.squid.squirt" + }; + let kind = if m.species() == Species::GlowSquid { + "glow_squid_ink" + } else { + "squid_ink" + }; + // makeSound draws the voice pitch even for a silent squid. + let pitch = m.voice_pitch(); + if !m.silent { + let at = [m.position.x, m.position.y, m.position.z]; + m.outbox.push(MobEvent::Sound( + sound, + at, + super::ai::sound_volume(m), + pitch, + )); + } + // rotateVector: xRot(xBodyRotO), then yRot(-yBodyRotO). + let to_radians = (std::f64::consts::PI / 180.0) as f32; + let (x_rot, y_rot) = ( + m.anim.vo[X_BODY_ROT] * to_radians, + -m.previous_body_yaw * to_radians, + ); + let rotate = |v: Vec3| v.rotate_x(x_rot).rotate_y(y_rot); + let pos = rotate(Vec3::new(0.0, -1.0, 0.0)).add_vector(m.position); + let offset_scale: f32 = if m.is_baby() { 0.1 } else { 0.3 }; + for _ in 0..30 { + let x = f64::from(m.random.next_float()) * 0.6 - 0.3; + let z = f64::from(m.random.next_float()) * 0.6 - 0.3; + let dir = rotate(Vec3::new(x, -1.0, z)); + let d = dir.scale(f64::from(offset_scale + m.random.next_float() * 2.0)); + // ServerLevel.sendParticles with a count of 0 and speed 0.1: one + // particle moving at the float offset times the float speed. + let speed = f64::from(0.1f32); + m.outbox.push(MobEvent::ParticleAt { + kind, + at: [pos.x, pos.y + 0.5, pos.z], + velocity: [ + speed * f64::from(d.x as f32), + speed * f64::from(d.y as f32), + speed * f64::from(d.z as f32), + ], + }); + } +} pub fn register(m: &mut Mob) { use Species as S; match m.species() { @@ -36,6 +88,7 @@ pub fn register(m: &mut Mob) { m.anim.v[TENTACLE_SPEED] = speed; } S::Axolotl => { + m.air = super::axolotl::MAX_AIR; // Native controller scaling is essential: the base speed attribute is 1.0. m.ai.mov.kind = MoveKind::SmoothSwimming { max_turn_x: 85.0, @@ -206,7 +259,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } S::Dolphin => { // Dolphin.tick: moisture out of water, hop on land. - if m.fluids.in_water || w.raining { + if m.fluids.in_water || super::ai::in_rain(m, w) { m.extra.counter = 2400; } else { m.extra.counter -= 1; diff --git a/crates/horizon-client/src/mobs/axolotl.rs b/crates/horizon-client/src/mobs/axolotl.rs index 55f15717c..2815418e3 100644 --- a/crates/horizon-client/src/mobs/axolotl.rs +++ b/crates/horizon-client/src/mobs/axolotl.rs @@ -3,6 +3,8 @@ use super::{Mob, MobEvent, MobWorld, Species, Who, animal_brain, keyframes::Anim use horizon_core::random::RandomSource; pub const PLAY_DEAD_TICKS: i32 = 200; pub const HUNTING_COOLDOWN: i64 = 2400; +/// `Axolotl.getMaxAirSupply`. +pub const MAX_AIR: i32 = 6000; /// AxolotlAttackablesSensor: always-hostile targets bypass the hunting cooldown. pub fn prey_eligible( diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 27d8e7c84..cecaff589 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) { match who { Who::Player => { cx.events.push(MobEvent::AttackPlayer(amount, from)); - let seconds = if cx.w.difficulty.hard { 18 } else { 10 }; - cx.events.push(MobEvent::EffectPlayer { - effect: "minecraft:poison", - ticks: seconds * 20, - amplifier: 0, - }); + // Poison only on normal (10 s) and hard (18 s). + let seconds = match cx.w.difficulty.id { + 2 => 10, + 3 => 18, + _ => 0, + }; + if seconds > 0 { + cx.events.push(MobEvent::EffectPlayer { + effect: "minecraft:poison", + ticks: seconds * 20, + amplifier: 0, + }); + } } Who::Mob(id) => cx.events.push(MobEvent::AttackMob { target: id, @@ -421,10 +428,10 @@ pub fn drop_off_nectar(m: &mut Mob) { set_has_nectar(m, false); st(m).crops_grown = 0; } -/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time -/// without nectar. +/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400 +/// ticks (the hive passes that when a sedated hive lets its bees out). pub fn set_stay_out_of_hive_countdown(m: &mut Mob) { - st(m).ticks_without_nectar = 0; + st(m).stay_out_of_hive = 400; } fn drop_hive(m: &mut Mob) { let b = st(m); diff --git a/crates/horizon-client/src/mobs/body_anim.rs b/crates/horizon-client/src/mobs/body_anim.rs index 4411d347b..360e1447f 100644 --- a/crates/horizon-client/src/mobs/body_anim.rs +++ b/crates/horizon-client/src/mobs/body_anim.rs @@ -959,7 +959,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool { let st = &m.anim.states; match m.species() { Species::Goat => goat(p, s, &Goat::from_mob(m)), - Species::PolarBear => polar_bear(p, s, 0.0), + Species::PolarBear => polar_bear(p, s, super::polar_bear::standing_scale(m, s.partial)), Species::Panda => panda(p, s, &Panda::default()), Species::Llama | Species::TraderLlama => { llama(p, s, m.extra.chested && !s.baby); @@ -969,8 +969,8 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool { s, &Turtle { on_land: !m.fluids.in_water && m.on_ground, - laying_egg: false, - has_egg: false, + laying_egg: m.extra.flag2, + has_egg: m.extra.flag, }, ), Species::Strider => strider(p, s, m.rider.is_some()), @@ -980,9 +980,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool { Species::Parrot => { let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial; let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial; + // `Parrot.isFlying` is `!onGround`, in water too. let pose = if m.extra.sitting { ParrotPose::Sitting - } else if !m.on_ground && !m.fluids.in_water { + } else if !m.on_ground { ParrotPose::Flying } else { ParrotPose::Standing diff --git a/crates/horizon-client/src/mobs/breeze.rs b/crates/horizon-client/src/mobs/breeze.rs index 5d78fa8ab..dee48f3d0 100644 --- a/crates/horizon-client/src/mobs/breeze.rs +++ b/crates/horizon-client/src/mobs/breeze.rs @@ -31,6 +31,28 @@ pub fn pose(m: &Mob) -> u8 { pub(super) fn set_pose(m: &mut Mob, pose: u8) { m.extra.variant3 = pose; } +/// `Pose.id` of the pose (`sync::pose_id`), and its inverse. +pub fn pose_id(m: &Mob) -> u8 { + match pose(m) { + SHOOTING => 16, + INHALING => 17, + LONG_JUMPING => 6, + SLIDING => 15, + _ => 0, + } +} +pub(super) fn set_pose_id(m: &mut Mob, id: u8) { + set_pose( + m, + match id { + 16 => SHOOTING, + 17 => INHALING, + 6 => LONG_JUMPING, + 15 => SLIDING, + _ => STANDING, + }, + ); +} /// `Breeze.getFiringYPosition`. pub fn firing_y(m: &Mob) -> f64 { m.position.y + f64::from(m.height()) / 2.0 + f64::from(0.3_f32) diff --git a/crates/horizon-client/src/mobs/camel.rs b/crates/horizon-client/src/mobs/camel.rs index 21ebe9e16..427b4c5ca 100644 --- a/crates/horizon-client/src/mobs/camel.rs +++ b/crates/horizon-client/src/mobs/camel.rs @@ -24,12 +24,13 @@ pub fn register(m: &mut Mob) { m.extra.tame = true; let tempt = m.kind.tempt.clone(); let g = &mut m.ai.goals; + // CamelAi speed modifiers (times the 0.09 movement speed attribute). g.add(0, Float); - g.add(1, CamelPanic(Panic::new(4.0 * 0.25))); + g.add(1, CamelPanic(Panic::new(4.0))); g.add(2, Breed::new(1.0)); - g.add(3, Tempt::new(2.5 * 0.25, tempt, false)); - g.add(4, FollowParent::new(2.5 * 0.25)); - g.add(5, Stroll::new(2.0 * 0.25)); + g.add(3, Tempt::new(2.5, tempt, false)); + g.add(4, FollowParent::new(2.5)); + g.add(5, Stroll::new(2.0)); g.add(5, RandomSitting); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); @@ -45,6 +46,25 @@ pub fn sitting(m: &Mob) -> bool { fn in_transition(m: &Mob) -> bool { pose_time(m) < if sitting(m) { SIT_DOWN } else { STAND_UP } } +/// `Camel.getPoseTime` of a client mob from the `LAST_POSE_CHANGE_TICK` it +/// was sent and the client's game time, and whether that value says it sits. +fn client_pose(m: &Mob, game_time: i64) -> (i64, bool) { + let tick = m.extra.pose_change_tick; + (game_time - tick.abs(), tick < 0) +} +/// The camel's `LAST_POSE_CHANGE_TICK` as the server sends it: in game time +/// (a mob counts its own age), negative while sitting. +pub fn synced_pose_tick(m: &Mob, game_time: i64) -> i64 { + let tick = (game_time - pose_time(m)).max(0); + if sitting(m) { -tick.max(1) } else { tick } +} +/// `Entity.onSyncedDataUpdated` for the pose tick of a client mob: its sign +/// is whether it sits. `client_tick` keeps `egg_time` (in the mob's age, for +/// `sitting` and `refuse_to_move`) with it. +pub fn set_synced_pose_tick(m: &mut Mob, tick: i64) { + m.extra.pose_change_tick = tick; + m.extra.egg_time = if tick < 0 { -1 } else { 0 }; +} /// `Camel.refuseToMove`: sitting or getting up or down. pub fn refuse_to_move(m: &Mob) -> bool { sitting(m) || in_transition(m) @@ -166,7 +186,29 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { m.ai.zza = 0.0; m.ai.nav.stop(); } - // setupAnimationStates. + animation_states(m, pose_time(m), sitting(m)); +} +/// `Camel.tick` on a client that is not the server: the dash cooldown, which +/// the dash flag starts, and the keyframe states from the synced pose tick. +pub fn client_tick(m: &mut Mob, game_time: i64) { + if m.extra.anim_ticks > 0 { + m.extra.anim_ticks -= 1; + } + let (pose_time, sitting) = client_pose(m, game_time); + let since = (i64::from(m.age) - pose_time).max(0) as i32; + m.extra.egg_time = if sitting { -since.max(1) } else { since }; + animation_states(m, pose_time, sitting); +} +/// `Camel.onSyncedDataUpdated(DASH)`: a dash starts the cooldown unless it +/// runs. Not on the first tick. +pub fn set_dashing(m: &mut Mob, dashing: bool) { + if m.age > 0 && m.extra.anim_ticks == 0 { + m.extra.anim_ticks = DASH_COOLDOWN; + } + m.extra.flag2 = dashing; +} +/// `Camel.setupAnimationStates` for a pose time and whether it sits. +fn animation_states(m: &mut Mob, pose_time: i64, sitting: bool) { let age = m.age as f32; if m.extra.ints[0] <= 0 { m.extra.ints[0] = m.random.next_int_bound(40).unwrap_or(0) + 80; @@ -175,9 +217,9 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { m.extra.ints[0] -= 1; } // isCamelVisuallySitting: (poseTime < 0) != sitting. - let visually_sitting = (pose_time(m) < 0) != sitting(m); - let sitting_down = sitting(m) && pose_time(m) < SIT_DOWN && pose_time(m) >= 0; - let transition = in_transition(m) && pose_time(m) >= 0; + let visually_sitting = (pose_time < 0) != sitting; + let sitting_down = sitting && (0..SIT_DOWN).contains(&pose_time); + let transition = (0..if sitting { SIT_DOWN } else { STAND_UP }).contains(&pose_time); let dashing = m.extra.flag2; let st = &mut m.anim.states; if visually_sitting { diff --git a/crates/horizon-client/src/mobs/control.rs b/crates/horizon-client/src/mobs/control.rs index 3df557362..e6874ea0b 100644 --- a/crates/horizon-client/src/mobs/control.rs +++ b/crates/horizon-client/src/mobs/control.rs @@ -1,12 +1,12 @@ //! Mob controls: `MoveControl` (with the flying and smooth-swimming //! variants), `LookControl`, `JumpControl` and `BodyRotationControl`. -use super::{Mob, MobWorld, living::wrap}; +use super::{Mob, MobWorld, Species, living::wrap}; use horizon_core::geometry::trig; /// `2.5000003E-7F` widened: the squared distance controls treat as arrived. const ARRIVED: f64 = 2.500_000_3e-7_f32 as f64; /// `1.0E-5F` widened. -const EPSILON: f64 = 1.0e-5_f32 as f64; +pub(super) const EPSILON: f64 = 1.0e-5_f32 as f64; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Operation { @@ -90,6 +90,9 @@ pub enum LookKind { Fixed, /// `HappyGhast.HappyGhastLookControl`. HappyGhast, + /// `Shulker.ShulkerLookControl`: the yaw turns in the attach face's + /// plane and the head is never clamped to the body. + Shulker, } #[derive(Clone, Debug, Default)] pub struct LookControl { @@ -113,8 +116,16 @@ pub struct BodyRotation { last_stable_head: f32, } impl Mob { + /// `Mob.getMaxHeadXRot`, with `Wolf`'s 20 degrees while sitting. + pub fn max_head_x_rot(&self) -> f32 { + if self.species() == Species::Wolf && self.extra.sitting { + 20.0 + } else { + self.kind.max_head_x_rot + } + } pub fn look_at(&mut self, at: [f64; 3]) { - let (y, x) = (self.kind.head_rot_speed, self.kind.max_head_x_rot); + let (y, x) = (self.kind.head_rot_speed, self.max_head_x_rot()); self.ai.look.set_look_at(at, y, x); } pub fn look_at_speed(&mut self, at: [f64; 3], y_speed: f32, x_max: f32) { @@ -135,6 +146,9 @@ impl Mob { if self.species() == super::Species::Rabbit { return super::rabbit::move_control(self, w); } + if self.species() == super::Species::Turtle { + return super::turtle::move_control(self); + } let attr_speed = self.movement_speed(); match self.ai.mov.kind { MoveKind::Custom => super::ai::custom_move_control(self, w, events), @@ -345,6 +359,20 @@ impl Mob { self.body_yaw += 4.0; } } + LookKind::Shulker => { + // ShulkerLookControl: resetXRotOnTick, getXRotD 0, and the + // yaw worked out in the attach face's plane (getYRotD). + self.head_pitch = 0.0; + if look.cooldown > 0 { + self.ai.look.cooldown -= 1; + if let Some(y) = super::shulker::look_yaw(self, look.wanted) { + self.head_yaw = rotate_towards(self.head_yaw, y, look.y_max_rot_speed); + } + } else { + self.head_yaw = rotate_towards(self.head_yaw, self.body_yaw, 10.0); + } + // clampHeadRotationToBody does nothing for a shulker. + } LookKind::Default | LookKind::Fixed => { if look.kind == LookKind::Default { self.head_pitch = 0.0; @@ -389,6 +417,13 @@ impl Mob { } /// BodyRotationControl.clientTick (Mob.tickHeadTurn). pub fn body_rotation_tick(&mut self) { + // Shulker.ShulkerBodyRotationControl.clientTick is empty: the body + // stays at 0 (recreateFromPacket), only the head turns. + if self.species() == super::Species::Shulker { + self.body_yaw = 0.0; + self.previous_body_yaw = 0.0; + return; + } let dx = self.position.x - self.previous.x; let dz = self.position.z - self.previous.z; let max = self.kind.max_head_y_rot; diff --git a/crates/horizon-client/src/mobs/doors.rs b/crates/horizon-client/src/mobs/doors.rs new file mode 100644 index 000000000..a55ab9863 --- /dev/null +++ b/crates/horizon-client/src/mobs/doors.rs @@ -0,0 +1,252 @@ +//! Doors a mob gets through on its own: `DoorInteractGoal` with its two +//! users, `BreakDoorGoal` (zombies that can break doors, vindicators in a +//! raid) and `OpenDoorGoal` (`AbstractIllager.RaiderOpenDoorGoal`). +use super::{ + Mob, MobEvent, MobWorld, Species, + goals::{Cx, Goal, MOVE, goal_clone, reduced}, + nav::NavKind, + raider, +}; +use horizon_core::{geometry::Vec3, random::RandomSource}; + +/// `DefaultDoorBreakTime`: `BreakDoorGoal.getDoorBreakTime` is +/// `max(240, doorBreakTime)`, and the vindicator's 6 is below it. +const DOOR_BREAK_TIME: i32 = 240; + +/// `GoalUtils.hasGroundPathNavigation`: ground, wall-climbing and +/// amphibious navigations walk. +pub fn can_navigate_ground(m: &Mob) -> bool { + matches!( + m.ai.nav.kind, + NavKind::Ground | NavKind::WallClimber | NavKind::Amphibious + ) +} +/// `Zombie.setCanBreakDoors`: only a mob that walks can break doors, and +/// then its navigation opens them. +pub fn set_can_break_doors(m: &mut Mob, can_break: bool) { + let can_break = can_break && can_navigate_ground(m); + m.extra.can_break_doors = can_break; + m.ai.nav.can_open_doors = can_break; +} +/// The species that extend `Zombie` (`CanBreakDoors` is theirs to save). +pub fn is_zombie(s: Species) -> bool { + matches!( + s, + Species::Zombie + | Species::Husk + | Species::Drowned + | Species::ZombieVillager + | Species::ZombifiedPiglin + ) +} +/// `DoorBlock.isWoodenDoor`: a door a hand opens, which is any but iron. +fn wooden_door(w: &MobWorld, p: [i32; 3]) -> bool { + let name = w.block_name(p[0], p[1], p[2]); + name.ends_with("_door") && name != "minecraft:iron_door" +} +/// `DoorInteractGoal`'s door: where it is and whether one is there. +#[derive(Clone, Debug, Default)] +struct Interact { + door: [i32; 3], + has_door: bool, +} +impl Interact { + /// `DoorInteractGoal.canUse`: the mob bumped into something and a + /// wooden door is on its path within 1.5 blocks, or at its own head. + fn can_use(&mut self, m: &Mob, w: &MobWorld) -> bool { + if !can_navigate_ground(m) || !m.horizontal_collision { + return false; + } + let Some(path) = m.ai.nav.path.as_ref().filter(|p| !p.is_done()) else { + return false; + }; + for node in path + .nodes + .iter() + .take((path.next + 2).min(path.nodes.len())) + { + self.door = [node.x, node.y + 1, node.z]; + let dx = m.position.x - f64::from(node.x); + let dz = m.position.z - f64::from(node.z); + if dx * dx + dz * dz <= 2.25 { + self.has_door = wooden_door(w, self.door); + if self.has_door { + return true; + } + } + } + let at = m.position; + self.door = [ + at.x.floor() as i32, + at.y.floor() as i32 + 1, + at.z.floor() as i32, + ]; + self.has_door = wooden_door(w, self.door); + self.has_door + } + /// `DoorInteractGoal.isOpen`: the door is still there and open. + fn is_open(&mut self, w: &MobWorld) -> bool { + if !self.has_door { + return false; + } + let d = self.door; + if !w.block_name(d[0], d[1], d[2]).ends_with("_door") { + self.has_door = false; + return false; + } + w.prop(d[0], d[1], d[2], "open") == Some("true") + } + /// `DoorInteractGoal.setOpen`. + fn set_open(&self, cx: &mut Cx, open: bool) { + let d = self.door; + if self.has_door && cx.w.block_name(d[0], d[1], d[2]).ends_with("_door") { + cx.events.push(MobEvent::SetDoorOpen { pos: d, open }); + } + } +} +/// `BlockPos.closerToCenterThan`. +fn closer_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool { + Vec3::new( + f64::from(p[0]) + 0.5, + f64::from(p[1]) + 0.5, + f64::from(p[2]) + 0.5, + ) + .distance_squared(at) + < distance * distance +} + +/// `BreakDoorGoal`: crack the door in the way for 240 ticks, then remove it. +/// A zombie does it on hard only; `Vindicator.VindicatorBreakDoorGoal` on +/// normal and hard, in a raid, and holds the mob's movement. +#[derive(Clone, Debug)] +pub struct BreakDoor { + interact: Interact, + vindicator: bool, + break_time: i32, + last_break_progress: i32, +} +impl BreakDoor { + /// `Zombie.breakDoorGoal`, added when the zombie can break doors. + pub fn zombie() -> Self { + Self::new(false) + } + /// `Vindicator.VindicatorBreakDoorGoal`. + pub fn vindicator() -> Self { + Self::new(true) + } + fn new(vindicator: bool) -> Self { + Self { + interact: Interact::default(), + vindicator, + break_time: 0, + last_break_progress: -1, + } + } + /// `DOOR_BREAKING_PREDICATE`. + fn valid_difficulty(&self, w: &MobWorld) -> bool { + if self.vindicator { + w.difficulty.normal || w.difficulty.hard + } else { + w.difficulty.hard + } + } + /// `BreakDoorGoal.canUse` after `DoorInteractGoal.canUse`. + fn can_break(&mut self, m: &Mob, w: &MobWorld) -> bool { + self.interact.can_use(m, w) + && w.mob_griefing + && self.valid_difficulty(w) + && !self.interact.is_open(w) + } +} +impl Goal for BreakDoor { + goal_clone!(); + fn flags(&self) -> u8 { + if self.vindicator { MOVE } else { 0 } + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if self.vindicator { + return raider::has_active_raid(m) + && m.random.next_int_bound(reduced(10)).unwrap_or(1) == 0 + && self.can_break(m, cx.w); + } + m.extra.can_break_doors && self.can_break(m, cx.w) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + (!self.vindicator || raider::has_active_raid(m)) + && self.break_time <= DOOR_BREAK_TIME + && !self.interact.is_open(cx.w) + && closer_to_center(self.interact.door, m.position, 2.0) + && self.valid_difficulty(cx.w) + } + fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { + self.break_time = 0; + if self.vindicator { + m.ai.no_action_time = 0; + } + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: self.interact.door, + progress: -1, + }); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let door = self.interact.door; + if m.random.next_int_bound(20).unwrap_or(1) == 0 { + cx.events.push(MobEvent::LevelEvent { + id: 1019, + pos: door, + data: 0, + }); + if !m.ai.swinging { + m.swing(); + } + } + self.break_time += 1; + let progress = (self.break_time as f32 / DOOR_BREAK_TIME as f32 * 10.0) as i32; + if progress != self.last_break_progress { + cx.events.push(MobEvent::DestroyProgress { + mob: m.id, + pos: door, + progress, + }); + self.last_break_progress = progress; + } + if self.break_time == DOOR_BREAK_TIME && self.valid_difficulty(cx.w) { + cx.events.push(MobEvent::BreakDoor { pos: door }); + } + } +} + +/// `AbstractIllager.RaiderOpenDoorGoal` (`OpenDoorGoal` that does not wait +/// to close): open the door in the way during a raid. It never continues, so +/// it closes the door again as soon as it stops. +#[derive(Clone, Debug, Default)] +pub struct RaiderOpenDoor { + interact: Interact, +} +impl Goal for RaiderOpenDoor { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.interact.can_use(m, cx.w) && raider::has_active_raid(m) + } + fn can_continue(&mut self, _m: &mut Mob, _cx: &mut Cx) -> bool { + false + } + fn start(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, true); + } + fn stop(&mut self, _m: &mut Mob, cx: &mut Cx) { + self.interact.set_open(cx, false); + } +} diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index e5ccfd283..3168d8f1e 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -304,6 +304,7 @@ fn loot_stack(item: &str, count: i32) -> LootStack { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), } } /// `Mob.dropChances.byEquipment(slot)`: the recorded chance, or the one the @@ -350,6 +351,9 @@ pub fn loot_entity(m: &Mob, vehicle: Option) -> LootEntity { .to_owned(), ); } + if s == S::Chicken { + e.chicken_variant = super::finalize::variant_component(m).map(|(_, id)| id); + } e.powered = s == S::Creeper && m.extra.charged; e.equipment = SLOTS .iter() @@ -399,6 +403,15 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec (Vec 0 { + out.push(Drop::Item(loot_stack(&item, carried))); + } + if let Some(stack) = slot_item(m, "mainhand") + && !d.loot.prevents_equipment_drop(&stack) + { + out.push(Drop::Item(stack)); + clear_slot(m, "mainhand"); + } + } + // Piglin.dropCustomDeathLoot: everything in its inventory. + if s == Species::Piglin { + for stack in m.extra.inventory.drain(..) { + let mut item = loot_stack(&stack.item, stack.count as i32); + item.damage = stack.damage as i32; + out.push(Drop::Item(item)); + } + } // dropEquipment: AbstractHorse's inventory, then a chested horse's chest. if equine::is_equine(s) { for stack in m.extra.items.iter().flatten() { diff --git a/crates/horizon-client/src/mobs/enderman.rs b/crates/horizon-client/src/mobs/enderman.rs index c5ed01680..17566fb65 100644 --- a/crates/horizon-client/src/mobs/enderman.rs +++ b/crates/horizon-client/src/mobs/enderman.rs @@ -184,15 +184,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } } } - if (m.fluids.in_water - || w.raining - && w.can_see_sky( - m.position.x.floor() as i32, - m.eye_y().floor() as i32, - m.position.z.floor() as i32, - )) - && m.alive() - { + if (m.fluids.in_water || super::ai::in_rain(m, w)) && m.alive() { // Hurt by water: teleport away. m.extra.counter2 = m.extra.counter2.max(1); } @@ -331,8 +323,11 @@ impl Goal for TakeBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_none() && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanTakeBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_none() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(20)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let x = (m.position.x - 2.0 + m.random.next_double() * 4.0).floor() as i32; @@ -379,8 +374,11 @@ impl Goal for LeaveBlock { fn flags(&self) -> u8 { 0 } - fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { - m.extra.carried.is_some() && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + // EndermanLeaveBlockGoal.canUse: needs mobGriefing, checked before the draw. + m.extra.carried.is_some() + && cx.w.mob_griefing + && m.random.next_int_bound(reduced(2000)).unwrap_or(1) == 0 } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let Some(carried) = m.extra.carried else { diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 5bb5faf53..1155388fd 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -28,6 +28,8 @@ pub const MARKINGS: [&str; 5] = ["", "white", "whitefield", "whitedots", "blackd pub const EAT: usize = 0; pub const STAND: usize = 1; pub const MOUTH: usize = 2; +/// `AbstractHorse.tailCounter`: client-side, it only drives the model. +pub const TAIL: usize = 3; /// What a rider asks of its mount this tick. #[derive(Clone, Copy, Debug, Default)] @@ -418,6 +420,20 @@ pub fn ridden_step(m: &mut Mob) { } } } +/// `AbstractHorse.aiStep`'s `moveTail` (one in 200 ticks) and the counter's +/// step in `tick` (it runs to 8, then stops). A client runs it on its own +/// random, as vanilla's client-side `aiStep` does. +pub fn tail_tick(m: &mut Mob) { + if m.random.next_int_bound(200).unwrap_or(1) == 0 { + m.anim.v[TAIL] = 1.0; + } + if m.anim.v[TAIL] > 0.0 { + m.anim.v[TAIL] += 1.0; + if m.anim.v[TAIL] > 8.0 { + m.anim.v[TAIL] = 0.0; + } + } +} /// `AbstractHorse.aiStep` and `tick`: healing, grazing, the rear and the /// eating/standing/mouth animations. pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { @@ -603,7 +619,15 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) { t[1] += speed * age; t[2] += speed * 2.0 * age; } - p.yrot("tail", 0.0); + // animateTail: the tail swings while the counter runs. + p.yrot( + "tail", + if m.anim.v[TAIL] > 0.0 { + (s.age * 0.7).mcos() + } else { + 0.0 + }, + ); if baby && donkey { // BabyDonkeyModel overrides the state's pitch with -30 degrees and // sets the head parts again. diff --git a/crates/horizon-client/src/mobs/farm.rs b/crates/horizon-client/src/mobs/farm.rs index 83d5ac1f5..87951b0e2 100644 --- a/crates/horizon-client/src/mobs/farm.rs +++ b/crates/horizon-client/src/mobs/farm.rs @@ -80,6 +80,7 @@ pub fn chicken_ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec) { let at = [m.position.x, m.position.y, m.position.z]; events.push(MobEvent::Sound("entity.chicken.egg", at, 1.0, pitch)); events.push(MobEvent::GiftLoot { + mob: m.id, table: "minecraft:gameplay/chicken_lay", at, }); diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index 4eeed885b..ccf4564ca 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -590,6 +590,14 @@ pub fn has_modifier(m: &Mob, a: Attr, id: &str) -> bool { .as_ref() .is_some_and(|s| s.modifiers.iter().any(|x| x.attr == a && x.id == id)) } +/// The amount of a permanent modifier, if the mob has it. +pub fn modifier_amount(m: &Mob, a: Attr, id: &str) -> Option { + m.extra + .attributes + .as_ref() + .and_then(|s| s.modifiers.iter().find(|x| x.attr == a && x.id == id)) + .map(|x| x.amount) +} /// `addOrReplacePermanentModifier`. pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) { let s = m.extra.attributes.get_or_insert_with(Box::default); @@ -605,6 +613,59 @@ pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) } refresh(m, a); } +/// `Zombie.handleAttributes`, from the zombie's own random. +pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) { + let r = &mut m.random; + let reinforcements = if m.kind.species == Species::ZombifiedPiglin { + 0.0 + } else { + r.next_double() * f64::from(0.1f32) + }; + let knockback = r.next_double() * f64::from(0.05f32); + let follow = r.next_double() * 1.5 * f64::from(special); + let leader = r.next_float() < special * 0.05; + let leader_rolls = leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); + set_base(m, Attr::SpawnReinforcements, reinforcements); + add_modifier( + m, + Attr::KnockbackResistance, + "minecraft:random_spawn_bonus", + knockback, + Op::AddValue, + ); + if follow > 1.0 { + add_modifier( + m, + Attr::FollowRange, + "minecraft:zombie_random_spawn_bonus", + follow, + Op::AddMultipliedTotal, + ); + } + if let Some((reinforce, health)) = leader_rolls { + add_modifier( + m, + Attr::SpawnReinforcements, + "minecraft:leader_zombie_bonus", + reinforce, + Op::AddValue, + ); + add_modifier( + m, + Attr::MaxHealth, + "minecraft:leader_zombie_bonus", + health, + Op::AddMultipliedTotal, + ); + if !matches!( + reason, + Reason::Conversion | Reason::Load | Reason::DimensionTravel + ) { + m.health = max_health(m); + } + super::doors::set_can_break_doors(m, true); + } +} pub fn max_health(m: &Mob) -> f32 { value(m, Attr::MaxHealth) as f32 } @@ -829,6 +890,14 @@ pub fn set_item(m: &mut Mob, slot: usize, id: &str) { } gear::set(m, gear_slot(slot), Vec::new()); } +/// The enchantments on what `m` has in `slot`. +pub fn slot_enchantments(m: &Mob, slot: usize) -> gear::Levels { + gear::gear(m).map_or_else(Vec::new, |g| g.slots[gear_slot(slot)].clone()) +} +/// Replace the enchantments of what `m` has in `slot` (after `set_item`). +pub fn set_slot_enchantments(m: &mut Mob, slot: usize, levels: gear::Levels) { + gear::set(m, gear_slot(slot), levels); +} /// `Mob.setDropChance`. pub fn set_drop_chance(m: &mut Mob, slot: usize, chance: f32) { let c = m @@ -1144,70 +1213,17 @@ impl Ctx<'_, L, R> { } } } - m.extra.can_break_doors = self.float() < special * 0.1; + let can_break_doors = self.float() < special * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if reason != Reason::Conversion { self.populate(m); self.enchant(m); } } self.halloween(m); - self.zombie_attributes(m, special, reason); + zombie_attributes(m, special, reason); g } - /// `Zombie.handleAttributes`, from the zombie's own random. - fn zombie_attributes(&mut self, m: &mut Mob, special: f32, reason: Reason) { - let r = &mut m.random; - let reinforcements = if m.kind.species == Species::ZombifiedPiglin { - 0.0 - } else { - r.next_double() * f64::from(0.1f32) - }; - let knockback = r.next_double() * f64::from(0.05f32); - let follow = r.next_double() * 1.5 * f64::from(special); - let leader = r.next_float() < special * 0.05; - let leader_rolls = - leader.then(|| (r.next_double() * 0.25 + 0.5, r.next_double() * 3.0 + 1.0)); - set_base(m, Attr::SpawnReinforcements, reinforcements); - add_modifier( - m, - Attr::KnockbackResistance, - "minecraft:random_spawn_bonus", - knockback, - Op::AddValue, - ); - if follow > 1.0 { - add_modifier( - m, - Attr::FollowRange, - "minecraft:zombie_random_spawn_bonus", - follow, - Op::AddMultipliedTotal, - ); - } - if let Some((reinforce, health)) = leader_rolls { - add_modifier( - m, - Attr::SpawnReinforcements, - "minecraft:leader_zombie_bonus", - reinforce, - Op::AddValue, - ); - add_modifier( - m, - Attr::MaxHealth, - "minecraft:leader_zombie_bonus", - health, - Op::AddMultipliedTotal, - ); - if !matches!( - reason, - Reason::Conversion | Reason::Load | Reason::DimensionTravel - ) { - m.health = max_health(m); - } - m.extra.can_break_doors = true; - } - } fn husk(&mut self, m: &mut Mob, g: Option, reason: Reason) -> Option { let mut g = self.zombie(m, g, reason); if reason != Reason::Conversion { @@ -1492,7 +1508,7 @@ impl Ctx<'_, L, R> { } fn sound(&mut self, m: &mut Mob, registry: fn(&SpawnRegistries) -> &VariantRegistry) { let r = registry(self.data.registries); - m.extra.sound_variant = r.random(self.random) as u8; + m.extra.sound_variant = Some(r.random(self.random) as u8); } /// `finalizeSpawn` of the mob's class. @@ -1998,6 +2014,17 @@ pub fn species_variant_component(s: Species, variant: u8) -> Option<(&'static st }; Some((component, format!("minecraft:{}", variant_name(s, variant)))) } +/// The `*_sound_variant` registry of a species, in registry order. +pub fn sound_variants(s: Species) -> &'static [&'static str] { + match s { + Species::Pig => &["big", "classic", "mini"], + Species::Cow => &["classic", "moody"], + Species::Chicken => &["classic", "picky"], + Species::Cat => &["classic", "royal"], + Species::Wolf => &["angry", "big", "classic", "cute", "grumpy", "puglin", "sad"], + _ => &[], + } +} /// The registry name of the game's variant index. pub fn variant_name(s: Species, index: u8) -> &'static str { let i = index as usize; diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 7f73e32c4..6704246de 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -588,24 +588,38 @@ impl Mob { if p.creative || p.allies.contains(&self.id) { return false; } - self.player_in_range(w, range, check_sight) + self.player_in_range(w, range, check_sight, true) + } + /// forCombat().ignoreInvisibilityTesting() for the player: the check for + /// a target the mob already has, which ignores the visibility scaling. + pub fn can_keep_target_player(&mut self, w: &MobWorld, range: f64) -> bool { + let Some(p) = &w.player else { return false }; + if p.creative || p.allies.contains(&self.id) { + return false; + } + self.player_in_range(w, range, true, false) } /// TargetingConditions.forNonCombat().range(range).test for the player /// (seen by anyone, within range scaled by visibility, in sight). pub fn can_see_player(&mut self, w: &MobWorld, range: f64) -> bool { - self.player_in_range(w, range, true) + self.player_in_range(w, range, true, true) } - fn player_in_range(&mut self, w: &MobWorld, range: f64, check_sight: bool) -> bool { + /// `scale` is `TargetingConditions.testInvisible`: scale the range by + /// the player's visibility. + fn player_in_range( + &mut self, + w: &MobWorld, + range: f64, + check_sight: bool, + scale: bool, + ) -> bool { let Some(p) = &w.player else { return false }; if !p.alive || p.spectator { return false; } // LivingEntity.getVisibilityPercent - let mut visibility = 1.0; - if p.sneaking { - visibility *= 0.8; - } - if let Some(head) = &p.head { + let mut visibility = if scale { p.visibility_percent() } else { 1.0 }; + if let Some(head) = p.head.as_ref().filter(|_| scale) { let skull = match self.species() { Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager => { "minecraft:zombie_head" @@ -1017,6 +1031,8 @@ pub struct MeleeAttack { pub speed: f64, pub follow_unseen: bool, pub zombie: bool, + /// `PolarBear.PolarBearMeleeAttackGoal`: rears up before each strike. + pub polar_bear: bool, path_target: [f64; 3], recalc: i32, pub until_attack: i32, @@ -1031,6 +1047,7 @@ impl MeleeAttack { speed, follow_unseen, zombie: false, + polar_bear: false, path_target: [0.0; 3], recalc: 0, until_attack: 0, @@ -1043,6 +1060,10 @@ impl MeleeAttack { self.zombie = true; self } + pub fn polar_bear(mut self) -> Self { + self.polar_bear = true; + self + } pub fn interval(mut self, ticks: i32) -> Self { self.interval = ticks; self @@ -1098,6 +1119,9 @@ impl Goal for MeleeAttack { } m.ai.aggressive = false; m.nav_stop(); + if self.polar_bear { + super::polar_bear::set_standing(m, false); + } } fn every_tick(&self) -> bool { true @@ -1131,7 +1155,17 @@ impl Goal for MeleeAttack { self.recalc = adjusted(true, self.recalc); } self.until_attack = (self.until_attack - 1).max(0); - if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) { + if self.polar_bear { + let cooldown = adjusted(true, self.interval); + super::polar_bear::check_and_perform_attack( + &mut self.until_attack, + cooldown, + m, + who, + &t, + cx, + ); + } else if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) { self.until_attack = adjusted(true, self.interval); m.swing(); super::ai::do_hurt_target(m, who, cx); @@ -1210,6 +1244,9 @@ pub struct NearestTarget { pub interval: i32, /// Extra condition on the attacker, checked after the random interval. pub when: Option bool>, + /// Mobs closer than this squared distance are not picked + /// (`GuardianAttackSelector`: `distanceToSqr > 9`). + pub beyond_sqr: Option, /// A condition checked before anything else, by subclasses overriding /// `canUse` (SpiderTargetGoal: only in the dark). pub gate: Option bool>, @@ -1242,6 +1279,7 @@ impl NearestTarget { must_reach: false, interval: reduced(10), when: None, + beyond_sqr: None, gate: None, label: None, found: None, @@ -1274,6 +1312,10 @@ impl NearestTarget { self.when = Some(f); self } + pub fn beyond(mut self, distance_sqr: f64) -> Self { + self.beyond_sqr = Some(distance_sqr); + self + } pub fn gate(mut self, f: fn(&Mob, &MobWorld) -> bool) -> Self { self.gate = Some(f); self @@ -1284,9 +1326,25 @@ impl NearestTarget { self } } +/// `AbstractIllager.canAttack`: a baby `AbstractVillager` is never attacked. +fn illager_spares(attacker: Species, target: &Seen) -> bool { + matches!( + attacker, + Species::Pillager | Species::Vindicator | Species::Evoker | Species::Illusioner + ) && target.baby + && matches!(target.species, Species::Villager | Species::WanderingTrader) +} /// TargetGoal.canContinueToUse pub fn target_continue(st: &mut TargetState, must_see: bool, m: &mut Mob, cx: &mut Cx) -> bool { let Some(who) = m.ai.target else { return false }; + if let Who::Mob(id) = who + && cx + .w + .seen(id) + .is_some_and(|s| illager_spares(m.species(), s)) + { + return false; + } let Some(t) = cx.w.target_info(who) else { return false; }; @@ -1345,10 +1403,12 @@ impl Goal for NearestTarget { prey => { let pos = Vec3::new(m.position.x, m.eye_y(), m.position.z); let id = m.id; + let attacker = m.species(); let candidates: Vec<(u32, Vec3)> = cx.w.nearby(pos, range, move |s| { s.id != id && !s.playing_dead + && !illager_spares(attacker, s) && match prey { Prey::Species(list) => list.contains(&s.species), Prey::Filter(f) => f(s), @@ -1361,6 +1421,9 @@ impl Goal for NearestTarget { if m.distance_sqr(cpos) > range * range { continue; } + if self.beyond_sqr.is_some_and(|d| m.distance_sqr(cpos) <= d) { + continue; + } if !m.sees(cx.w, Who::Mob(cid)) { continue; } @@ -1393,6 +1456,9 @@ pub struct HurtByTarget { /// `Bee.BeeHurtByOtherGoal`: kept only while angry, and alerting others /// only when the attacker is in sight. pub bee: bool, + /// `PolarBear.PolarBearHurtByTargetGoal`: a hurt cub only calls the + /// adult bears and drops the target itself. + pub polar_bear: bool, timestamp: i32, st: TargetState, } @@ -1403,6 +1469,7 @@ impl HurtByTarget { alert_except: vec![], ignore: vec![], bee: false, + polar_bear: false, timestamp: 0, st: TargetState { unseen_memory: 300, @@ -1422,6 +1489,10 @@ impl HurtByTarget { self.bee = true; self } + pub fn polar_bear(mut self) -> Self { + self.polar_bear = true; + self + } } impl Default for HurtByTarget { fn default() -> Self { @@ -1433,6 +1504,8 @@ impl Goal for HurtByTarget { fn name(&self) -> &'static str { if self.bee { "BeeHurtByOtherGoal" + } else if self.polar_bear { + "PolarBearHurtByTargetGoal" } else { "HurtByTarget" } @@ -1471,7 +1544,9 @@ impl Goal for HurtByTarget { self.timestamp = m.ai.last_hurt_by_time; self.st.unseen_memory = 300; self.st.unseen = 0; - if self.alert + // PolarBearHurtByTargetGoal.start: a cub alerts the adults itself. + let cub = self.polar_bear && m.is_baby(); + if (self.alert || cub) && let Some(by) = m.ai.target { let range = m.attrs.follow_range; @@ -1491,6 +1566,8 @@ impl Goal for HurtByTarget { && (s.position.z - pos.z).abs() <= range + 1.0 }) .filter(|s| !self.alert_except.contains(&s.species)) + // PolarBearHurtByTargetGoal.alertOther: adults only. + .filter(|s| !(self.polar_bear && s.baby)) .filter(|s| Who::Mob(s.id) != by) .map(|s| s.id) .collect(); @@ -1505,6 +1582,9 @@ impl Goal for HurtByTarget { }); } } + if cub { + m.ai.target = None; + } } fn stop(&mut self, m: &mut Mob, _: &mut Cx) { m.ai.target = None; @@ -1712,7 +1792,7 @@ impl Goal for Tempt { } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let Some(p) = cx.w.player.clone() else { return }; - let (y, x) = (m.kind.max_head_y_rot + 20.0, m.kind.max_head_x_rot); + let (y, x) = (m.kind.max_head_y_rot + 20.0, m.max_head_x_rot()); m.look_at_speed(p.eye, y, x); if m.distance_sqr(p.position) < self.stop_distance * self.stop_distance { m.nav_stop(); @@ -1762,6 +1842,10 @@ impl Goal for Breed { if tamable && (!m.extra.tame || m.extra.sitting) { return false; } + // TurtleBreedGoal.canUse: not while carrying an egg. + if species == Species::Turtle && super::turtle::has_egg(m) { + return false; + } let id = m.id; // Horses and donkeys breed together (AbstractHorse.canParent: tame). // Sniffer.canMate: calm states only. @@ -1771,11 +1855,16 @@ impl Goal for Breed { } let llama = super::llama::is_llama(species); let equine = matches!(species, Species::Horse | Species::Donkey) || llama; - if equine && !m.extra.tame { - return false; - } - // Llama.canParent: not ridden, and at full health. - if llama && (m.rider.is_some() || m.health < m.attrs.max_health) { + // AbstractHorse.canParent: tame, an adult, not a vehicle or passenger + // and at full health. + if equine + && (!m.extra.tame + || m.is_baby() + || m.rider.is_some() + || m.extra.rider.is_some() + || m.extra.mount.is_some() + || m.health < m.attrs.max_health) + { return false; } self.partner = @@ -1788,7 +1877,10 @@ impl Goal for Breed { && s.in_love && !s.baby && (!tamable || s.tame) - && (!equine || s.tame) + // Wolf.canMate: a sitting wolf is no partner. + && !(species == Species::Wolf && s.sitting) + // AbstractHorse.canParent of the partner. + && (!equine || s.tame && !s.ridden && s.health >= s.stats[0] as f32) && (!sniffer || super::sniffer::can_mate_state(s.variant)) }) .map(|s| s.id) @@ -1809,7 +1901,7 @@ impl Goal for Breed { let Some(p) = self.partner.and_then(|p| cx.w.seen(p)).cloned() else { return; }; - let x = m.kind.max_head_x_rot; + let x = m.max_head_x_rot(); m.look_at_speed([p.position.x, p.eye_y, p.position.z], 10.0, x); m.nav_move_to_entity( cx.w, @@ -1832,6 +1924,10 @@ impl Goal for Breed { }); m.extra.in_love = 0; m.extra.age = 6000; + // TurtleBreedGoal.breed: the mother carries an egg, not a baby. + if m.species() == Species::Turtle { + m.extra.flag = true; + } cx.events.push(MobEvent::Breed { parent: m.id, partner: p.id, diff --git a/crates/horizon-client/src/mobs/guardian.rs b/crates/horizon-client/src/mobs/guardian.rs index 9704de044..b630aeadd 100644 --- a/crates/horizon-client/src/mobs/guardian.rs +++ b/crates/horizon-client/src/mobs/guardian.rs @@ -61,7 +61,8 @@ pub fn register(m: &mut Mob) { NearestTarget::species( &[Species::Squid, Species::GlowSquid, Species::Axolotl], true, - ), + ) + .beyond(9.0), ); } /// `GuardianAttackGoal`. @@ -120,9 +121,11 @@ impl Goal for GuardianAttack { 1.0, )); } else if self.attack_time >= attack_duration(m) { - // Indirect magic (1, +2 for the elder; NORMAL difficulty), then + // Indirect magic (1, +2 on Hard, +2 for the elder), then // doHurtTarget. - let magic = if is_elder(m) { 3.0 } else { 1.0 }; + let magic = 1.0 + + if cx.w.difficulty.hard { 2.0 } else { 0.0 } + + if is_elder(m) { 2.0 } else { 0.0 }; let from = [m.position.x, m.position.y, m.position.z]; match who { Who::Player => cx.events.push(MobEvent::DamagePlayer { @@ -396,6 +399,8 @@ pub fn beam_geometry( layer: 0, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); }; // Sides: one quad per texture repeat. diff --git a/crates/horizon-client/src/mobs/happy_ghast.rs b/crates/horizon-client/src/mobs/happy_ghast.rs index bd4c5b1f3..418556750 100644 --- a/crates/horizon-client/src/mobs/happy_ghast.rs +++ b/crates/horizon-client/src/mobs/happy_ghast.rs @@ -364,8 +364,7 @@ impl Goal for GhastTempt { let Some(p) = &cx.w.player else { return false }; // getNearestPlayer(TargetingConditions.forNonCombat() // .ignoreLineOfSight().range(TEMPT_RANGE 16)): creative players too. - let visibility = if p.sneaking { 0.8 } else { 1.0 }; - let range = (16.0_f64 * visibility).max(2.0); + let range = (16.0_f64 * p.visibility_percent()).max(2.0); p.alive && !p.spectator && (Self::wants(m, &p.main_hand) || Self::wants(m, &p.off_hand)) @@ -544,14 +543,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) { m.ai.home = Some((here, radius)); } } - // continuousHeal: a point every 30 seconds, every second in rain. + // continuousHeal: a point every 30 seconds, every second in rain or snow + // (Level.precipitationAt). if m.alive() && m.death_time == 0 && m.health < m.attrs.max_health { let (x, y, z) = ( m.position.x.floor() as i32, m.position.y.floor() as i32, m.position.z.floor() as i32, ); - let wet = w.raining && w.can_see_sky(x, y, z); + let wet = w.precipitation_at(x, y, z).is_some(); let interval = if wet { 20 } else { 600 }; if m.age.is_multiple_of(interval) { m.health = (m.health + 1.0).min(m.attrs.max_health); diff --git a/crates/horizon-client/src/mobs/hostile.rs b/crates/horizon-client/src/mobs/hostile.rs index 0d3feaa6c..34dc256f9 100644 --- a/crates/horizon-client/src/mobs/hostile.rs +++ b/crates/horizon-client/src/mobs/hostile.rs @@ -177,9 +177,14 @@ pub fn creeper_tick(m: &mut Mob, events: &mut Vec) { if m.swell >= 30 { m.swell = 30; let radius = if m.extra.charged { 6.0 } else { 3.0 }; + events.push(MobEvent::ExplosionSource { + mob: m.id, + charged: m.extra.charged, + }); events.push(MobEvent::Explode( [m.position.x, m.position.y, m.position.z], radius, + m.species(), )); m.removed = true; } diff --git a/crates/horizon-client/src/mobs/illager.rs b/crates/horizon-client/src/mobs/illager.rs index 45ef076d3..00581d6d3 100644 --- a/crates/horizon-client/src/mobs/illager.rs +++ b/crates/horizon-client/src/mobs/illager.rs @@ -72,6 +72,8 @@ pub fn register_vindicator(m: &mut Mob) { let g = &mut m.ai.goals; g.add(0, Float); g.add(1, avoid_creaking(1.0, 1.2)); + g.add(2, super::doors::BreakDoor::vindicator()); + g.add(3, super::doors::RaiderOpenDoor::default()); g.add(4, super::raider::HoldGroundAttack::new(10.0)); g.add(5, MeleeAttack::new(1.0, false)); g.add(8, Stroll::new(0.6)); @@ -86,6 +88,13 @@ pub fn register_vindicator(m: &mut Mob) { .when(|m, _| m.custom_name.as_deref() == Some("Johnny")), ); } +/// `Vindicator.customServerAiStep`: its navigation opens doors while a raid +/// is at its position. +pub fn vindicator_server_ai_step(m: &mut Mob) { + if super::doors::can_navigate_ground(m) { + m.ai.nav.can_open_doors = super::raider::state(m).is_some_and(|s| s.raided); + } +} // Evoker state: `counter` spellCastingTickCount, `variant2` the spell // being cast (0 none, 1 vexes, 2 fangs, 3 wololo), `counter2` the wololo // sheep's id. @@ -179,8 +188,8 @@ impl Goal for UseSpell { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { let age = i64::from(m.age); if self.spell == Spell::Wololo { - // EvokerWololoSpellGoal.canUse: a blue sheep within 16 (4 up or - // down), no target, griefing on. + // EvokerWololoSpellGoal.canUse: a blue sheep within the box + // (16 sideways, 4 up or down) and `range(16)`, no target, griefing on. if m.ai.target.is_some() || m.extra.counter > 0 || age < self.next || !cx.w.mob_griefing { return false; @@ -196,6 +205,7 @@ impl Goal for UseSpell { && (s.position.x - p.x).abs() <= 16.0 && (s.position.y - p.y).abs() <= 4.0 && (s.position.z - p.z).abs() <= 16.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .map(|s| s.id) .collect(); @@ -210,7 +220,8 @@ impl Goal for UseSpell { return false; } if self.spell == Spell::Vexes { - // EvokerSummonSpellGoal: fewer vexes nearby than a roll of 1..8. + // EvokerSummonSpellGoal: fewer vexes within `range(16)` than a + // roll of 1..8. let p = m.position; let vexes = cx.w.others @@ -218,7 +229,7 @@ impl Goal for UseSpell { .filter(|s| { s.species == Species::Vex && s.alive - && s.position.distance_squared(p) <= 16.0 * 16.0 * 3.0 + && s.position.distance_squared(p) <= 16.0 * 16.0 }) .count() as i32; return m.random.next_int_bound(8).unwrap_or(0) + 1 > vexes; @@ -670,7 +681,12 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) { p.xrot("right_arm", x + a * 1.2 - b * 0.4); p.xrot("left_arm", x + a * 1.2 - b * 0.4); } else { - // swingWeaponDown (right-handed). + // swingWeaponDown: the main arm swings down. + let (main, off) = if m.main_arm_left() { + ("left_arm", "right_arm") + } else { + ("right_arm", "left_arm") + }; let swing = s.swing; let a2 = (swing * PI).msin(); let a = ((1.0 - (1.0 - swing) * (1.0 - swing)) * PI).msin(); @@ -679,11 +695,11 @@ pub fn anim_pose(m: &Mob, s: &State, p: &mut Poses, pose: ArmPose) { p.yrot("right_arm", PI / 20.0); p.yrot("left_arm", -PI / 20.0); p.xrot( - "right_arm", + main, -1.884_955_8 + (s.age * 0.09).mcos() * 0.15 + (a2 * 2.2 - a * 0.4), ); p.xrot( - "left_arm", + off, -0.0 + (s.age * 0.19).mcos() * 0.5 + (a2 * 1.2 - a * 0.4), ); } diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 394821ace..10388d0c0 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -9,8 +9,15 @@ use crate::{ }; use horizon_core::{ geometry::{Aabb, Vec3, trig}, + position::BlockPos, random::RandomSource, }; +use horizon_world::{ + entity_fluid::{CurrentAccumulator, CurrentTarget}, + fluid::{FluidKind, FluidState}, + fluid_flow::{FluidFlowWorld, flow_at}, + registry::BlockStateId, +}; /// `Entity.moveDist`, `flyDist` and `nextStep`. #[derive(Clone, Copy, Debug)] @@ -36,6 +43,50 @@ pub struct Fluids { pub in_lava: bool, pub eye_in_water: bool, } +/// `FluidFlowWorld` over the mob's scene: the neighbours a flow vector reads. +struct FlowScene<'a, 'w>(&'a MobWorld<'w>); +impl FluidFlowWorld for FlowScene<'_, '_> { + type Error = std::convert::Infallible; + type Block = BlockStateId; + fn fluid(&mut self, p: BlockPos) -> Result { + Ok(self.0.fluid_tables.fluid(self.0.world.block(p.x, p.y, p.z))) + } + fn block(&mut self, p: BlockPos) -> Result { + Ok(self.0.world.block(p.x, p.y, p.z)) + } + fn blocks_fluid_flow(&self, s: BlockStateId) -> bool { + self.0.fluid_tables.blocks_flow(s) + } + fn is_ice_class(&self, s: BlockStateId) -> bool { + self.0.fluid_tables.ice(s) + } + fn is_face_sturdy( + &mut self, + s: BlockStateId, + _: BlockPos, + d: horizon_core::geometry::Direction, + ) -> Result { + Ok(self.0.fluid_tables.sturdy(s, d)) + } +} +/// The mob's velocity, for `CurrentAccumulator::apply_to`. +struct Pushed<'a>(&'a mut Vec3); +impl CurrentTarget for Pushed<'_> { + type Error = std::convert::Infallible; + fn is_player_class(&self) -> bool { + false + } + fn velocity(&mut self) -> Result { + Ok(*self.0) + } + fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Self::Error> { + let sum = Vec3::new(self.0.x + delta.x, self.0.y + delta.y, self.0.z + delta.z); + if delta.is_finite() && sum.is_finite() { + *self.0 = sum; + } + Ok(()) + } +} pub fn wrap(a: f32) -> f32 { let mut a = a % 360.0; if a >= 180.0 { @@ -140,6 +191,40 @@ impl MobWorld<'_> { } out } + /// Entity.updateFluidHeightAndDoFluidPushing's currents over the deflated + /// box: the flow of every water and lava block it touches, summed (a thin + /// fluid layer scales its flow down), as (water, lava). + fn fluid_currents(&self, bounds: Aabb) -> (CurrentAccumulator, CurrentAccumulator) { + let base_y = bounds.min_position().y; + let b = bounds.deflate(Vec3::new(0.001, 0.001, 0.001)); + let (lo, hi) = (b.min_position(), b.max_position()); + let mut scene = FlowScene(self); + let (mut water, mut lava) = (CurrentAccumulator::default(), CurrentAccumulator::default()); + for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) { + for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) { + for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) { + let p = BlockPos::new(x, y, z); + let state = self.fluid_tables.fluid(self.world.block(x, y, z)); + let current = match state.kind() { + FluidKind::Empty => continue, + FluidKind::Water | FluidKind::FlowingWater => &mut water, + FluidKind::Lava | FluidKind::FlowingLava => &mut lava, + }; + let surface = f64::from(y) + self.fluid_height(x, y, z).1; + if surface < lo.y { + continue; + } + current.height = current.height.max(surface - base_y); + let mut flow = flow_at(&mut scene, state, p).unwrap_or(Vec3::ZERO); + if current.height < 0.4 { + flow = flow.scale(current.height); + } + current.accumulate(flow); + } + } + } + (water, lava) + } /// `BlockState.getCollisionShape(level, pos)` (the empty collision /// context) as boxes in the world. pub fn block_collision_boxes(&self, x: i32, y: i32, z: i32) -> Vec { @@ -235,6 +320,26 @@ impl MobWorld<'_> { }; scene.unobstructed(b).unwrap_or(false) } + /// `LevelReader.containsAnyLiquid`: the blocks from the box's minimum + /// corner to the block holding its maximum corner. + pub fn contains_any_liquid(&self, b: Aabb) -> bool { + let (lo, hi) = (b.min_position(), b.max_position()); + let (x0, y0, z0) = ( + lo.x.floor() as i32, + lo.y.floor() as i32, + lo.z.floor() as i32, + ); + let (x1, y1, z1) = ( + hi.x.floor() as i32, + hi.y.floor() as i32, + hi.z.floor() as i32, + ); + (x0..=x1).any(|x| { + (y0..=y1).any(|y| { + (z0..=z1).any(|z| self.facts.get(self.world.block(x, y, z)).fluid != Fluid::None) + }) + }) + } } impl Mob { /// `Entity.refreshDimensions` after growing from `old_w` by `old_h`: @@ -281,6 +386,15 @@ impl Mob { return (f64::from(0.2_f32), f64::from(0.2_f32), f64::from(0.2_f32)); } let k = &self.kind; + // Avatar.POSES: a player's or mannequin's box follows its pose. + if matches!(&*k.id, "minecraft:player" | "minecraft:mannequin") { + let d = self.extra.avatar_pose.dimensions(1.0); + return ( + f64::from(d.width), + f64::from(d.height), + f64::from(d.eye_height), + ); + } let (mut w, mut h, mut e) = (k.width as f32, k.height as f32, k.eye_height as f32); // Sniffer.DIGGING_DIMENSIONS: 0.4 lower, its eyes at 0.81. if k.species == super::Species::Sniffer && super::sniffer::digging(self) { @@ -334,6 +448,27 @@ impl Mob { pub fn is_in_liquid(&self) -> bool { self.fluids.in_water || self.fluids.in_lava } + /// `Entity.isPushedByFluid`: water animals, turtles, axolotls, frogs and + /// nautiluses keep their own course. + fn pushed_by_fluid(&self) -> bool { + use super::Species as S; + !matches!( + self.species(), + S::Tadpole + | S::Cod + | S::Salmon + | S::TropicalFish + | S::Pufferfish + | S::Squid + | S::GlowSquid + | S::Dolphin + | S::Turtle + | S::Axolotl + | S::Frog + | S::Nautilus + | S::ZombieNautilus + ) + } fn affected_by_fluids(&self) -> bool { true } @@ -428,6 +563,17 @@ impl Mob { // Entity.checkSupportingBlock self.supporting = self.supporting_block(w, moved); self.movement_emission(w, moved, events); + // Entity.applyEffectsFromBlocks: bubble columns push the mob. + if crate::motion::bubble_column_push( + w.world, + w.registry, + w.facts, + w.collision, + self.bounds(), + &mut self.velocity, + ) { + self.fall_distance = 0.0; + } } /// LivingEntity.checkFallDamage: the fluid state refreshed out of water /// (touching water resets the fall), then Entity.checkFallDamage. @@ -440,16 +586,57 @@ impl Mob { } if self.on_ground { if self.fall_distance > 0.0 { + self.trample_farmland(w, events); self.cause_fall_damage(events); } self.fall_distance = 0.0; } } + /// `FarmlandBlock.fallOn`: a landing on farmland turns it to dirt with + /// chance `fallDistance - 0.5`, if mobs may grief and the mob's + /// `width * width * height` is over 0.512. `Block.pushEntitiesUp` lifts + /// the mob onto the dirt, which is a full block. + fn trample_farmland(&mut self, w: &MobWorld, events: &mut Vec) { + let p = self.block_on_legacy(w); + if w.block_name(p[0], p[1], p[2]) != "minecraft:farmland" { + return; + } + let roll = f64::from(self.random.next_float()); + let Some(dirt) = w.registry.default_state("minecraft:dirt") else { + return; + }; + if roll < self.fall_distance - 0.5 + && w.mob_griefing + && self.width() * self.width() * self.height() > 0.512 + { + events.push(MobEvent::SetBlock { + pos: p, + state: dirt, + }); + self.position.y = self.position.y.max(f64::from(p[1]) + 1.0); + } + } /// Entity.updateFluidInteraction: the fluid state, then touching water /// resets the fall and entering it (after the first tick) splashes. pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec) { let was_touching_water = self.fluids.in_water; self.fluids = w.fluids(self.bounds(), self.eye_y()); + // EntityFluidInteraction.applyCurrentTo: the flow pushes the mob. + if (self.fluids.in_water || self.fluids.in_lava) && self.pushed_by_fluid() { + let (water, lava) = w.fluid_currents(self.bounds()); + let lava_scale = if w.nether { + 0.007 + } else { + 0.0023333333333333335 + }; + let mut pushed = Pushed(&mut self.velocity); + if water.height > 0.0 { + let _ = water.apply_to(&mut pushed, 0.014); + } + if lava.height > 0.0 { + let _ = lava.apply_to(&mut pushed, lava_scale); + } + } if self.fluids.in_water { self.fall_distance = 0.0; if !was_touching_water && !self.first_tick { @@ -529,13 +716,15 @@ impl Mob { } } /// `Entity.nextStep`: the next whole metre walked; a warden's steps come - /// every 0.55, a strider's every 0.6. + /// every 0.55, a strider's every 0.6, a turtle's every 0.15. fn next_step(&self) -> f32 { let walked = self.ai.steps.move_dist; if self.kind.species == super::Species::Warden { walked + 0.55 } else if self.kind.species == super::Species::Strider { walked + 0.6 + } else if self.kind.species == super::Species::Turtle { + walked + 0.15 } else { (walked as i32).wrapping_add(1) as f32 } @@ -629,7 +818,11 @@ impl Mob { return; } let power = self.fall_distance + 1.0e-6 - self.kind.safe_fall_distance; - let damage = (power * self.kind.fall_damage_multiplier).floor(); + let mut damage = (power * self.kind.fall_damage_multiplier).floor(); + // Frog.calculateFallDamage: five points less than the base damage. + if self.species() == super::Species::Frog { + damage -= 5.0; + } if damage > 0.0 { let at = [self.position.x, self.position.y, self.position.z]; let sound = if damage > 4.0 { @@ -813,14 +1006,24 @@ impl Mob { self.velocity.y -= gravity / 4.0; } } - // jumpOutOfFluid + // jumpOutOfFluid: Entity.isFree wants the box dry and clear of the + // entities a mob collides with (canBeCollidedWith: living shulkers). let m = self.velocity; + let ahead = self.bounds().move_by(Vec3::new( + m.x, + m.y + f64::from(0.6_f32) - self.position.y + old_y, + m.z, + )); + let wide = ahead.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7)); if self.horizontal_collision - && w.no_collision(self.bounds().move_by(Vec3::new( - m.x, - m.y + f64::from(0.6_f32) - self.position.y + old_y, - m.z, - ))) + && w.no_collision(ahead) + && !w.contains_any_liquid(ahead) + && !w.others_near(wide).any(|s| { + s.id != self.id + && s.alive + && s.species == super::Species::Shulker + && s.bounds.intersects(wide) + }) { self.velocity.y = f64::from(0.3_f32); } @@ -908,7 +1111,7 @@ impl Mob { (distance * 4.0).min(1.0) }; self.limb_speed += (target - self.limb_speed) * 0.4; - self.limb_position += self.limb_speed * if self.is_baby() { 3.0 } else { 1.0 }; + self.limb_position += self.limb_speed; } else { self.limb_speed = 0.0; } diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index 9456c00a1..8be270ec1 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -359,11 +359,14 @@ fn can_attack(m: &Mob, w: &MobWorld, who: Who) -> bool { /// `Sensor.isEntityAttackable`: `TargetingConditions.forCombat()` within the /// follow range, in sight. fn attackable(m: &mut Mob, w: &MobWorld, who: Who) -> bool { + // The current ATTACK_TARGET is tested with ignoreInvisibilityTesting. + let current = mem_ref(m).attack_target.value() == Some(who); let range = m.attrs.follow_range; if !can_attack(m, w, who) { return false; } match who { + Who::Player if current => m.can_keep_target_player(w, range), Who::Player => m.can_target_player(w, range, true), Who::Mob(id) => { let Some(s) = w.seen(id) else { return false }; @@ -1137,6 +1140,7 @@ struct Quarry { position: Vec3, head_yaw: f32, height: f64, + passenger: bool, } fn quarry(w: &MobWorld, who: Who) -> Option { let t = w.target_info(who)?; @@ -1148,6 +1152,7 @@ fn quarry(w: &MobWorld, who: Who) -> Option { position: t.position, head_yaw, height: t.bounds.max_position().y - t.bounds.min_position().y, + passenger: t.passenger, }) } /// `BreezeUtil.randomPointBehindTarget`. @@ -1233,7 +1238,8 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { let firing = super::breeze::firing_y(m); let (xd, yd, zd) = ( q.position.x - m.position.x, - q.position.y + q.height * 0.3 - firing, + // target.getY(target.isPassenger() ? 0.8 : 0.3). + q.position.y + q.height * if q.passenger { 0.8 } else { 0.3 } - firing, q.position.z - m.position.z, ); let start = Vec3::new(m.position.x, firing, m.position.z); @@ -1242,14 +1248,19 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) { start, Some(Who::Mob(m.id)), ); - // The new entity's own random spreads the shot (5 - difficulty * 4 at - // normal): not the breeze's. + // The new entity's own random spreads the shot (5 - difficulty id * 4): + // not the breeze's. let mut own = LegacyRandom::new( cx.now .wrapping_mul(0x5DEE_CE66) .wrapping_add(i64::from(m.id)), ); - p.shoot(&mut own, [xd, yd, zd], 0.7, -3.0); + p.shoot( + &mut own, + [xd, yd, zd], + 0.7, + 5.0 - f32::from(cx.w.difficulty.id) * 4.0, + ); cx.events.push(MobEvent::Projectile(p)); breeze_sound(m, cx, "entity.breeze.shoot", 1.5); } diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index fff536932..b62262bb0 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -10,13 +10,19 @@ use super::{ }, }; use horizon_core::geometry::{Vec3, trig::Mth}; -use horizon_core::random::RandomSource; +use horizon_core::random::{LegacyRandom, RandomSource}; use std::f32::consts::PI; /// `PiglinAi.ADMIRE_DURATION`. pub const ADMIRE: i32 = 119; +/// `PiglinAi.eat`: the `ATE_RECENTLY` memory lasts 200 ticks (kept in +/// `extra.ints[0]` of a piglin). +pub const ATE_RECENTLY: i32 = 200; /// `AbstractPiglin.CONVERSION_TIME` (and the hoglin's). const CONVERSION: i32 = 300; +/// `PiglinAi.CELEBRATION_TIME`: how long the `CELEBRATE_LOCATION` and +/// `DANCING` memories last. +const CELEBRATION: i32 = 300; fn hostile_to_player(m: &Mob, w: &MobWorld) -> bool { if m.is_baby() { @@ -45,7 +51,9 @@ pub fn register_piglin(m: &mut Mob) { g.add(3, super::illager::crossbow_goal(1.0, 8.0)); } g.add(3, MeleeAttack::new(1.0, true)); + g.add(4, PiglinAvoidRepellent); g.add(4, GoToWantedItem); + g.add(4, Celebrate); g.add(5, Stroll::avoiding_water(0.6)); g.add(6, LookAtPlayer::new(8.0)); g.add(7, RandomLookAround::default()); @@ -176,6 +184,54 @@ impl Goal for BabyPanic { self.0.stop(m, cx) } } +/// `PiglinAi.avoidRepellent`: `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, false)` +/// of the idle activity. Closer than 8 blocks to a repellent and not walking +/// anywhere, walk away from it. +#[derive(Clone, Debug)] +struct PiglinAvoidRepellent; +impl Goal for PiglinAvoidRepellent { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some(r) = m.extra.repellent else { + return false; + }; + // The idle activity: not fighting or admiring, and no walk target yet. + if m.ai.target.is_some() || m.extra.using_item > 0 || !m.ai.nav.is_done() { + return false; + } + // Vec3.atBottomCenterOf. + let avoid = [ + f64::from(r[0]) + 0.5, + f64::from(r[1]), + f64::from(r[2]) + 0.5, + ]; + if m.distance_sqr(Vec3::new(avoid[0], avoid[1], avoid[2])) >= 64.0 { + return false; + } + for _ in 0..10 { + if let Some(p) = m.land_random_pos_away(cx.w, 16, 7, avoid) { + let at = [ + p[0].floor() as i32, + p[1].floor() as i32, + p[2].floor() as i32, + ]; + m.ai.pending_path = m.create_path_to(cx.w, at, 0); + return m.ai.pending_path.is_some(); + } + } + false + } + fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { + !m.ai.nav.is_done() + } + fn start(&mut self, m: &mut Mob, _: &mut Cx) { + let path = m.ai.pending_path.take(); + m.nav_move_to_path(path, 1.0); + } +} /// Admiring a gold item held in the off hand: stand and look at it. #[derive(Clone, Debug)] struct Admire; @@ -191,6 +247,45 @@ impl Goal for Admire { m.nav_stop(); } } +/// The celebrate activity: `GoToTargetLocation` on the spot a killed target +/// fell (within 2 blocks, or 4 and slower while dancing). +#[derive(Clone, Debug)] +struct Celebrate; +impl Celebrate { + fn walk(m: &mut Mob, cx: &mut Cx) { + let Some(at) = m.extra.home else { return }; + let (close, speed) = if m.extra.scratch[0] > 0 { + (4.0, 0.6) + } else { + (2.0, 1.0) + }; + let center = Vec3::new( + f64::from(at[0]) + 0.5, + f64::from(at[1]), + f64::from(at[2]) + 0.5, + ); + if m.distance_sqr(center) >= close * close { + m.nav_move_to(cx.w, [center.x, center.y, center.z], speed); + } + } +} +impl Goal for Celebrate { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool { + m.extra.scratch[1] > 0 && m.extra.home.is_some() && m.extra.using_item == 0 + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + Self::walk(m, cx); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + if m.ai.nav.is_done() { + Self::walk(m, cx); + } + } +} /// `GoToWantedItem`: walk to the gold the game spotted. #[derive(Clone, Debug)] struct GoToWantedItem; @@ -242,6 +337,54 @@ fn hoglin_repellent(m: &mut Mob, w: &MobWorld) { m.ai.target = None; } } +/// `StartCelebratingIfTargetDead` for a piglin (`scratch[0]` dancing, +/// `scratch[1]` ticks of celebration left, `scratch[2]` the hit seen, `home` +/// the `CELEBRATE_LOCATION`): a killed target starts a celebration at its +/// spot, and a hoglin's death makes the piglin dance one time in ten. +/// `PiglinAi.wasHurtBy` ends both. Runs before the target goals drop the +/// dead target. +pub fn celebrate(m: &mut Mob, w: &MobWorld) { + if m.extra.scratch[1] > 0 { + m.extra.scratch[1] -= 1; + if m.extra.scratch[1] == 0 { + m.extra.scratch[0] = 0; + m.extra.home = None; + } + } + if let Some(by) = m.ai.last_hurt_by + && m.ai.last_hurt_by_time != m.extra.scratch[2] + { + m.extra.scratch[2] = m.ai.last_hurt_by_time; + // PiglinAi.wasHurtBy: another piglin's hit ends nothing. + let piglin = + matches!(by, Who::Mob(id) if w.seen(id).is_some_and(|s| s.species == Species::Piglin)); + if !piglin { + m.extra.scratch[0] = 0; + m.extra.scratch[1] = 0; + m.extra.home = None; + } + } + if m.extra.scratch[1] > 0 { + return; + } + let Some(target) = m.ai.target else { return }; + let Some(t) = w.target_info(target).filter(|t| !t.alive) else { + return; + }; + let hoglin = + matches!(target, Who::Mob(id) if w.seen(id).is_some_and(|s| s.species == Species::Hoglin)); + // PiglinAi.wantsToDance: a fresh random seeded with the game time, not + // the piglin's own. + if hoglin && LegacyRandom::new(w.game_time).next_float() < 0.1 { + m.extra.scratch[0] = 1; + } + m.extra.scratch[1] = CELEBRATION; + m.extra.home = Some([ + t.position.x.floor() as i32, + t.position.y.floor() as i32, + t.position.z.floor() as i32, + ]); +} /// Per-tick piglin and hoglin state: admiring, anger, zombification and the /// hoglin attack animation. Returns a conversion when it happens. pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { @@ -257,6 +400,9 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.ai.target = None; } } + if m.species() == Species::Piglin && m.extra.ints[0] > 0 { + m.extra.ints[0] -= 1; + } // Admiring disabled after being hit by a player. if m.extra.egg_time > 0 { m.extra.egg_time -= 1; @@ -299,6 +445,26 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { m.extra.counter2 = 0; } + // PiglinSpecificSensor (every 20 ticks): the nearest repelling block. + if m.species() == Species::Piglin && m.alive() && m.age.wrapping_add(m.id).is_multiple_of(20) { + m.extra.repellent = find_piglin_repellent(m, w); + } +} +/// `PiglinSpecificSensor.findNearestRepellent`: the nearest `#piglin_repellents` +/// block within 8 blocks sideways and 4 up or down; a soul campfire only +/// counts while lit. +fn find_piglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { + let tag = w.block_tags.get("minecraft:piglin_repellents")?; + let at = [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]; + w.nearest_block(at, 8, 4, |p, name| { + tag.contains(name) + && (name != "minecraft:soul_campfire" + || w.prop(p[0], p[1], p[2], "lit") == Some("true")) + }) } /// `HoglinBase.hurtAndThrowTarget`. pub fn hoglin_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) { @@ -345,7 +511,10 @@ pub fn ears(p: &mut Poses, s: &State) { /// Piglins' and brutes' ears flap (`AbstractPiglinModel`), arms follow the /// arm pose (`PiglinModel`). pub fn piglin_anim(m: &Mob, s: &State, p: &mut Poses) { - let pose = if m.extra.using_item > 0 { + // Piglin.getArmPose: dancing comes first. + let pose = if m.extra.scratch[0] > 0 { + PiglinPose::Dancing + } else if m.extra.using_item > 0 { PiglinPose::AdmiringItem } else if m.extra.flag { PiglinPose::CrossbowCharge @@ -356,7 +525,14 @@ pub fn piglin_anim(m: &Mob, s: &State, p: &mut Poses) { } else { PiglinPose::Default }; - piglin_pose(p, s, pose, 25.0, m.extra.using_item as f32); + piglin_pose( + p, + s, + pose, + 25.0, + m.extra.using_item as f32, + m.main_arm_left(), + ); } /// `PiglinArmPose`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -368,11 +544,19 @@ pub enum PiglinPose { CrossbowCharge, AdmiringItem, } -/// `PiglinModel.setupAnim` for a piglin facing right-handed. -pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32, ticks: f32) { - use super::anim::{ArmPose, Hum, humanoid}; +/// `PiglinModel.setupAnim`; `left` when the piglin's main arm is the left one. +pub fn piglin_pose( + p: &mut Poses, + s: &State, + pose: PiglinPose, + max_charge: f32, + ticks: f32, + left: bool, +) { + use super::anim::{ArmPose, Hum, crossbow_charge, crossbow_hold, humanoid}; let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty); h.weapon_swing = pose == PiglinPose::MeleeWeapon; + h.left_main = left; h.charge = (max_charge, ticks); humanoid(p, s, &h); ears(p, s); @@ -399,27 +583,22 @@ pub fn piglin_pose(p: &mut Poses, s: &State, pose: PiglinPose, max_charge: f32, p.get("left_arm")[1] += (dance * 40.0).msin() * 0.5 + 0.5; p.get("body")[1] += (dance * 40.0).msin() * 0.35; } - PiglinPose::MeleeWeapon if s.swing == 0.0 => p.xrot("right_arm", -1.8), - PiglinPose::CrossbowHold => { - p.yrot("right_arm", -0.3 + hy); - p.yrot("left_arm", 0.6 + hy); - p.xrot("right_arm", -PI / 2.0 + hx + 0.1); - p.xrot("left_arm", -1.5 + hx); - } - PiglinPose::CrossbowCharge => { - p.yrot("right_arm", -0.8); - p.xrot("right_arm", -0.970_796_35); - p.xrot("left_arm", -0.970_796_35); - let alpha = ticks.clamp(0.0, max_charge) / max_charge; - p.yrot("left_arm", 0.4 + alpha * (0.85 - 0.4)); - let x = -0.970_796_35 + alpha * (-PI / 2.0 - -0.970_796_35); - p.xrot("left_arm", x); + // holdWeaponHigh + PiglinPose::MeleeWeapon if s.swing == 0.0 => { + p.xrot(if left { "left_arm" } else { "right_arm" }, -1.8) } + PiglinPose::CrossbowHold => crossbow_hold(p, hx, hy, !left), + PiglinPose::CrossbowCharge => crossbow_charge(p, max_charge, ticks, !left), PiglinPose::AdmiringItem => { p.xrot("head", 0.5); p.yrot("head", 0.0); - p.yrot("left_arm", 0.5); - p.xrot("left_arm", -0.9); + if left { + p.yrot("right_arm", -0.5); + p.xrot("right_arm", -0.9); + } else { + p.yrot("left_arm", 0.5); + p.xrot("left_arm", -0.9); + } } _ => {} } diff --git a/crates/horizon-client/src/mobs/polar_bear.rs b/crates/horizon-client/src/mobs/polar_bear.rs new file mode 100644 index 000000000..bfd464a93 --- /dev/null +++ b/crates/horizon-client/src/mobs/polar_bear.rs @@ -0,0 +1,191 @@ +//! Polar bears (`PolarBear`): a neutral animal that defends its cubs, +//! fights back when hurt, and rears up before each swing. A cub flees what +//! hurts it and calls the adults. +//! +//! `extra.flag` is `DATA_STANDING_ID`, `extra.counter` the warning sound's +//! delay, `extra.anger` the `NeutralMob` anger left and `extra.flag2` whether +//! the player is who it is angry at. `anim.v[STAND]` is +//! `clientSideStandAnimation`. +use horizon_core::{geometry::Vec3, random::RandomSource}; + +use super::{ + DamageKind, Mob, MobWorld, Species, Who, + goals::{ + Cx, Float, FollowParent, Goal, HurtByTarget, LookAtPlayer, MOVE, MeleeAttack, + NearestTarget, Panic, RandomLookAround, Stroll, goal_clone, + }, + world::TargetInfo, +}; + +/// `anim.v` slot of `clientSideStandAnimation`. +pub const STAND: usize = 0; +/// `STAND_ANIMATION_TICKS`. +const STAND_TICKS: f32 = 6.0; + +pub fn register(m: &mut Mob) { + let g = &mut m.ai.goals; + g.add(0, Float); + g.add(1, MeleeAttack::new(1.25, true).polar_bear()); + g.add(1, PolarBearPanic(Panic::new(2.0))); + g.add(4, FollowParent::new(1.25)); + g.add(5, Stroll::new(1.0)); + g.add(6, LookAtPlayer::new(6.0)); + g.add(7, RandomLookAround::default()); + let t = &mut m.ai.targets; + t.add(1, HurtByTarget::new().polar_bear()); + // PolarBearAttackPlayersGoal: players near a cub, within half the + // follow range, never for a cub itself. + t.add( + 2, + NearestTarget::player(true) + .interval(20) + .follow_scale(0.5) + .reach() + .gate(|m, _| !m.is_baby()) + .when(cub_nearby) + .named("PolarBearAttackPlayersGoal"), + ); + t.add( + 3, + NearestTarget::player(true).when(|m, _| m.extra.anger > 0 && m.extra.flag2), + ); + t.add( + 4, + NearestTarget::species(&[Species::Fox], true) + .interval(10) + .reach() + .when(|m, _| !m.is_baby()), + ); +} + +/// `PolarBearAttackPlayersGoal.canUse`: a polar bear cub within 8 blocks +/// (4 up and down). +fn cub_nearby(m: &Mob, w: &MobWorld) -> bool { + let area = m.bounds().inflate(Vec3::new(8.0, 4.0, 8.0)); + w.others_near(area) + .any(|s| s.species == Species::PolarBear && s.baby && s.bounds.intersects(area)) +} + +/// `PanicGoal` with a function of the bear's age for the damage tag: a cub +/// panics at `PANIC_CAUSES`, an adult only at `PANIC_ENVIRONMENTAL_CAUSES`. +#[derive(Clone, Debug)] +struct PolarBearPanic(Panic); +impl Goal for PolarBearPanic { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + (m.is_baby() || environmental_panic(m)) && self.0.can_use(m, cx) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(m, cx) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx) + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop(m, cx) + } +} +/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (fire, lava, cactus), or +/// burning. +fn environmental_panic(m: &Mob) -> bool { + m.fire_ticks > 0 && !m.kind.flags.fire_immune + || m.last_damage.is_some_and(|(d, t)| { + matches!( + d.kind, + DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Cactus + ) && i64::from(m.age) - t <= 40 + }) +} + +/// `PolarBear.setStanding`. +pub fn set_standing(m: &mut Mob, standing: bool) { + m.extra.flag = standing; +} + +/// `PolarBear.playWarningSound`. +fn play_warning_sound(m: &mut Mob, cx: &mut Cx) { + if m.extra.counter <= 0 { + m.voice("entity.polar_bear.warning", cx.events); + m.extra.counter = 40; + } +} + +/// `PolarBearMeleeAttackGoal.checkAndPerformAttack`: strike when ready and +/// in reach, rear up and growl in the last 10 ticks while close. +pub fn check_and_perform_attack( + until_attack: &mut i32, + cooldown: i32, + m: &mut Mob, + who: Who, + t: &TargetInfo, + cx: &mut Cx, +) { + if *until_attack <= 0 && m.within_melee_range(t) && m.sees(cx.w, who) { + *until_attack = cooldown; + super::ai::do_hurt_target(m, who, cx); + set_standing(m, false); + return; + } + let reach = t.bounds.size().x as f32 + 3.0; + if m.distance_sqr(t.position) < f64::from(reach * reach) { + if *until_attack <= 0 { + set_standing(m, false); + *until_attack = cooldown; + } + if *until_attack <= 10 { + set_standing(m, true); + play_warning_sound(m, cx); + } + } else { + *until_attack = cooldown; + set_standing(m, false); + } +} + +/// `PolarBear.tick`'s client half: `clientSideStandAnimation` follows the +/// synched standing flag by one tick a tick. A client of a server runs it +/// too. +pub fn client_tick(m: &mut Mob) { + let v = m.anim.v[STAND]; + m.anim.vo[STAND] = v; + let step = if m.extra.flag { 1.0 } else { -1.0 }; + m.anim.v[STAND] = (v + step).clamp(0.0, STAND_TICKS); +} +/// `PolarBear.getStandingAnimationScale`. +pub fn standing_scale(m: &Mob, partial: f32) -> f32 { + m.anim.lerp(STAND, partial) / STAND_TICKS +} + +/// `PolarBear.tick`: the stand animation, the warning sound's delay and +/// `NeutralMob.updatePersistentAnger`. +pub fn ai_step(m: &mut Mob, w: &MobWorld) { + client_tick(m); + if m.extra.counter > 0 { + m.extra.counter -= 1; + } + if m.ai.target.is_some() { + // Anger follows the target and starts over every tick it is held: + // 20 to 39 seconds (`PERSISTENT_ANGER_TIME`). + m.extra.flag2 = m.ai.target == Some(Who::Player); + m.extra.anger = 400 + m.random.next_int_bound(381).unwrap_or(0); + } else if m.extra.anger > 0 { + m.extra.anger -= 1; + if m.extra.anger == 0 { + stop_being_angry(m); + } + } + // A player in creative or spectator mode is forgiven at once. + if m.extra.flag2 && w.player.as_ref().is_some_and(|p| p.creative || p.spectator) { + stop_being_angry(m); + } +} +/// `NeutralMob.stopBeingAngry`. +fn stop_being_angry(m: &mut Mob) { + m.ai.last_hurt_by = None; + m.ai.target = None; + m.extra.anger = 0; + m.extra.flag2 = false; +} diff --git a/crates/horizon-client/src/mobs/rabbit.rs b/crates/horizon-client/src/mobs/rabbit.rs index a2ab6e95c..ac4c3850f 100644 --- a/crates/horizon-client/src/mobs/rabbit.rs +++ b/crates/horizon-client/src/mobs/rabbit.rs @@ -1,5 +1,9 @@ //! Rabbit hopping controls and the clocks consumed by `RabbitModel`. -use super::{Mob, MobEvent, MobWorld, control::Operation}; +use super::{ + Mob, MobEvent, MobWorld, + control::Operation, + goals::{Breed, Tempt}, +}; use horizon_core::{ geometry::{Vec3, trig}, random::RandomSource, @@ -14,7 +18,12 @@ const IDLE_TIMEOUT: usize = 3; const CLIENT_JUMP_TICKS: usize = 4; const CLIENT_JUMP_DURATION: usize = 5; +/// Rabbit.registerGoals after the generic panic and stroll: BreedGoal and +/// TemptGoal with rabbit food. pub fn register(m: &mut Mob) { + let food = m.kind.food.clone(); + m.ai.goals.add(2, Breed::new(0.8)); + m.ai.goals.add(3, Tempt::new(1.0, food, false)); m.ai.mov.kind = super::control::MoveKind::Custom; m.ai.nav.can_float = true; m.extra.ints[IDLE_TIMEOUT] = m.random.next_int_bound(40).unwrap_or(0) + 180; diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 6c3ab9b64..9b1d17eed 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -30,6 +30,9 @@ pub struct State { pub celebrating: bool, /// The raid is active (`hasActiveRaid`); set by the game every tick. pub active_raid: bool, + /// A raid is active within 96 blocks (`ServerLevel.isRaided`); set by + /// the game every tick for vindicators. + pub raided: bool, /// The raid is won or lost (`Raid.isOver`), lost (`isLoss`). pub raid_over: bool, pub raid_loss: bool, @@ -38,6 +41,11 @@ pub struct State { pub in_village: bool, /// A dropped ominous banner this raider may fetch. pub banner_at: Option<[f64; 3]>, + /// The item entity of `banner_at`. + pub banner_item: u32, + /// Banners with no path to them, and until when (game time): + /// `unreachableBannerCache`. + pub unreachable_banners: Vec<(u32, i64)>, /// Beds around the raid's centre. pub homes: Option>>, /// A ravager carrying a raider, set every tick from `Extra::rider`. @@ -113,7 +121,7 @@ pub fn register(m: &mut Mob) { } m.extra.raider = Some(Box::default()); m.ai.goals.add(4, LongDistancePatrol::new(0.7, 0.595)); - m.ai.goals.add(1, ObtainBanner); + m.ai.goals.add(1, ObtainBanner::default()); m.ai.goals.add(3, PathfindToRaid); m.ai.goals.add(4, MoveThroughVillage::new(1.05, 1.0)); m.ai.goals.add(5, Celebration); @@ -336,23 +344,37 @@ fn close_to_center(p: [i32; 3], at: Vec3, distance: f64) -> bool { /// `Raider.ObtainRaidLeaderBannerGoal`: walk to a fallen ominous banner when /// the wave has no living leader (the game finds the banner and picks it up). -#[derive(Clone, Debug)] -pub struct ObtainBanner; +#[derive(Clone, Debug, Default)] +pub struct ObtainBanner { + path_to_banner: Option, +} impl Goal for ObtainBanner { goal_clone!(); fn flags(&self) -> u8 { MOVE } - fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { - state(m).is_some_and(|s| s.banner_at.is_some()) + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + let Some((at, item)) = state(m).and_then(|s| s.banner_at.map(|at| (at, s.banner_item))) + else { + return false; + }; + // A banner with no path to it is skipped for 600 ticks. + let path = m.create_path_to_entity(cx.w, at, 1); + if path.as_ref().is_some_and(|p| p.reached) { + self.path_to_banner = path; + return true; + } + let now = cx.w.game_time; + let s = state_mut(m); + s.unreachable_banners.retain(|&(_, until)| now < until); + s.unreachable_banners.push((item, now + 600)); + false } fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { !m.ai.nav.is_done() && state(m).is_some_and(|s| s.banner_at.is_some()) } - fn start(&mut self, m: &mut Mob, cx: &mut Cx) { - if let Some(at) = state(m).and_then(|s| s.banner_at) { - m.nav_move_to(cx.w, at, 1.15); - } + fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { + m.nav_move_to_path(self.path_to_banner.take(), 1.15); } } diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 077fd38a6..bbed917a4 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -14,6 +14,12 @@ use std::f32::consts::PI; /// A texture lookup into the shared atlas (name without namespace -> uv rect). pub type Atlas<'a> = &'a dyn Fn(&str) -> Option<[f32; 4]>; +/// The overlay code of an enchanted armour layer's glint. 7 and 8 are the +/// item glint and 9 to 12 Horizon's End-portal and breaking overlays, so 13 +/// marks the `ARMOR_ENTITY_GLINT_TEXTURING` sample: entity.wgsl transforms +/// the model UV and reads `enchanted_glint_armor`. +pub const ARMOR_GLINT: u32 = 13; + /// One draw of a model with a texture. pub struct Layer { pub model: String, @@ -177,8 +183,71 @@ pub fn body(m: &Mob) -> (String, String) { "frog#main".into(), format!("entity/frog/frog_{}", climate(m)), ), - S::Rabbit => ( - baby_or(m, "rabbit#main", "rabbit_baby#main"), + // RabbitRenderer.getTextureLocation: the Toast name tag, else the + // variant (the killer bunny's id is 99). + S::Rabbit => { + let name = if m.custom_name.as_deref() == Some("Toast") { + "toast" + } else { + match m.extra.variant { + 1 => "white", + 2 => "black", + 3 => "white_splotched", + 4 => "gold", + 5 => "salt", + 99 => "caerbannog", + _ => "brown", + } + }; + ( + baby_or(m, "rabbit#main", "rabbit_baby#main"), + b(&format!("entity/rabbit/rabbit_{name}")), + ) + } + // ParrotRenderer.getVariantTexture. + S::Parrot => { + let name = ["red_blue", "blue", "green", "yellow_blue", "grey"] + [usize::from(m.extra.variant).min(4)]; + (m.kind.model_key(), format!("entity/parrot/parrot_{name}")) + } + // AxolotlRenderer.getTextureLocation. + S::Axolotl => { + let name = + ["lucy", "wild", "gold", "cyan", "blue"][usize::from(m.extra.variant).min(4)]; + ( + baby_or(m, "axolotl#main", "axolotl_baby#main"), + b(&format!("entity/axolotl/axolotl_{name}")), + ) + } + // SalmonRenderer.submit: the model of the salmon's size. + S::Salmon => { + let model = match m.extra.variant { + 0 => "salmon_small#main", + 2 => "salmon_large#main", + _ => "salmon#main", + }; + (model.into(), "entity/fish/salmon".into()) + } + // The baby models and textures of AgeableMobRenderer's other + // subclasses. + S::Goat => ( + baby_or(m, "goat#main", "goat_baby#main"), + b(&m.kind.texture), + ), + S::PolarBear => ( + baby_or(m, "polar_bear#main", "polar_bear_baby#main"), + b(&m.kind.texture), + ), + S::Ocelot => ( + baby_or(m, "ocelot#main", "ocelot_baby#main"), + b(&m.kind.texture), + ), + S::Turtle => ( + baby_or(m, "turtle#main", "turtle_baby#main"), + b(&m.kind.texture), + ), + S::Armadillo => ( + baby_or(m, "armadillo#main", "armadillo_baby#main"), b(&m.kind.texture), ), S::Pig => { @@ -241,7 +310,7 @@ pub fn body(m: &Mob) -> (String, String) { ), S::ZombieVillager => ( baby_or(m, "zombie_villager#main", "zombie_villager_baby#main"), - "entity/zombie_villager/zombie_villager".into(), + b("entity/zombie_villager/zombie_villager"), ), S::Villager => ( baby_or(m, "villager#main", "villager_baby#main"), @@ -601,14 +670,6 @@ pub fn layers(m: &Mob) -> Vec { white, false, ), - // EnergySwirlLayer (the scroll is not reproduced: atlas sprites do not wrap). - S::Creeper if m.extra.charged => push( - &mut v, - "creeper#armor", - "entity/creeper/creeper_armor", - 0x80FF_FFFF, - true, - ), S::TropicalFish => { // TropicalFishPatternLayer: the pattern tinted with the pattern colour. let (model, letter, n) = if m.extra.variant < 6 { @@ -765,7 +826,14 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 let rotation = match sleep { // setupRotations for Pose.SLEEPING, with the head moved toward the pillow. Some((angle, step)) => { - let head = (m.eye_y() - m.position.y) as f32 - 0.1; + // The eye height of the standing pose (LivingEntityRenderer + // extracts `getEyeHeight(Pose.STANDING)` for a bed). + let eye = if avatar(m) { + m.kind.eye_height as f32 + } else { + (m.eye_y() - m.position.y) as f32 + }; + let head = eye - 0.1; glam::Mat4::from_translation(glam::Vec3::new(-step[0] * head, 0.0, -step[1] * head)) * glam::Mat4::from_rotation_y(angle.to_radians()) * glam::Mat4::from_rotation_z(90.0_f32.to_radians()) @@ -833,10 +901,15 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 base *= species_scale(m, alpha); base * glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0)) } -/// `LivingEntityRenderer.isShaking`: converting zombies, piglins and -/// hoglins, and a zombie villager being cured. +/// `LivingEntityRenderer.isShaking`: a fully frozen mob (the skeleton's own +/// `isShaking` is its stray conversion only), converting zombies, piglins +/// and hoglins, a zombie villager being cured and a cold strider. fn shaking(m: &Mob) -> bool { + if m.is_fully_frozen() && m.species() != Species::Skeleton { + return true; + } match m.species() { + Species::Strider => super::strider::suffocating(m), Species::ZombieVillager => super::zombie_villager::is_converting(m), Species::Zombie | Species::Husk => m.extra.counter2 > 0, Species::Piglin | Species::PiglinBrute | Species::Hoglin => { @@ -958,6 +1031,13 @@ fn draw_normals( let normal = poly.vertex_normal(normals); for v in poly.vertices { let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0); + // The armour glint scrolls the layer's model UV in the shader; + // the item path already passed its transformed UV. + let glint_uv = if overlay == ARMOR_GLINT { + [v[3], v[4]] + } else { + [0.0, 0.0] + }; out.push(Vertex { normal, pos: pos.to_array(), @@ -968,6 +1048,7 @@ fn draw_normals( layer: overlay, color, light, + glint_uv, }); } } @@ -1128,9 +1209,9 @@ fn render_visible( light }; // EquipmentLayerRenderer draws armour with OverlayTexture.NO_OVERLAY: never - // the hurt tint, the glint (7) when enchanted. + // the hurt tint, the armour glint (13) when enchanted. let overlay = match armor_slot(&layer.model) { - Some(slot) if super::gear::enchanted(m, slot) => 7, + Some(slot) if super::gear::enchanted(m, slot) => ARMOR_GLINT, Some(_) => 0, None => overlay, }; @@ -1151,10 +1232,50 @@ fn render_visible( } } } + // EnergySwirlLayer: the armour model, additive, scrolling over the sprite. + if let Some((model, texture, x_offset)) = energy_swirl(m, s.age) + && let Some(part) = m.kind.layer(model) + && let Some([u0, v0, u1, v1]) = atlas(texture) + { + layer_poses.reset(part); + anim::setup(m, &s, &mut layer_poses, &data.animations); + // `RenderTypes.energySwirl`'s texture offset, as the sprite-local UV. + let (du, dv) = (x_offset % 1.0, s.age * 0.01 % 1.0); + let start = out.len(); + draw( + part, + base, + &layer_poses, + [du, dv, 1.0 + du, 1.0 + dv], + crate::breaking::stage_light([u1 - u0, v1 - v0]), + crate::breaking::stage_light([u0, v0]), + crate::gfx::ENERGY_SWIRL_OVERLAY, + out, + ); + // No lighting: the colour bytes carry the sprite's size (fs_energy_swirl). + for vertex in &mut out[start..] { + vertex.normal = [0; 4]; + } + } if shell_last { draw_body(out); } } +/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper +/// (`CreeperPowerLayer`) or a powered wither (`WitherArmorLayer`); `t` is `ageInTicks`. +fn energy_swirl(m: &Mob, t: f32) -> Option<(&'static str, &'static str, f32)> { + match m.species() { + Species::Creeper if m.extra.charged => { + Some(("creeper#armor", "entity/creeper/creeper_armor", t * 0.01)) + } + Species::Wither if super::wither::powered(m) => Some(( + "wither#armor", + "entity/wither/wither_armor", + (t * 0.02).mcos() * 3.0, + )), + _ => None, + } +} /// The equipment slot of a `HumanoidArmorLayer` model (`zombie#helmet`). fn armor_slot(model: &str) -> Option { Some(match model.rsplit('#').next()? { @@ -1235,6 +1356,15 @@ fn hand_base( 0.0 }; let mut t = part_matrix(model, base, poses, arm, offset)?; + if m.species() == Species::Vex { + // `VexModel.translateToHand`: the held item is drawn at 0.55 scale. + t *= glam::Mat4::from_scale(glam::Vec3::splat(0.55)); + t *= glam::Mat4::from_translation(glam::Vec3::new( + if left { -0.046_875 } else { 0.046_875 }, + -0.156_25, + 0.078_125, + )); + } let (ox, oy, oz) = if m.is_baby() { (0.0, 1.0, -4.5) } else { @@ -1421,4 +1551,40 @@ mod tests { assert!(vertices.iter().all(|v| v.uv[0] >= 0.5)); } } + /// `VexModel.translateToHand`: the held item is drawn at 0.55 scale and + /// moved by the vex's own offset, so the vex's sword is the tiny one + /// vanilla shows. It was drawn at the full size, like a zombie's. + #[test] + fn the_vex_holds_its_item_at_the_small_scale() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("entity-models.json").exists() { + return; + } + let types = super::super::MobTypes::load(&root).unwrap(); + let kind = std::sync::Arc::new(types.types["minecraft:vex"].clone()); + let mob = Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0); + let data = MobData { + states: Vec::new(), + placements: Default::default(), + heightmaps: Default::default(), + animations: Default::default(), + biome_tags: Default::default(), + entity_tags: Default::default(), + villager_hats: Default::default(), + pois: Default::default(), + }; + for left in [false, true] { + let t = held_item_transform(&mob, 0.5, glam::DVec3::ZERO, &data, left).unwrap(); + // The model's own part scale is 1, so the item's scale is the + // renderer's: `poseStack.scale(0.55F, 0.55F, 0.55F)`. + for axis in [t.x_axis, t.y_axis, t.z_axis] { + let scale = axis.truncate().length(); + assert!( + (scale - 0.55).abs() < 1.0e-5, + "the vex's item is drawn at 0.55 scale, got {scale}" + ); + } + } + } } diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index 11ebdadb4..c510409d9 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -3,8 +3,9 @@ //! their face is lost or when badly hurt, and shrugging off arrows while //! closed. use super::{ - Damage, DamageKind, Mob, MobEvent, MobWorld, Who, + Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, + control::LookKind, goals::{ Cx, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, NearestTarget, RandomLookAround, goal_clone, reduced, @@ -14,7 +15,7 @@ use crate::projectiles::{Projectile, ProjectileWorld, STEPS}; use horizon_core::geometry::trig::Mth; use horizon_core::{ geometry::{Aabb, Vec3}, - random::RandomSource, + random::{LegacyRandom, RandomSource}, }; use std::f32::consts::PI; @@ -37,7 +38,13 @@ pub fn register(m: &mut Mob) { g.add(4, ShulkerAttack { attack_time: 0 }); g.add(7, Peek { time: 0 }); g.add(8, RandomLookAround::default()); - m.ai.targets.add(1, HurtByTarget::new().alert()); + // `ShulkerLookControl`: the head turns in the attach face's plane. + m.ai.look.kind = LookKind::Shulker; + // `HurtByTargetGoal(this, Shulker.class).setAlertOthers()`: damage from + // another shulker draws no retaliation (`toIgnoreDamage`), but a hurt + // shulker alerts every targetless shulker around. + m.ai.targets + .add(1, HurtByTarget::new().alert().ignoring(&[Species::Shulker])); // ShulkerNearestAttackGoal: the search box is only 4 deep along the // attach axis. m.ai.targets.add( @@ -57,6 +64,10 @@ pub fn register(m: &mut Mob) { fn face_step(face: u8) -> [i32; 3] { STEPS[face.min(5) as usize] } +/// `Direction.getOpposite` for a 3D data value. +fn opposite(face: u8) -> u8 { + face.min(5) ^ 1 +} fn block_pos(m: &Mob) -> [i32; 3] { [ m.position.x.floor() as i32, @@ -64,19 +75,83 @@ fn block_pos(m: &Mob) -> [i32; 3] { m.position.z.floor() as i32, ] } -/// `Shulker.canStayAt`: an empty spot against a sturdy face. -fn can_stay_at(w: &MobWorld, p: [i32; 3], face: u8) -> bool { - if !w.facts.is_air(w.world.block(p[0], p[1], p[2])) { +/// `Shulker.getProgressDeltaAabb` at scale 1: the shell's box from `pos` +/// (the bottom centre), grown `from`..`to` along `direction`. +fn progress_aabb(direction: u8, from: f64, to: f64, pos: Vec3) -> Aabb { + let s = face_step(direction).map(f64::from); + let (max, min) = (from.max(to), from.min(to)); + Aabb::new(Vec3::new(-0.5, 0.0, -0.5), Vec3::new(0.5, 1.0, 0.5)) + .expand_towards(Vec3::new(s[0] * max, s[1] * max, s[2] * max)) + .contract(Vec3::new( + -s[0] * (1.0 + min), + -s[1] * (1.0 + min), + -s[2] * (1.0 + min), + )) + .move_by(pos) +} +/// `Level.noCollision(shulker, aabb)`: no block collision under the empty +/// context, and none of the entities a shulker collides with overlaps +/// (`canBeCollidedWith`: living shulkers and adult happy ghasts; players +/// and other mobs never block it). +fn no_collision(m: &Mob, w: &MobWorld, bounds: Aabb) -> bool { + let mut scene = crate::collisions::SceneCollisions { + world: w.world, + binding: w.collision, + subject: None, + }; + if !crate::collisions::no_collision_empty(&mut scene, bounds) { return false; } + let grown = bounds.inflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7)); + !w.others_near(grown).any(|s| { + s.id != m.id + && s.alive + && (s.species == Species::Shulker || (s.species == Species::HappyGhast && !s.baby)) + && s.bounds.intersects(grown) + }) +} +/// `Shulker.canStayAt`: an empty spot against a sturdy face, with room +/// for the fully opened shell (`canStayAt`). +fn can_stay_at(m: &Mob, w: &MobWorld, p: [i32; 3], face: u8) -> bool { + // isPositionBlocked: anything but air blocks, except a moving piston + // being driven through the shulker's own cell. + if !w.facts.is_air(w.world.block(p[0], p[1], p[2])) + && (p != block_pos(m) || w.block_name(p[0], p[1], p[2]) != "minecraft:moving_piston") + { + return false; + } + // loadedAndEntityCanStandOnFace: the supporting block's face toward + // the shell is sturdy. let s = face_step(face); - let n = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]); - w.facts.get(n).full_collision + let support = w.world.block(p[0] + s[0], p[1] + s[1], p[2] + s[2]); + let open = opposite(face); + if w.facts.get(support).sturdy_full & (1 << open) == 0 { + return false; + } + // The fully opened shell's box, shrunk by the skin vanilla uses. + let opened = progress_aabb( + open, + -1.0, + 1.0, + Vec3::new( + f64::from(p[0]) + 0.5, + f64::from(p[1]), + f64::from(p[2]) + 0.5, + ), + ) + .deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6)); + no_collision(m, w, opened) } /// `findAttachableSurface`: the first face (down, up, north, south, west, /// east) to cling to. -fn attachable(w: &MobWorld, p: [i32; 3]) -> Option { - (0..6).find(|&f| can_stay_at(w, p, f)) +fn attachable(m: &Mob, w: &MobWorld, p: [i32; 3]) -> Option { + (0..6).find(|&f| can_stay_at(m, w, p, f)) +} +/// `BlockUtil.packDifferenceInPosition` within 8 blocks of `pos`. +fn pack_diff(pos: [i32; 3], to: [i32; 3]) -> i32 { + ((to[0] - pos[0] + 8) & 0xFF) << 16 + | ((to[1] - pos[1] + 8) & 0xFF) << 8 + | (to[2] - pos[2] + 8) & 0xFF } /// `Shulker.teleportSomewhere`: up to five tries within 8 blocks. fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec) -> bool { @@ -87,12 +162,31 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec) -> bool { for _ in 0..5 { let r = |m: &mut Mob| m.random.next_int_bound(17).unwrap_or(8) - 8; let t = [old[0] + r(m), old[1] + r(m), old[2] + r(m)]; - if t[1] <= crate::chunk::MIN_Y || !w.facts.is_air(w.world.block(t[0], t[1], t[2])) { + let unit = Aabb::new( + Vec3::new(f64::from(t[0]), f64::from(t[1]), f64::from(t[2])), + Vec3::new( + f64::from(t[0]) + 1.0, + f64::from(t[1]) + 1.0, + f64::from(t[2]) + 1.0, + ), + ); + if t[1] <= crate::chunk::MIN_Y + || !w.facts.is_air(w.world.block(t[0], t[1], t[2])) + || !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6))) + { continue; } - let Some(face) = attachable(w, t) else { + let Some(face) = attachable(m, w, t) else { continue; }; + // `#minecraft:shulker_does_not_teleport_to` (bedrock) is never clung to. + let s = face_step(face); + if w.has_tag( + "minecraft:shulker_does_not_teleport_to", + w.block_name(t[0] + s[0], t[1] + s[1], t[2] + s[2]), + ) { + continue; + } m.extra.variant3 = face; events.push(MobEvent::Sound( "entity.shulker.teleport", @@ -106,6 +200,14 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec) -> bool { f64::from(t[2]) + 0.5, ); m.previous = m.position; + // Level.levelEvent(2016, oldPos, packedDiff): the teleport's + // particles at both ends, seen by the players around. + events.push(MobEvent::LevelEvent { + id: 2016, + pos: old, + data: pack_diff(old, t), + }); + m.needs_sync = true; m.extra.counter = 0; m.ai.target = None; return true; @@ -127,31 +229,109 @@ fn set_peek(m: &mut Mob, amount: i32, events: &mut Vec) { )); m.extra.counter = amount; } -/// `Shulker.tick` additions: keep a face (or teleport), ease the peek, -/// stay still and square on its block. +/// `Shulker.updatePeekAmount`/`updatePeekAmountO`: the eased peek. +fn ease_peek(m: &mut Mob) -> (f32, f32) { + let old = m.anim.v[PEEK]; + let target = m.extra.counter as f32 * 0.01; + let new = if old > target { + (old - 0.05).clamp(target, 1.0) + } else { + (old + 0.05).clamp(0.0, target) + }; + m.anim.v[PEEK] = new; + (old, new) +} +/// `Shulker.tick` additions: keep a face (or teleport), ease the peek +/// (pushing whatever it opens into), stay still and square on its block. pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { m.velocity = Vec3::ZERO; m.body_yaw = 0.0; m.yaw = 0.0; m.head_pitch = 0.0; let p = block_pos(m); - if !can_stay_at(w, p, m.extra.variant3) { - match attachable(w, p) { + if !can_stay_at(m, w, p, m.extra.variant3) { + match attachable(m, w, p) { Some(f) => m.extra.variant3 = f, None => { teleport(m, w, events); } } } - let a = &mut m.anim; - let target = m.extra.counter as f32 * 0.01; - let cur = a.v[PEEK]; - if cur > target { - a.v[PEEK] = (cur - 0.05).clamp(target, 1.0); - } else if cur < target { - a.v[PEEK] = (cur + 0.05).clamp(0.0, target); + let (old, new) = ease_peek(m); + if new != old { + on_peek_change(m, w, old, new, events); } } +/// `Shulker.getPhysicalPeek`: the lid's lift for a peek amount. +fn physical_peek(amount: f32) -> f64 { + f64::from(0.5 - ((0.5 + amount) * PI).msin() * 0.5) +} +/// `Shulker.onPeekAmountChange`: an opening shell pushes out of its way +/// every entity that can be collided with (except other shulkers). +fn on_peek_change(m: &Mob, w: &MobWorld, old: f32, new: f32, events: &mut Vec) { + let dir = opposite(m.extra.variant3); + let s = face_step(dir).map(f64::from); + let (old_p, new_p) = (physical_peek(old), physical_peek(new)); + let push = new_p - old_p; + if push <= 0.0 { + return; + } + let delta = [push * s[0], push * s[1], push * s[2]]; + let area = progress_aabb(dir, old_p, new_p, m.position); + if let Some(p) = &w.player + && p.alive + && !p.spectator + && p.bounds.intersects(area) + { + events.push(MobEvent::Shift { + target: Who::Player, + delta, + }); + } + for seen in w.others_near(area) { + if seen.id == m.id + || !seen.alive + || seen.species == Species::Shulker + || !seen.bounds.intersects(area) + { + continue; + } + events.push(MobEvent::Shift { + target: Who::Mob(seen.id), + delta, + }); + } +} +/// `ShulkerLookControl.getYRotD`: the look yaw in the open direction's +/// plane (`None`: the target is on the head's own axis). +pub fn look_yaw(m: &Mob, at: [f64; 3]) -> Option { + let open = opposite(m.extra.variant3); + // `attachFace.getRotation().transform(FORWARD)`: the model's forward + // rotated as the renderer does (`rotations` uses the same quats). + let forward = face_quat(m.extra.variant3) * glam::Vec3::Z; + let s = face_step(open).map(|v| v as f32); + let right = glam::Vec3::from_array(s).cross(forward); + let out = glam::Vec3::new( + (at[0] - m.position.x) as f32, + (at[1] - m.eye_y()) as f32, + (at[2] - m.position.z) as f32, + ); + let (delta_right, delta_forward) = (right.dot(out), forward.dot(out)); + if delta_right.abs() <= 1.0e-5 && delta_forward.abs() <= 1.0e-5 { + return None; + } + Some(horizon_core::geometry::trig::atan2_degrees( + f64::from(-delta_right), + f64::from(delta_forward), + )) +} +/// `Shulker.tick` on a client that is not the server: the lid eases to the +/// peek it was sent, and its body stays square (`ShulkerBodyRotationControl`). +pub fn client_tick(m: &mut Mob) { + m.anim.vo = m.anim.v; + ease_peek(m); + m.body_yaw = 0.0; +} /// `Shulker.hurtServer` before the damage: arrows bounce off a closed shell. pub fn accepts(m: &Mob, source: &Damage) -> bool { !(m.extra.counter == 0 && source.kind == DamageKind::Arrow) @@ -164,12 +344,62 @@ pub fn after_hurt(m: &mut Mob) { m.extra.flag = true; } } -/// The deferred teleport of `after_hurt`. -pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { +/// `Shulker.hurtServer` after a shulker bullet's damage: unless the +/// low-health teleport was drawn instead (`else if`), `hitByShulkerBullet` +/// runs on the next tick. Whether the shell is open is read here, at hurt +/// time, so a goal opening it before that tick cannot change the outcome. +pub fn hit_by_bullet(m: &mut Mob) { + if m.species() == Species::Shulker && !m.extra.flag && m.extra.counter != 0 { + m.extra.flag2 = true; + } +} +/// The deferred teleport of `after_hurt` and `hitByShulkerBullet`. +pub fn ai_step( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { if m.extra.flag { m.extra.flag = false; teleport(m, w, events); } + if m.extra.flag2 { + m.extra.flag2 = false; + hit_by_shulker_bullet(m, w, level_random, events); + } +} +/// `Shulker.hitByShulkerBullet` (only queued by an open shulker): it +/// teleports, and leaves a new one of its colour behind unless shulkers +/// crowd the place (one in five fewer chances for every other shulker +/// within 8 blocks). +fn hit_by_shulker_bullet( + m: &mut Mob, + w: &MobWorld, + level_random: &mut LegacyRandom, + events: &mut Vec, +) { + let old_position = m.position; + let old_box = m.bounds(); + if !teleport(m, w, events) { + return; + } + let area = old_box.inflate(Vec3::new(8.0, 8.0, 8.0)); + // The shulker itself stands within reach of its old box, so it counts. + let others = w + .others_near(area) + .filter(|s| { + s.alive && s.species == Species::Shulker && s.id != m.id && s.bounds.intersects(area) + }) + .count(); + let failure_chance = others as f32 / 5.0; + // The level's random, not the shulker's. + if level_random.next_float() < failure_chance { + return; + } + let mut baby = Mob::new(0, m.kind.clone(), old_position, 0.0); + baby.extra.color = m.extra.color; + events.push(MobEvent::Spawn(Box::new(baby))); } /// A ProjectileWorld over the mob world, for aiming a new bullet. struct Probe<'a, 'w> { @@ -269,7 +499,7 @@ impl Goal for Peek { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { m.ai.target.is_none() && m.random.next_int_bound(reduced(40)).unwrap_or(1) == 0 - && can_stay_at(cx.w, block_pos(m), m.extra.variant3) + && can_stay_at(m, cx.w, block_pos(m), m.extra.variant3) } fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { m.ai.target.is_none() && self.time > 0 @@ -312,24 +542,27 @@ pub fn texture(m: &Mob) -> String { None => "entity/shulker/shulker".into(), } } +/// `attachFace.getOpposite().getRotation()` by attach face: the open +/// direction's rotation (`Direction.getRotation` of `face ^ 1`). +fn face_quat(face: u8) -> glam::Quat { + match face.min(5) { + // Attached below: upright. + 0 => glam::Quat::IDENTITY, + 1 => glam::Quat::from_rotation_x(PI), + 2 => glam::Quat::from_rotation_x(PI / 2.0), + 3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI), + 4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0), + _ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0), + } +} /// `ShulkerRenderer.setupRotations`: turned onto its attach face around the /// block's centre (after a half turn of the body). pub fn rotations(m: &Mob) -> glam::Mat4 { - { - let q = match m.extra.variant3 { - // Attached below: upright. - 0 => glam::Quat::IDENTITY, - 1 => glam::Quat::from_rotation_x(PI), - 2 => glam::Quat::from_rotation_x(PI / 2.0), - 3 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI), - 4 => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, -PI / 2.0), - _ => glam::Quat::from_euler(glam::EulerRot::XYZ, PI / 2.0, 0.0, PI / 2.0), - }; - glam::Mat4::from_rotation_y(PI) - * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0)) - * glam::Mat4::from_quat(q) - * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0)) - } + let q = face_quat(m.extra.variant3); + glam::Mat4::from_rotation_y(PI) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.5, 0.0)) + * glam::Mat4::from_quat(q) + * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.5, 0.0)) } /// `ShulkerModel.setupAnim`: the lid rises and twists with the peek, the /// head turns. diff --git a/crates/horizon-client/src/mobs/sniffer.rs b/crates/horizon-client/src/mobs/sniffer.rs index 6979171b8..72aa76cc2 100644 --- a/crates/horizon-client/src/mobs/sniffer.rs +++ b/crates/horizon-client/src/mobs/sniffer.rs @@ -74,6 +74,30 @@ pub fn head_block(m: &Mob) -> [i32; 3] { /// the dropped seed and the water malus. pub fn ai_step(m: &mut Mob, events: &mut Vec) { use super::path::PathType; + let current = state(m); + animate(m); + if current == DIGGING && now(m) == m.extra.ints[3] { + let h = head_block(m); + events.push(MobEvent::GiftLoot { + mob: m.id, + table: "minecraft:gameplay/sniffer_digging", + at: [f64::from(h[0]), f64::from(h[1]), f64::from(h[2])], + }); + events.push(MobEvent::Sound( + "entity.sniffer.drop_seed", + m.center(), + 1.0, + 1.0, + )); + } + // The searching sound is the client's (`playSearchingSound`). + // onPathfindingStart / Done: water only blocks the way while it is dry. + let wet = m.fluids.in_water || m.fire_ticks > 0; + m.set_malus(PathType::Water, if wet { 0.0 } else { -1.0 }); +} +/// `Sniffer.onSyncedDataUpdated(DATA_STATE)`: the animation states of the +/// state it changed to (on a client, each tick for the state it has). +pub fn animate(m: &mut Mob) { let age = m.age as f32; let current = state(m); if current != m.extra.scratch[0] as u8 { @@ -91,23 +115,6 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { } m.extra.scratch[0] = i32::from(current); } - if current == DIGGING && now(m) == m.extra.ints[3] { - let h = head_block(m); - events.push(MobEvent::GiftLoot { - table: "minecraft:gameplay/sniffer_digging", - at: [f64::from(h[0]), f64::from(h[1]), f64::from(h[2])], - }); - events.push(MobEvent::Sound( - "entity.sniffer.drop_seed", - m.center(), - 1.0, - 1.0, - )); - } - // The searching sound is the client's (`playSearchingSound`). - // onPathfindingStart / Done: water only blocks the way while it is dry. - let wet = m.fluids.in_water || m.fire_ticks > 0; - m.set_malus(PathType::Water, if wet { 0.0 } else { -1.0 }); } /// The brain memory holding the explored positions /// (`SNIFFER_EXPLORED_POSITIONS`). diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index 93e3fe119..3cce491ef 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -35,8 +35,12 @@ pub fn placement_ok(species: Species, w: &MobWorld, x: i32, y: i32, z: i32) -> b #[derive(Clone, Copy, Debug)] pub struct LocalDifficulty { pub effective: f32, + /// The world difficulty is normal. + pub normal: bool, /// The world difficulty is hard. pub hard: bool, + /// `Difficulty.getId`: 0 peaceful to 3 hard. + pub id: u8, } /// `DimensionType.MOON_BRIGHTNESS_PER_PHASE`. pub const MOON_BRIGHTNESS: [f32; 8] = [1.0, 0.75, 0.5, 0.25, 0.0, 0.25, 0.5, 0.75]; @@ -45,10 +49,13 @@ impl LocalDifficulty { /// the overworld clock, the chunk's inhabited time and the moon. pub fn new(base: u8, total: i64, inhabited: i64, moon: f32) -> Self { let hard = base == 3; + let normal = base == 2; if base == 0 { return Self { effective: 0.0, + normal, hard, + id: base, }; } let global = ((total as f32 - 72000.0) / 1_440_000.0).clamp(0.0, 1.0) * 0.25; @@ -60,7 +67,9 @@ impl LocalDifficulty { } Self { effective: f32::from(base) * (0.75 + global + local), + normal, hard, + id: base, } } /// `getSpecialMultiplier`. @@ -148,6 +157,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Pig | S::Cow | S::Chicken => m.extra.variant = climate_index, S::Sheep => m.extra.color = super::farm::sheep_color(biome.climate, &mut m.random), S::Mooshroom => m.extra.variant = 0, + // Turtle.finalizeSpawn: home is where it spawned. + S::Turtle => { + m.extra.home = Some([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]) + } S::Frog => m.extra.variant = biome.frog, S::Wolf => m.extra.variant = biome.wolf, S::Cat => m.extra.variant = super::tame::cat_variant(m, biome.full_moon), @@ -158,6 +175,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { if m.random.next_float() < 0.05 { m.extra.age = i32::MIN / 2; } + // setCanBreakDoors(nextFloat() < special * 0.1), before the gear. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); if m.species() != S::Drowned { populate_armor(m, difficulty); } @@ -191,6 +211,9 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { // ZombifiedPiglin.populateDefaultEquipmentSlots: a golden sword, or // one time in 20 a golden spear. S::ZombifiedPiglin => { + // Zombie.finalizeSpawn's setCanBreakDoors. + let can_break_doors = m.random.next_float() < difficulty.special() * 0.1; + super::doors::set_can_break_doors(m, can_break_doors); let spear = m.random.next_int_bound(20).unwrap_or(1) == 0; m.extra.main_hand = Some( if spear { @@ -264,6 +287,18 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { S::Slime | S::MagmaCube => super::cube::finalize(m), _ => {} } + // Zombie.finalizeSpawn ends with handleAttributes: the reinforcement + // chance (and a leader's bonus) that `Zombie.hurtServer` reads. + if matches!( + m.species(), + S::Zombie | S::Husk | S::Drowned | S::ZombieVillager | S::ZombifiedPiglin + ) { + super::finalize::zombie_attributes( + m, + difficulty.special(), + super::finalize::Reason::Natural, + ); + } } /// VillagerType.byBiome pub fn villager_type(b: &BiomeFacts) -> u8 { diff --git a/crates/horizon-client/src/mobs/species.rs b/crates/horizon-client/src/mobs/species.rs index f48a21fe9..64106574b 100644 --- a/crates/horizon-client/src/mobs/species.rs +++ b/crates/horizon-client/src/mobs/species.rs @@ -1447,7 +1447,6 @@ impl Def { | S::IronGolem | S::Cat | S::Ocelot - | S::Frog | S::Slime | S::MagmaCube | S::Wither @@ -1500,7 +1499,7 @@ impl Def { ), // Nautilus.handleAirSupply: it dries out on land. needs_water: aquatic || s == S::Nautilus, - water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem), + water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem | S::Strider), animal: !matches!(s, S::Bat | S::SulfurCube | S::Mannequin) && !self.hostile_class() && !aquatic @@ -1581,7 +1580,7 @@ impl Def { S::Bee => "bee_food", S::Turtle => "turtle_food", S::Armadillo => "armadillo_food", - S::Frog => "frog_food", + S::Frog | S::Tadpole => "frog_food", S::Axolotl => "axolotl_food", S::Hoglin => "hoglin_food", S::Strider => "strider_food", @@ -1629,6 +1628,7 @@ impl Def { match self.species { Species::Dolphin | Species::Axolotl => 1.0, Species::Slime | Species::MagmaCube | Species::SulfurCube => 0.0, + Species::Guardian | Species::ElderGuardian | Species::Shulker => 180.0, _ => 40.0, } } @@ -1640,6 +1640,9 @@ impl Def { Species::Sniffer => 50.0, Species::Goat => 15.0, Species::Armadillo => 32.0, + Species::Ravager => 45.0, + Species::Camel | Species::CamelHusk => 30.0, + Species::Shulker => 180.0, _ => 75.0, } } @@ -1658,6 +1661,12 @@ impl Def { | S::Camel | S::CamelHusk => 400, S::Ocelot => 900, + // Guardian.getAmbientSoundInterval (the elder too). + S::Guardian | S::ElderGuardian => 160, + // Hoglin extends Animal; Shulker extends AbstractGolem. + S::Hoglin | S::Shulker => 120, + // Allay is a PathfinderMob: the Mob default. + S::Allay => 80, S::Squid | S::GlowSquid | S::Cod diff --git a/crates/horizon-client/src/mobs/sulfur_cube.rs b/crates/horizon-client/src/mobs/sulfur_cube.rs index 00b846b86..91c303467 100644 --- a/crates/horizon-client/src/mobs/sulfur_cube.rs +++ b/crates/horizon-client/src/mobs/sulfur_cube.rs @@ -297,6 +297,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool { | DamageKind::Fall | DamageKind::Sting | DamageKind::Cactus + | DamageKind::Freeze ); if immune { if let Some(from) = source.from { @@ -370,6 +371,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { events.push(MobEvent::Explode( [m.position.x, m.position.y + 0.0625, m.position.z], power, + m.species(), )); m.removed = true; return; diff --git a/crates/horizon-client/src/mobs/sync.rs b/crates/horizon-client/src/mobs/sync.rs new file mode 100644 index 000000000..d1c5b6609 --- /dev/null +++ b/crates/horizon-client/src/mobs/sync.rs @@ -0,0 +1,85 @@ +//! A server's mobs as a client animates them. The server thread, a LAN host +//! and a dedicated server run each mob's animation state (`ai::tick`); a +//! client of a server only gets synched data and entity events, and runs the +//! animation steps vanilla runs on the client (`Entity.onSyncedDataUpdated`, +//! `handleEntityEvent` and the per tick animation states). The server's half +//! is `game/lan.rs` (`mob_meta`, with `pose_id` here for index 6) and +//! `MobEvent::EntityEvent`; the client's half is `game/remote_mob_data.rs`, +//! `game/remote.rs` (`C::EntityEvent`) and `client_tick` here. +//! +//! To animate another species for a client: +//! - send its synched data in `mob_meta` and read it in +//! `remote_mob_data::apply`; +//! - send its poses in `pose_id` and read them in `set_pose`; +//! - broadcast its entity events with `MobEvent::EntityEvent` and handle +//! them in `entity_event`; +//! - run its client side animation in `client_tick`, sharing the function +//! with the server's `ai_step` where the server computes the same. +use super::{Mob, Species, breeze, camel, shulker, sniffer, warden}; + +// `Pose.id` of the poses mobs take. +pub const STANDING: u8 = 0; +pub const LONG_JUMPING: u8 = 6; +pub const SITTING: u8 = 10; +pub const ROARING: u8 = 11; +pub const SNIFFING: u8 = 12; +pub const EMERGING: u8 = 13; +pub const DIGGING: u8 = 14; +pub const SLIDING: u8 = 15; +pub const SHOOTING: u8 = 16; +pub const INHALING: u8 = 17; + +/// `Entity.DATA_POSE` (index 6) of a mob of a server: the pose its species +/// keeps in its own fields. +pub fn pose_id(m: &Mob) -> u8 { + match m.species() { + Species::Warden => warden::pose_id(m), + Species::Breeze => breeze::pose_id(m), + Species::Camel | Species::CamelHusk if camel::sitting(m) => SITTING, + _ => STANDING, + } +} + +/// `Entity.onSyncedDataUpdated(DATA_POSE)` on a client. +pub fn set_pose(m: &mut Mob, id: u8) { + m.extra.pose = id; + match m.species() { + Species::Warden => warden::set_pose_id(m, id), + Species::Breeze => breeze::set_pose_id(m, id), + _ => {} + } +} + +/// The per tick animation steps of a client's mobs (after `m.age` moved on), +/// for the species whose server runs them in `ai_step`. `game_time` is the +/// client level's. +pub fn client_tick(m: &mut Mob, game_time: i64) { + match m.species() { + Species::Warden => warden::client_tick(m), + Species::Sniffer => sniffer::animate(m), + Species::Breeze => breeze::ai_step(m), + Species::Camel | Species::CamelHusk => camel::client_tick(m, game_time), + Species::Shulker => shulker::client_tick(m), + _ => {} + } +} + +/// `handleEntityEvent` of the mob types that start animations by an event +/// (`ClientboundEntityEventPacket` for a mob of a client). +pub fn entity_event(m: &mut Mob, event: u8) { + let age = m.age as f32; + if m.species() == Species::Warden { + match event { + // Attack: the roar ends, the swing starts. + 4 => { + m.anim.states[warden::ROAR].stop(); + m.anim.states[warden::ATTACK].start(age); + } + // The tendrils twitch. + 61 => m.anim.v[warden::TENDRIL] = 10.0, + // The sonic boom. + 62 => m.anim.states[warden::SONIC_BOOM].start(age), + _ => {} + } + } +} diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index 1e725df80..f38296d4b 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -2,7 +2,7 @@ //! sitting, following and defending the owner, begging, anger and shaking //! off water (`Wolf`, `WolfModel`). use super::{ - Damage, Mob, MobEvent, MobWorld, Species, Who, + Damage, Mob, MobEvent, MobWorld, PlayerView, Species, Who, anim::{Poses, State}, goals::{ Breed, Cx, Float, Goal, HurtByTarget, JUMP, LOOK, LeapAtTarget, LookAtPlayer, MOVE, @@ -124,17 +124,23 @@ pub fn set_tame(m: &mut Mob, tame: bool) { m.attrs.max_health = 8.0; } } -/// The owner (the local player) when tame. -fn owner(m: &Mob, w: &MobWorld) -> Option<(Vec3, f64, bool)> { +/// `TamableAnimal.getOwner`: the player the saved owner UUID names, when it +/// is the one this mob sees; anyone else nearby is not its owner. +fn owner_view<'a>(m: &Mob, w: &'a MobWorld) -> Option<&'a PlayerView> { if !m.extra.tame { return None; } let p = w.player.as_ref()?; + (m.extra.owner == Some(p.uuid)).then_some(p) +} +/// The owner's position, eye height and spectator flag when tame. +fn owner(m: &Mob, w: &MobWorld) -> Option<(Vec3, f64, bool)> { + let p = owner_view(m, w)?; Some((p.position, p.eye[1], p.spectator)) } /// `TamableAnimal.unableToMoveToOwner`. fn unable_to_move_to_owner(m: &Mob, w: &MobWorld) -> bool { - m.extra.sitting || w.player.as_ref().is_some_and(|p| p.spectator) + m.extra.sitting || owner_view(m, w).is_some_and(|p| p.spectator) } /// `TamableAnimal.tryToTeleportToOwner`: ten tries around the owner. fn teleport_to_owner(m: &mut Mob, w: &MobWorld) { @@ -240,7 +246,7 @@ impl Goal for SitWhenOrdered { match owner_distance_sqr(m, cx.w) { None => true, Some(d) => { - let attacked = cx.w.player.as_ref().is_some_and(|p| p.hurt_by.is_some()); + let attacked = owner_view(m, cx.w).is_some_and(|p| p.hurt_by.is_some()); !(d < 144.0 && attacked) && m.extra.sitting } } @@ -360,7 +366,7 @@ impl Goal for FollowOwner { }; let far = m.distance_sqr(at) >= 144.0; if !far { - let max = max_head_x_rot(m); + let max = m.max_head_x_rot(); m.look_at_speed([at.x, eye, at.z], 10.0, max); } self.recalc -= 1; @@ -388,7 +394,7 @@ impl Goal for OwnerHurtBy { if !m.extra.tame || m.extra.sitting { return false; } - let Some((who, at)) = cx.w.player.as_ref().and_then(|p| p.hurt_by) else { + let Some((who, at)) = owner_view(m, cx.w).and_then(|p| p.hurt_by) else { return false; }; at != self.timestamp && wants_to_attack(m, cx.w, who) @@ -399,7 +405,7 @@ impl Goal for OwnerHurtBy { .is_some_and(|t| t.alive) } fn start(&mut self, m: &mut Mob, cx: &mut Cx) { - if let Some((who, at)) = cx.w.player.as_ref().and_then(|p| p.hurt_by) { + if let Some((who, at)) = owner_view(m, cx.w).and_then(|p| p.hurt_by) { m.ai.target = Some(who); self.timestamp = at; } @@ -422,7 +428,7 @@ impl Goal for OwnerHurt { if !m.extra.tame || m.extra.sitting { return false; } - let Some((who, at)) = cx.w.player.as_ref().and_then(|p| p.last_hurt) else { + let Some((who, at)) = owner_view(m, cx.w).and_then(|p| p.last_hurt) else { return false; }; at != self.timestamp && wants_to_attack(m, cx.w, who) @@ -433,7 +439,7 @@ impl Goal for OwnerHurt { .is_some_and(|t| t.alive) } fn start(&mut self, m: &mut Mob, cx: &mut Cx) { - if let Some((who, at)) = cx.w.player.as_ref().and_then(|p| p.last_hurt) { + if let Some((who, at)) = owner_view(m, cx.w).and_then(|p| p.last_hurt) { m.ai.target = Some(who); self.timestamp = at; } @@ -507,7 +513,7 @@ impl Goal for Beg { } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { if let Some(p) = &cx.w.player { - let max = max_head_x_rot(m); + let max = m.max_head_x_rot(); m.look_at_speed([p.position.x, p.eye[1], p.position.z], 10.0, max); } self.ticks -= 1; @@ -529,12 +535,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { let v = &mut m.anim.v; v[INTEREST] += (goal - v[INTEREST]) * 0.4; // isWet (extra.flag2), isShaking (counter2 > 0). - let rain = w.raining - && w.can_see_sky( - m.position.x.floor() as i32, - (m.position.y + f64::from(m.height())).floor() as i32, - m.position.z.floor() as i32, - ); + let rain = super::ai::in_rain(m, w); let shaking = m.extra.counter2 > 0; if m.fluids.in_water || rain { m.extra.flag2 = true; @@ -683,14 +684,6 @@ pub fn texture(m: &Mob) -> String { let baby = if m.is_baby() { "_baby" } else { "" }; format!("entity/wolf/{file}{state}{baby}") } -/// `Wolf.getMaxHeadXRot`: 20 degrees while sitting. -fn max_head_x_rot(m: &Mob) -> f32 { - if m.extra.sitting { - 20.0 - } else { - m.kind.max_head_x_rot - } -} /// Cat variants (`CatVariants`): registry name, texture file. pub const CAT_VARIANTS: [&str; 11] = [ @@ -835,7 +828,7 @@ impl Goal for CatLieOnBlock { m.extra.flag = self.0.reached; } } -/// `CatSitOnBlockGoal`: chests, lit furnaces and beds. +/// `CatSitOnBlockGoal`: unopened chests, lit furnaces and the foot of beds. #[derive(Clone, Debug)] struct CatSitOnBlock(super::goals::MoveToBlock); fn can_sit_on(w: &MobWorld, p: [i32; 3]) -> bool { @@ -846,7 +839,15 @@ fn can_sit_on(w: &MobWorld, p: [i32; 3]) -> bool { match f.name.as_ref() { // The lit state is the only light source a furnace has. "minecraft:furnace" => f.emission > 0, - _ => true, + // `ChestBlockEntity.getOpenCount(level, pos) < 1`: players' openers + // and the copper golems' own count. + "minecraft:chest" => { + let key = (p[0], p[1], p[2]); + !w.lid_openers.contains_key(&key) + && w.world.chest_openers.get(&key).is_none_or(|o| o.0 < 1) + } + // A bed's foot only (`BedBlock.PART != HEAD`). + _ => w.prop(p[0], p[1], p[2], "part") != Some("head"), } } impl Goal for CatSitOnBlock { diff --git a/crates/horizon-client/src/mobs/turtle.rs b/crates/horizon-client/src/mobs/turtle.rs new file mode 100644 index 000000000..e56973384 --- /dev/null +++ b/crates/horizon-client/src/mobs/turtle.rs @@ -0,0 +1,399 @@ +//! Turtles (`Turtle`): the egg a bred mother carries (`TurtleBreedGoal`), her +//! walk home (`TurtleGoHomeGoal`) and the egg she digs into the sand +//! (`TurtleLayEggGoal`). `extra.flag` is `hasEgg`, `extra.flag2` `isLayingEgg`, +//! `extra.counter` `layEggCounter` and `extra.home` `homePos`. Its own +//! `TurtleMoveControl` sets the speed it walks and swims at. Not ported yet: +//! `Turtle.travelInWater` (swimming still uses the generic fluid travel), +//! `TurtlePathNavigation` and the rest of the turtle goal set, so movement in +//! water still differs from vanilla. +use super::{ + Mob, MobEvent, MobWorld, + control::{EPSILON, Operation, rotlerp}, + goals::{Breed, Cx, Goal, JUMP, MOVE, MoveToBlock, goal_clone, reduced}, +}; +use horizon_core::{geometry::trig, random::RandomSource}; + +pub fn register(m: &mut Mob) { + let g = &mut m.ai.goals; + g.add(1, Breed::new(1.0)); + g.add(1, LayEgg(MoveToBlock::new(1.0, 16, 1))); + g.add(4, GoHome::default()); +} +/// `Turtle.hasEgg`. +pub fn has_egg(m: &Mob) -> bool { + m.extra.flag +} +/// `Turtle.setLayingEgg`. +fn set_laying_egg(m: &mut Mob, on: bool) { + m.extra.counter = i32::from(on); + m.extra.flag2 = on; +} +/// `homePos.closerToCenterThan(position, distance)`. Spawning, hatching and +/// loading all set a home; one made any other way is at home where it stands. +fn close_to_home(m: &Mob, distance: f64) -> bool { + let home = m.extra.home.unwrap_or([ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ]); + let d = [ + f64::from(home[0]) + 0.5 - m.position.x, + f64::from(home[1]) + 0.5 - m.position.y, + f64::from(home[2]) + 0.5 - m.position.z, + ]; + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < distance * distance +} +fn block_of(m: &Mob) -> [i32; 3] { + [ + m.position.x.floor() as i32, + m.position.y.floor() as i32, + m.position.z.floor() as i32, + ] +} +/// `TurtleEggBlock.isSand`. +fn is_sand(w: &MobWorld, p: [i32; 3]) -> bool { + w.has_tag("minecraft:sand", w.block_name(p[0], p[1], p[2])) +} +/// `Turtle.TurtleMoveControl.updateSpeed`: slow in water away from home +/// (slower still as a baby), and halved every tick on land, never under 0.06. +fn update_speed(m: &mut Mob) { + if m.fluids.in_water { + m.velocity.y += 0.005; + if !close_to_home(m, 16.0) { + m.set_speed((m.ai.speed / 2.0).max(0.08)); + } + if m.is_baby() { + m.set_speed((m.ai.speed / 3.0).max(0.06)); + } + } else if m.on_ground { + m.set_speed((m.ai.speed / 2.0).max(0.06)); + } +} +/// `Turtle.TurtleMoveControl.tick`: ease the speed an eighth of the way to +/// the wanted one each tick (after `updateSpeed` cut it), face the wanted +/// point and lift towards it; no jumping, and the operation is never reset. +pub fn move_control(m: &mut Mob) { + update_speed(m); + if m.ai.mov.operation != Operation::MoveTo || m.ai.nav.is_done() { + m.set_speed(0.0); + return; + } + let [wx, wy, wz] = m.ai.mov.wanted; + let (xd, yd, zd) = (wx - m.position.x, wy - m.position.y, wz - m.position.z); + let dist = (xd * xd + yd * yd + zd * zd).sqrt(); + if dist < EPSILON { + m.set_speed(0.0); + return; + } + let y_rot = trig::atan2_degrees(zd, xd) - 90.0; + m.yaw = rotlerp(m.yaw, y_rot, 90.0); + m.body_yaw = m.yaw; + let wanted = (m.ai.mov.speed_modifier * m.movement_speed()) as f32; + // Mth.lerp(0.125F, getSpeed(), wanted). + m.set_speed(m.ai.speed + 0.125 * (wanted - m.ai.speed)); + m.velocity.y += f64::from(m.ai.speed) * (yd / dist) * 0.1; +} +/// `Turtle.aiStep`: while digging, sand crumbs fly every fifth tick. +pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + if m.alive() && m.extra.flag2 && m.extra.counter >= 1 && m.extra.counter % 5 == 0 { + let at = block_of(m); + let below = [at[0], at[1] - 1, at[2]]; + if is_sand(w, below) { + events.push(MobEvent::BlockBreakEffect { + pos: at, + block: below, + }); + } + } +} + +/// `Turtle.TurtleLayEggGoal`: a mother with an egg within 9 blocks of home +/// walks to sand with air above, digs for 200 ticks and lays it. +#[derive(Clone, Debug)] +struct LayEgg(MoveToBlock); +impl LayEgg { + /// `isValidTarget`: sand with air above it. + fn valid(w: &MobWorld, p: [i32; 3]) -> bool { + w.facts.is_air(w.world.block(p[0], p[1] + 1, p[2])) && is_sand(w, p) + } +} +impl Goal for LayEgg { + goal_clone!(); + fn flags(&self) -> u8 { + MOVE | JUMP + } + fn name(&self) -> &'static str { + "TurtleLayEggGoal" + } + fn every_tick(&self) -> bool { + true + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + if !(has_egg(m) && close_to_home(m, 9.0)) { + return false; + } + // MoveToBlockGoal.canUse. + if self.0.next_start > 0 { + self.0.next_start -= 1; + return false; + } + self.0.next_start = reduced(200 + m.random.next_int_bound(200).unwrap_or(0)); + self.0.find_nearest(m, cx.w, Self::valid) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(cx.w, Self::valid) && has_egg(m) && close_to_home(m, 9.0) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx.w); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.tick(m, cx.w); + if m.fluids.in_water || !self.0.reached { + return; + } + if m.extra.counter < 1 { + set_laying_egg(m, true); + } else if m.extra.counter > 200 { + let at = [m.position.x, m.position.y, m.position.z]; + let pitch = 0.9 + m.random.next_float() * 0.2; + cx.events + .push(MobEvent::Sound("entity.turtle.lay_egg", at, 0.3, pitch)); + // Blocks.TURTLE_EGG with EGGS = nextInt(4) + 1 above the sand. + let eggs = m.random.next_int_bound(4).unwrap_or(0) + 1; + let registry = cx.w.registry; + let state = registry + .default_state("minecraft:turtle_egg") + .and_then(|d| registry.state(d).cloned()) + .and_then(|mut s| { + s.properties.insert("eggs".into(), eggs.to_string()); + registry.resolve(&s).ok() + }); + let b = self.0.block; + if let Some(state) = state { + cx.events.push(MobEvent::SetBlock { + pos: [b[0], b[1] + 1, b[2]], + state, + }); + } + m.extra.flag = false; + set_laying_egg(m, false); + m.extra.in_love = 600; + } + if m.extra.flag2 { + m.extra.counter += 1; + } + } +} + +/// `Turtle.TurtleGoHomeGoal`: a mother with an egg heads home; any adult +/// that strayed 64 blocks away does now and then. +#[derive(Clone, Debug, Default)] +struct GoHome { + stuck: bool, + close_to_home_try_ticks: i32, +} +impl Goal for GoHome { + goal_clone!(); + fn flags(&self) -> u8 { + 0 + } + fn name(&self) -> &'static str { + "TurtleGoHomeGoal" + } + fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + if m.is_baby() { + false + } else if has_egg(m) { + true + } else { + m.random.next_int_bound(reduced(700)).unwrap_or(1) == 0 && !close_to_home(m, 64.0) + } + } + fn start(&mut self, _m: &mut Mob, _cx: &mut Cx) { + self.stuck = false; + self.close_to_home_try_ticks = 0; + } + fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { + !close_to_home(m, 7.0) && !self.stuck && self.close_to_home_try_ticks <= reduced(600) + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + let home = m.extra.home.unwrap_or(block_of(m)); + let close = close_to_home(m, 16.0); + if close { + self.close_to_home_try_ticks += 1; + } + if !m.ai.nav.is_done() { + return; + } + let to = [ + f64::from(home[0]) + 0.5, + f64::from(home[1]), + f64::from(home[2]) + 0.5, + ]; + let mut next = m + .default_random_pos_towards(cx.w, 16, 3, to, std::f64::consts::PI / 10.0) + .or_else(|| m.default_random_pos_towards(cx.w, 8, 7, to, std::f64::consts::FRAC_PI_2)); + if let Some(at) = next + && !close + && cx.w.block_name( + at[0].floor() as i32, + at[1].floor() as i32, + at[2].floor() as i32, + ) != "minecraft:water" + { + next = m.default_random_pos_towards(cx.w, 16, 5, to, std::f64::consts::FRAC_PI_2); + } + match next { + Some(at) => { + m.nav_move_to(cx.w, at, 1.0); + } + None => self.stuck = true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::{ + Species, + path::{Path, PathNode, PathType}, + }; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + /// A turtle (movement speed 0.25) strolling (`speedModifier` 1.0) to a + /// point ten blocks ahead along a path it has not finished. + fn strolling_turtle() -> Mob { + let mut kind = crate::mobs::sensor_scan_tests::kind(); + let k = Arc::make_mut(&mut kind); + k.id = "minecraft:turtle".into(); + k.species = Species::Turtle; + k.speed = 0.25; + k.step_height = 1.0; + let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0); + m.on_ground = true; + let node = PathNode { + x: 0, + y: 0, + z: 10, + kind: PathType::Walkable, + }; + m.ai.nav.path = Some(Path { + nodes: vec![node], + next: 0, + target: [0, 0, 10], + dist_to_target: 0.0, + reached: true, + }); + m.ai.mov.set_wanted(0.0, 0.0, 10.0, 1.0); + m + } + fn speeds(m: &mut Mob, ticks: usize) -> Vec { + (0..ticks) + .map(|_| { + move_control(m); + assert_eq!(m.ai.zza, m.ai.speed, "setSpeed sets the forward input"); + m.ai.speed + }) + .collect() + } + + #[test] + fn walks_on_land_at_a_third_of_its_movement_speed() { + // TurtleMoveControl.tick on the ground: updateSpeed makes it + // max(speed / 2, 0.06) = 0.06, then Mth.lerp(0.125F, 0.06, 0.25), + // every tick. The default MoveControl walked it at the full 0.25, so + // the walk animation (and TurtleModel's 8 * walkSpeed leg swing) ran + // about nine times too fast (#1776). + let mut m = strolling_turtle(); + let land = 0.06_f32 + 0.125 * (0.25 - 0.06_f32); + assert_eq!(speeds(&mut m, 40), vec![land; 40]); + // Facing the wanted point, the body with it; it never jumps. + assert_eq!(m.body_yaw, m.yaw); + assert!(crate::mobs::living::wrap(m.yaw).abs() < 1.0e-3); + assert!(!m.ai.jump); + assert_eq!(m.ai.mov.operation, Operation::MoveTo); + assert_eq!(m.velocity.y, 0.0); + } + + #[test] + fn water_speed_is_cut_away_from_home_and_for_babies() { + // updateSpeed in water near home: no cut, the lerp climbs towards + // 0.25, and it lifts the turtle 0.005 a tick. (Only the control's + // speed: Turtle.travelInWater is not ported.) + let mut m = strolling_turtle(); + m.on_ground = false; + m.fluids.in_water = true; + let s = speeds(&mut m, 60); + assert_eq!(s[0], 0.125 * 0.25); + assert!(s.windows(2).all(|w| w[1] > w[0]) && s[59] > 0.24 && s[59] < 0.25); + assert!((m.velocity.y - 60.0 * 0.005).abs() < 1.0e-9); + // More than 16 blocks from home: max(speed / 2, 0.08) first. + let mut m = strolling_turtle(); + m.on_ground = false; + m.fluids.in_water = true; + m.extra.home = Some([0, 0, 40]); + let far = 0.08_f32 + 0.125 * (0.25 - 0.08_f32); + assert_eq!(speeds(&mut m, 20), vec![far; 20]); + // A baby: max(speed / 3, 0.06). + let mut m = strolling_turtle(); + m.on_ground = false; + m.fluids.in_water = true; + m.extra.age = -24000; + let young = 0.06_f32 + 0.125 * (0.25 - 0.06_f32); + assert_eq!(speeds(&mut m, 20), vec![young; 20]); + } + + #[test] + fn stops_when_the_path_is_done_or_the_point_reached() { + let mut m = strolling_turtle(); + m.ai.nav.stop(); + assert_eq!(speeds(&mut m, 1), vec![0.0]); + let mut m = strolling_turtle(); + m.ai.mov.set_wanted(0.0, 0.0, 0.0, 1.0); + assert_eq!(speeds(&mut m, 1), vec![0.0]); + // updateSpeed runs before the early return: a stopped turtle in + // water is still lifted 0.005, path done or not moving at all. + let mut m = strolling_turtle(); + m.fluids.in_water = true; + m.ai.nav.stop(); + assert_eq!(speeds(&mut m, 1), vec![0.0]); + assert_eq!(m.velocity.y, 0.005); + let mut m = strolling_turtle(); + m.fluids.in_water = true; + m.ai.mov.operation = Operation::Wait; + assert_eq!(speeds(&mut m, 1), vec![0.0]); + assert_eq!(m.velocity.y, 0.005); + } + + #[test] + fn lifts_towards_a_point_above_by_the_slope_of_the_way_there() { + // setDeltaMovement(... + getSpeed() * (dy / dist) * 0.1): two up, + // three across and ten ahead. + let mut m = strolling_turtle(); + m.ai.mov.set_wanted(3.0, 2.0, 10.0, 1.0); + let speed = speeds(&mut m, 1)[0]; + let dist = (3.0_f64 * 3.0 + 2.0 * 2.0 + 10.0 * 10.0).sqrt(); + assert_eq!(m.velocity.y, f64::from(speed) * (2.0 / dist) * 0.1); + assert!(m.velocity.y > 0.0); + } + + #[test] + fn turns_at_most_ninety_degrees_a_tick_and_wraps_into_a_turn() { + // rotlerp(yRot, target, 90): from 150 towards about -16.7 it turns + // the full 90 to 60, then reaches the target, kept in [0, 360] as + // about 343.3; the body follows the yaw each tick. + let mut m = strolling_turtle(); + m.ai.mov.set_wanted(3.0, 0.0, 10.0, 1.0); + m.yaw = 150.0; + m.body_yaw = 150.0; + let target = trig::atan2_degrees(10.0, 3.0) - 90.0; + assert!((-17.0..-16.0).contains(&target)); + move_control(&mut m); + assert_eq!((m.yaw, m.body_yaw), (60.0, 60.0)); + move_control(&mut m); + assert_eq!(m.yaw, 60.0 + (target - 60.0) + 360.0); + assert_eq!(m.body_yaw, m.yaw); + } +} diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index e77a37641..3707f4bbe 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -1,7 +1,8 @@ //! Zombies (zombie, husk, drowned, zombie villager) and skeletons (skeleton, //! stray, bogged, wither skeleton): goals, bow attacks, water conversion. use super::{ - Mob, MobEvent, Species, Who, + Damage, Mob, MobEvent, Species, Who, + doors::BreakDoor, goals::{ Avoid, AvoidEntity, Cx, FleeSun, Goal, HurtByTarget, LOOK, LookAtPlayer, MOVE, MeleeAttack, NearestTarget, RandomLookAround, RestrictSun, Stroll, goal_clone, @@ -10,11 +11,22 @@ use super::{ use crate::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, random::RandomSource}; +/// `Zombie.hurtServer`, once the hit landed: a zombie with a target (its own, +/// else a living attacker) asks the game to try calling a reinforcement; the +/// difficulty, chance and spawn tests are the game's (`call_reinforcements`). +pub fn after_hurt(m: &mut Mob, source: &Damage) { + if let Some(target) = m.ai.target.or(source.attacker) { + m.outbox + .push(MobEvent::Reinforcements { mob: m.id, target }); + } +} + pub fn register(m: &mut Mob) { use Species as S; match m.species() { S::Zombie | S::Husk | S::ZombieVillager => { let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); @@ -42,6 +54,7 @@ pub fn register(m: &mut Mob) { S::ZombifiedPiglin => { // ZombifiedPiglin.addBehaviourGoals: neutral until provoked. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); g.add(2, MeleeAttack::new(1.0, false).zombie()); @@ -58,6 +71,7 @@ pub fn register(m: &mut Mob) { S::Drowned => { // Zombie.registerGoals, then Drowned.addBehaviourGoals. let g = &mut m.ai.goals; + g.add(1, BreakDoor::zombie()); g.add(4, AttackTurtleEgg::default()); g.add(8, LookAtPlayer::new(8.0)); g.add(8, RandomLookAround::default()); @@ -136,12 +150,13 @@ pub fn register(m: &mut Mob) { } // The AbstractSkeleton constructor's reassessWeaponGoal, after // registerGoals; the weapon itself comes with finalizeSpawn. - let interval = if matches!(species, S::Bogged | S::Parched) { - 70 + // getHardAttackInterval / getAttackInterval. + let (hard, normal) = if matches!(species, S::Bogged | S::Parched) { + (50, 70) } else { - 40 + (20, 40) }; - m.ai.goals.add(4, SkeletonWeapon::new(interval)); + m.ai.goals.add(4, SkeletonWeapon::new(hard, normal)); } _ => {} } @@ -217,8 +232,10 @@ pub fn drowned_travel( m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9); true } -/// The trident throw of `Drowned.performRangedAttack`, until thrown tridents -/// exist: an arrow-like shot with the trident's damage. +/// The trident throw of `Drowned.performRangedAttack`: a `ThrownTrident` of +/// the held trident (a plain one when it holds none), owned by the drowned, +/// which cannot be picked up and does not return (`AbstractArrow` constructor: +/// only a player's projectile is `Pickup.ALLOWED`). fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { let Some(t) = cx.w.target_info(who) else { return; @@ -228,15 +245,18 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { let yd = t.position.y + (t.bounds.max_position().y - t.bounds.min_position().y) / 3.0 - start.y; let zd = t.position.z - m.position.z; let dist = (xd * xd + zd * zd).sqrt(); - let mut p = Projectile::new( - ProjectileKind::Arrow { - base_damage: 8.0, - critical: false, - effect: None, - }, - start, - Some(Who::Mob(m.id)), - ); + let mut trident = crate::items::ItemStack::new("minecraft:trident", 1); + if m.extra.main_hand.as_deref() == Some("minecraft:trident") + && let Some(g) = super::gear::gear(m) + { + for (e, l) in &g.slots[super::gear::MAIN] { + trident.enchant(e, *l); + } + } + let mut p = Projectile::new(ProjectileKind::Trident, start, Some(Who::Mob(m.id))); + if let Some(a) = p.arrow.as_mut() { + a.set_item(&trident); + } p.shoot( &mut crate::projectiles::own_random(m), [xd, yd + dist * f64::from(0.2_f32), zd], @@ -524,13 +544,20 @@ pub struct SkeletonWeapon { bow: BowAttack, melee: MeleeAttack, use_bow: bool, + /// The weapon the goal was last reassessed for. + assessed: Option, + hard_interval: i32, + normal_interval: i32, } impl SkeletonWeapon { - pub fn new(bow_interval: i32) -> Self { + pub fn new(hard_interval: i32, normal_interval: i32) -> Self { Self { - bow: BowAttack::new(1.0, bow_interval, 15.0), + bow: BowAttack::new(1.0, normal_interval, 15.0), melee: MeleeAttack::new(1.2, false), use_bow: false, + assessed: None, + hard_interval, + normal_interval, } } } @@ -556,6 +583,18 @@ impl Goal for SkeletonWeapon { fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { // The goal registered for the weapon in hand. self.use_bow = holding_bow(m); + // reassessWeaponGoal: a bow gets the hard interval only on Hard; it + // is picked again when the weapon changes (the first check after + // spawning or loading stands for finalizeSpawn and + // readAdditionalSaveData). + if self.assessed != Some(self.use_bow) { + self.assessed = Some(self.use_bow); + self.bow.interval_min = if cx.w.difficulty.hard { + self.hard_interval + } else { + self.normal_interval + }; + } if self.use_bow { self.bow.can_use(m, cx) } else { diff --git a/crates/horizon-client/src/mobs/vex.rs b/crates/horizon-client/src/mobs/vex.rs index de38f6da8..1301d1922 100644 --- a/crates/horizon-client/src/mobs/vex.rs +++ b/crates/horizon-client/src/mobs/vex.rs @@ -29,6 +29,7 @@ pub fn register(m: &mut Mob) { g.add(8, RandomMove); g.add(9, LookAtPlayer::new(3.0).probability(1.0)); g.add(10, LookAtPlayer::mobs(8.0)); + // `HurtByTargetGoal(this, Raider.class)`: raiders are ignored, vexes are not. let mut hurt = HurtByTarget::new().alert(); hurt.ignore = vec![ Species::Pillager, @@ -36,7 +37,7 @@ pub fn register(m: &mut Mob) { Species::Evoker, Species::Illusioner, Species::Witch, - Species::Vex, + Species::Ravager, ]; m.ai.targets.add(1, hurt); m.ai.targets.add(2, CopyOwnerTarget); diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 148034323..20280b190 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -448,6 +448,24 @@ impl Ext { } (stack.count > 0).then_some(stack) } + /// `SimpleContainer.removeItemType`: take up to `count` of the item from + /// the first stacks that hold it. + pub fn remove_item(&mut self, item: &str, mut count: u32) { + let mut slot = 0; + while slot < self.inventory.len() && count > 0 { + let s = &mut self.inventory[slot]; + if &*s.item == item { + let n = s.count.min(count); + s.count -= n; + count -= n; + if s.count == 0 { + self.inventory.remove(slot); + continue; + } + } + slot += 1; + } + } pub fn count_item(&self, item: &str) -> u32 { self.inventory .iter() @@ -754,30 +772,6 @@ pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) { } m.extra.left_handed = random.next_float() < 0.05; } -/// `PoiManager.take(HOME, ..., 48)`: the nearest bed head not `claimed` -/// (`free_head`: a bed head that is not occupied). -pub fn take_vacant_bed( - claimed: &[[i32; 3]], - from: [i32; 3], - mut free_head: impl FnMut(i32, i32, i32) -> bool, -) -> Option<[i32; 3]> { - let mut best: Option<(i32, [i32; 3])> = None; - for y in from[1] - 8..=from[1] + 8 { - for z in from[2] - 48..=from[2] + 48 { - for x in from[0] - 48..=from[0] + 48 { - let d = (x - from[0]).pow(2) + (y - from[1]).pow(2) + (z - from[2]).pow(2); - if d <= 48 * 48 - && best.is_none_or(|(b, _)| d < b) - && free_head(x, y, z) - && !claimed.contains(&[x, y, z]) - { - best = Some((d, [x, y, z])); - } - } - } - } - best.map(|(_, p)| p) -} /// `Villager.canBreed`: enough food, awake and an adult. pub fn can_breed(m: &Mob) -> bool { m.species() == Species::Villager @@ -911,6 +905,53 @@ mod tests { assert!(t.unset); assert_eq!(m.extra.variant2, 2); } + /// The reported sequence: a villager works at a bench, the bench is + /// destroyed (its job site is gone, so `ResetProfession` makes it + /// unemployed) and it takes another bench's job. The new profession's + /// trades are offered again. + /// + /// `Villager.setVillagerData` drops the offers (`offers = null`) when the + /// profession changes, so `AbstractVillager.getOffers` rolls the new + /// profession's trade set the next time the offers are asked for. A + /// villager that keeps its old offers marked as made never trades again: + /// it shakes its head instead of opening the trading menu. + #[test] + fn a_villager_that_loses_its_job_site_offers_its_new_profession_trades() { + let mut m = villager(); + // The composter's job: the villager is a farmer. + set_profession(&mut m, 5); + assert_eq!(m.extra.variant2, 5); + // Its trades were made, so the offers are there. + let trade = m.extra.trade.get_or_insert_default(); + trade.unset = false; + trade.offers = vec![crate::merchant::MerchantOffer { + base_cost_a: crate::merchant::ItemCost { + stack: ItemStack::new("minecraft:wheat", 20), + }, + cost_b: None, + result: ItemStack::new("minecraft:emerald", 1), + uses: 0, + max_uses: 16, + reward_exp: true, + special_price_diff: 0, + demand: 0, + price_multiplier: 0.0, + xp: 1, + }]; + // The work bench is destroyed: the job site is gone, so the villager + // is unemployed and its offers are dropped (`offers = null`). + set_profession(&mut m, NONE); + let t = m.extra.trade.as_ref().unwrap(); + assert!(t.offers.is_empty()); + assert!(t.unset); + // It takes the smithing table's job. The new profession's trades are + // rolled by the next `getOffers` (ensure_offers). + set_profession(&mut m, 13); + let t = m.extra.trade.as_ref().unwrap(); + assert!(t.offers.is_empty()); + assert!(t.unset); + assert_eq!(m.extra.variant2, 13); + } fn stack(item: &str, count: u32) -> ItemStack { ItemStack::new(item, count) diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 7b311a9e0..676d9b713 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -789,14 +789,7 @@ pub fn golem_detected(m: &mut Mob, now: i64) { // ---- Behaviours --------------------------------------------------------- -/// The POI types villagers use. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Poi { - /// A job site, by profession index. - Job(u8), - Home, - Meeting, -} +pub use crate::poi_sections::Poi; impl Poi { fn max_tickets(self) -> usize { match self { @@ -1242,6 +1235,8 @@ enum B { WorkAtPoi { t: Timed, last_check: i64, + /// `WorkAtComposter` (a farmer's package) rather than `WorkAtPoi`. + composter: bool, }, StrollAroundPoi { running: bool, @@ -1635,7 +1630,11 @@ impl B { *birth = now + 275 + i64::from(m.random.next_int_bound(50).unwrap_or(0)); true } - B::WorkAtPoi { t, last_check } => { + B::WorkAtPoi { + t, + last_check, + composter, + } => { let mm = mem(m); let Some(site) = mm.job_site.value() else { return false; @@ -1656,6 +1655,13 @@ impl B { mm.last_worked_at_poi.set(now); mm.look_target.set(Tracker::Block(site)); play_work_sound(m, cx); + // WorkAtComposter.useWorkstation, before the restock. + if *composter { + cx.events.push(MobEvent::WorkAtComposter { + mob: m.id, + pos: site, + }); + } restock_if_due(m, cx.w); true } @@ -2905,11 +2911,18 @@ impl B { let mut bodies = Vec::new(); if let Some(t) = cx.w.target_info(target) { let (min, max) = (t.bounds.min_position(), t.bounds.max_position()); - bodies.push((t.position, max.x - min.x, max.y - min.y)); + bodies.push((partner, t.position, max.x - min.x, max.y - min.y)); } - bodies.push((m.position, f64::from(m.width()), f64::from(m.height()))); - for (feet, width, height) in bodies { + bodies.push(( + m.id, + m.position, + f64::from(m.width()), + f64::from(m.height()), + )); + for (mob, feet, width, height) in bodies { cx.events.push(MobEvent::AroundSelf { + mob, + event: 12, kind: "heart", feet: [feet.x, feet.y, feet.z], width, @@ -3396,8 +3409,10 @@ fn breeding_possible(m: &mut Mob, w: &MobWorld) -> bool { && super::villager::can_breed(m) && w.seen(id).is_some_and(|s| s.in_love) } -/// `VillagerMakeLove.takeVacantBed`: a bed head no villager has claimed. -fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { +/// `VillagerMakeLove.takeVacantBed`: `PoiManager.take(HOME, ..., 48)` over a +/// sphere of 48 blocks, the nearest bed head no villager has claimed that the +/// villager can walk to (`canReach`: a path that gets there). +fn take_bed_for_child(m: &mut Mob, w: &MobWorld) -> Option<[i32; 3]> { let claimed: Vec<[i32; 3]> = w .others .iter() @@ -3405,10 +3420,20 @@ fn take_bed_for_child(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> { .filter_map(|s| s.bed) .chain(m.extra.bed) .collect(); - let p = m.position; - let from = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]; - super::villager::take_vacant_bed(&claimed, from, |x, y, z| { - w.prop(x, y, z, "part") == Some("head") && w.prop(x, y, z, "occupied") != Some("true") + let from = block_of(m.position); + let mut beds: Vec<[i32; 3]> = homes_in_range(m, w, 48) + .into_iter() + .filter(|p| { + w.prop(p[0], p[1], p[2], "part") == Some("head") + && w.prop(p[0], p[1], p[2], "occupied") != Some("true") + && !claimed.contains(p) + }) + .collect(); + // Nearest first; equally near beds stay in scan order. + beds.sort_by_key(|p| dist_sqr(*p, from)); + beds.into_iter().find(|&p| { + m.create_path_to(w, p, Poi::Home.valid_range()) + .is_some_and(|path| path.reached) }) } /// `BehaviorUtils.lockGazeAndWalkToEachOther`: this villager's half, and @@ -3580,6 +3605,8 @@ fn pois_scan_order(_m: &Mob, found: &[(Poi, [i32; 3])]) -> Vec { /// `AbstractVillager.addParticlesAroundSelf(HAPPY_VILLAGER)` on the client. fn happy_particles(m: &Mob, cx: &mut Bx) { cx.events.push(MobEvent::AroundSelf { + mob: m.id, + event: 14, kind: "happy_villager", feet: [m.position.x, m.position.y, m.position.z], width: f64::from(m.width()), @@ -3775,6 +3802,7 @@ fn packages(baby: bool, seed: i64, farmer: bool) -> Vec { B::WorkAtPoi { t: timed(60, 60), last_check: 0, + composter: farmer, }, 7, ), diff --git a/crates/horizon-client/src/mobs/warden.rs b/crates/horizon-client/src/mobs/warden.rs index 11161ef7c..747443577 100644 --- a/crates/horizon-client/src/mobs/warden.rs +++ b/crates/horizon-client/src/mobs/warden.rs @@ -67,6 +67,29 @@ impl Pose { } } } +/// `Pose.id` of the poses a warden takes (`sync::pose_id`). +pub fn pose_id(m: &Mob) -> u8 { + match pose(m) { + Pose::Standing => 0, + Pose::Roaring => 11, + Pose::Sniffing => 12, + Pose::Emerging => 13, + Pose::Digging => 14, + } +} +/// `Entity.onSyncedDataUpdated(DATA_POSE)` on a client. +pub(super) fn set_pose_id(m: &mut Mob, id: u8) { + set_pose( + m, + match id { + 11 => Pose::Roaring, + 12 => Pose::Sniffing, + 13 => Pose::Emerging, + 14 => Pose::Digging, + _ => Pose::Standing, + }, + ); +} /// The warden's pose (kept in `Extra.counter` so it can be read while the /// brain is being ticked). pub fn pose(m: &Mob) -> Pose { @@ -434,6 +457,11 @@ pub fn do_push(m: &mut Mob, w: &MobWorld, who: Who, events: &mut Vec) pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mut Vec) { m.anim.states[ROAR].stop(); m.anim.states[ATTACK].start(m.age as f32); + events.push(MobEvent::EntityEvent { + mob: m.id, + event: 4, + at: at(m), + }); let pitch = voice_pitch(m); sound(m, "entity.warden.attack_impact", 10.0, pitch, events); warden_brain::mem(m).sonic_boom_cooldown.set_ttl((), 40); @@ -546,6 +574,11 @@ fn receive_vibration(m: &mut Mob, w: &MobWorld, info: Info, events: &mut Vec) { - let Some(st) = m.extra.warden.as_ref() else { - return; - }; - let client_anger = st.client_anger; - let delay = heart_beat_delay(client_anger); - if i64::from(m.age) % i64::from(delay) == 0 { - m.anim.v[HEART] = 10.0; + if heartbeat(m) { events.push(MobEvent::LevelEventSound { sound: "entity.warden.heartbeat", at: at(m), @@ -745,6 +772,30 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec) { spread: 0.2, }); } + animate(m, events); +} +/// The heart's beat on its clock, from the client's anger level: whether +/// this tick has one (its sound is the caller's). +fn heartbeat(m: &mut Mob) -> bool { + let Some(st) = m.extra.warden.as_ref() else { + return false; + }; + let delay = heart_beat_delay(st.client_anger); + let beat = i64::from(m.age) % i64::from(delay) == 0; + if beat { + m.anim.v[HEART] = 10.0; + } + beat +} +/// `Warden.tick` on a client that is not the server: the heart and tendril +/// animations and the keyframes that ended. Its sounds and particles come +/// from the server. +pub fn client_tick(m: &mut Mob) { + m.anim.vo = m.anim.v; + heartbeat(m); + animate(m, &mut Vec::new()); +} +fn animate(m: &mut Mob, events: &mut Vec) { // The animation values count down after the sync of the last tick. for slot in [TENDRIL, HEART] { if m.anim.v[slot] > 0.0 { diff --git a/crates/horizon-client/src/mobs/warden_brain.rs b/crates/horizon-client/src/mobs/warden_brain.rs index 8caafefc9..667bbedf0 100644 --- a/crates/horizon-client/src/mobs/warden_brain.rs +++ b/crates/horizon-client/src/mobs/warden_brain.rs @@ -937,6 +937,11 @@ impl B { mm.sonic_boom_sound_delay.set_ttl((), SONIC_SOUND_DELAY); // Entity event 62: the sonic boom keyframes start. m.anim.states[warden::SONIC_BOOM].start(m.age as f32); + cx.events.push(MobEvent::EntityEvent { + mob: m.id, + event: 62, + at: [m.position.x, m.position.y, m.position.z], + }); sound(m, "entity.warden.sonic_charge", 3.0, 1.0, cx.events); true } diff --git a/crates/horizon-client/src/mobs/witch.rs b/crates/horizon-client/src/mobs/witch.rs index 0990ae01d..b0ec7866a 100644 --- a/crates/horizon-client/src/mobs/witch.rs +++ b/crates/horizon-client/src/mobs/witch.rs @@ -193,6 +193,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } Who::Player => false, }; + // `target.hasEffect`: a target that already has the effect gets the next one. + let has = |effect: &str| match who { + Who::Player => cx.w.player.as_ref().is_some_and(|p| p.has_effect(effect)), + Who::Mob(id) => cx.w.seen(id).is_some_and(|s| s.has_effect(effect)), + }; let (effect, ticks, amplifier) = if heals { m.ai.target = None; if health <= 4.0 { @@ -200,11 +205,11 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { } else { ("minecraft:regeneration", 900, 0) } - } else if dist >= 8.0 { + } else if dist >= 8.0 && !has("slowness") { ("minecraft:slowness", 1800, 0) - } else if health >= 8.0 { + } else if health >= 8.0 && !has("poison") { ("minecraft:poison", 900, 0) - } else if dist <= 3.0 && m.random.next_float() < 0.25 { + } else if dist <= 3.0 && !has("weakness") && m.random.next_float() < 0.25 { ("minecraft:weakness", 1800, 0) } else { ("minecraft:instant_damage", 1, 0) @@ -236,6 +241,15 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) { pitch, )); } +/// The movement speed of a witch with `SPEED_MODIFIER_DRINKING` (-0.25, +/// `ADD_VALUE`) while it drinks: it stands still for the 32 ticks. +pub fn speed(m: &Mob, base: f64) -> f64 { + if m.species() == Species::Witch && m.extra.flag { + (base - 0.25).max(0.0) + } else { + base + } +} /// Witch.aiStep before `super.aiStep()`: the healing cooldown, then drink /// water breathing, fire resistance, healing or swiftness. pub fn ai_step(m: &mut Mob, cx_events: &mut Vec, target_far: bool) { diff --git a/crates/horizon-client/src/mobs/wither.rs b/crates/horizon-client/src/mobs/wither.rs index 6702686c5..2fdd16372 100644 --- a/crates/horizon-client/src/mobs/wither.rs +++ b/crates/horizon-client/src/mobs/wither.rs @@ -96,7 +96,7 @@ pub fn make_invulnerable(m: &mut Mob) { pub fn powered(m: &Mob) -> bool { m.health <= m.attrs.max_health / 2.0 } -fn head_pos(m: &Mob, i: usize) -> [f64; 3] { +pub fn head_pos(m: &Mob, i: usize) -> [f64; 3] { if i == 0 { return [m.position.x, m.position.y + 3.0, m.position.z]; } @@ -221,6 +221,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec { /// The biome's base temperature at a position. @@ -206,6 +208,25 @@ impl MobWorld<'_> { } self.world.light(x, y, z) >> 4 == 15 } + /// Level.precipitationAt: raining, open to the sky, nothing motion + /// blocking above, then the biome's precipitation at the position: + /// `Some(true)` rain, `Some(false)` snow, `None` nothing. + pub fn precipitation_at(&self, x: i32, y: i32, z: i32) -> Option { + if !self.raining || !self.can_see_sky(x, y, z) || self.motion_blocking_height(x, z) > y { + return None; + } + let chunk = self.world.chunks.get(&(x >> 4, z >> 4))?; + let biome = self.biomes.get(chunk.biome(x, y, z)); + if !biome.precipitation { + return None; + } + Some(biome.temperature_at(BlockPos::new(x, y, z), self.sea_level) >= 0.15) + } + /// Level.isRainingAt: the precipitation at the position is rain, not snow + /// or none. + pub fn raining_at(&self, x: i32, y: i32, z: i32) -> bool { + self.precipitation_at(x, y, z) == Some(true) + } /// Level.getLightLevelDependentMagicValue: the dimension's brightness curve /// at the raw (sky darkened) light level. pub fn brightness(&self, x: i32, y: i32, z: i32) -> f32 { @@ -230,6 +251,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: true, is_player: true, creative: p.creative, + passenger: p.passenger, }), Who::Mob(id) => self.seen(id).map(|s| TargetInfo { position: s.position, @@ -239,6 +261,7 @@ impl MobWorld<'_> { can_be_seen_as_enemy: !s.playing_dead, is_player: false, creative: false, + passenger: s.passenger, }), } } diff --git a/crates/horizon-client/src/mod_content_tests.rs b/crates/horizon-client/src/mod_content_tests.rs index 3d38fae4d..aeeab3016 100644 --- a/crates/horizon-client/src/mod_content_tests.rs +++ b/crates/horizon-client/src/mod_content_tests.rs @@ -114,6 +114,7 @@ fn ruby_mod(dir: &Path) -> ContentMod { /// new blocks behave like their templates with the overrides, and vanilla's /// states are where they were. #[test] +#[ignore = "full content root: CI and pr_ready run it"] fn content_root_loads_with_every_table() { let Some(base) = prepared() else { return; @@ -352,6 +353,7 @@ fn no_content_keeps_the_base_root() { use horizon_world::registry::BlockStateId; #[test] +#[ignore = "full content root: CI and pr_ready run it"] fn shared_behaviour_document_matches_independent_loaders() { let Some(root) = prepared() else { return }; let assets = Assets::load(&root).expect("assets"); @@ -414,6 +416,7 @@ fn shared_behaviour_document_matches_independent_loaders() { #[cfg(unix)] #[test] +#[ignore = "full content root: CI and pr_ready run it"] fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() { let Some(base) = prepared() else { return }; let scratch = std::env::temp_dir().join(format!("horizon-json-reloads-{}", std::process::id())); @@ -535,6 +538,7 @@ fn independent_reloads_read_changed_behaviour_json_and_report_invalid_files() { /// A built root is kept for later starts, and built again when a mod's /// files change. #[test] +#[ignore = "full content root: CI and pr_ready run it"] fn content_root_is_reused_until_a_mod_changes() { let Some(base) = prepared() else { return; @@ -571,6 +575,7 @@ fn content_root_is_reused_until_a_mod_changes() { /// set, pool and templates join the bundles, and its biome tag keeps only /// what the loader knows. #[test] +#[ignore = "full content root: CI and pr_ready run it"] fn content_root_carries_a_structure_mod() { let Some(base) = prepared() else { return; diff --git a/crates/horizon-client/src/model_library.rs b/crates/horizon-client/src/model_library.rs index 018bc6af2..5edf0f5a4 100644 --- a/crates/horizon-client/src/model_library.rs +++ b/crates/horizon-client/src/model_library.rs @@ -232,18 +232,7 @@ mod tests { .block_library(&fixture.0, vec!["horizon:nested/child".into()]) .is_ok() ); - let library = parsed.into_item_library(); - assert!(library.is_err()); - fs::write(fixture.0.join("items.json"), "{")?; - let load = || { - crate::item_render::ItemRenderer::load_with_library(&fixture.0, library.clone()) - .err() - .unwrap() - .to_string() - }; - assert!(!load().starts_with("item models:")); - fs::write(fixture.0.join("items.json"), "{}")?; - assert!(load().starts_with("item models:")); + assert!(parsed.into_item_library().is_err()); Ok(()) } } diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index fcdd32cbd..0ff3b13c5 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -44,13 +44,19 @@ use std::{ pub(crate) mod environment; mod fluids; -pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER}; +pub use fluids::{FluidResources, FluidTags, LAVA, PlayerFluids, WATER, bubble_column_push}; pub struct MotionResources { pub attributes: Arc, pub blocks: BoundMovementProperties, pub climbable: BTreeSet, + /// `BlockTags.CAN_GLIDE_THROUGH`: climbable blocks a gliding player passes. + pub glide_through: BTreeSet, + /// `BlockTags.SOUL_SPEED_BLOCKS`: what Soul Speed's effects look for. + pub soul_speed_blocks: BTreeSet, pub fall_reset: BTreeSet, + /// `BlockTags.CRYSTAL_SOUND_BLOCKS`: blocks that chime under footsteps. + pub crystal_sound: BTreeSet, pub landing: BlockLandingRegistry, pub health: crate::health::HealthResources, pub fluids: FluidResources, @@ -108,11 +114,31 @@ impl MotionResources { suppresses_bounce: tag("minecraft:suppresses_bounce")?, })?, climbable: tag("minecraft:climbable")?, + glide_through: tag("minecraft:can_glide_through")?, + soul_speed_blocks: tag("minecraft:soul_speed_blocks")?, fall_reset: tag("minecraft:fall_damage_resetting")?, + crystal_sound: tag("minecraft:crystal_sound_blocks")?, }) } } +/// What the `location_changed` enchantment effects (Soul Speed) need to know +/// between ticks. +#[derive(Clone, Debug, Default)] +pub struct LocationEffects { + /// `MovementAffectedByPredicate`: the block under the player + /// (`getBlockPosBelowThatAffectsMyMovement`) is in `#minecraft:soul_speed_blocks`. + pub on_soul_speed_block: bool, + /// `LivingEntity.lastPos`: the block the player was in last tick. + pub block: Option, + /// A landing ran `onChangedBlock` (`LivingEntity.checkFallDamage`). + pub landed: bool, + /// The Soul Speed level of the boots seen last tick (0: none). + pub boots_level: u32, + /// The Soul Speed level whose attribute modifiers are on the player (0: none). + pub applied_level: u32, +} + /// Ordered requests and phase records for world/audio/network consumers. Landing /// hurt dispatch updates stored health synchronously before these are drained. #[derive(Clone, Copy, Debug)] @@ -142,6 +168,11 @@ pub enum MotionEvent { state: BlockStateId, in_water: bool, }, + /// `Entity.playAmethystStepSound`: `block.amethyst_block.chime`. + Chime { + volume: f32, + pitch: f32, + }, /// `Entity.waterSwimSound`: `entity.player.swim`. Swim { volume: f32, @@ -203,6 +234,14 @@ pub enum MotionEvent { bubble: bool, position: Vec3, }, + /// `HoneyBlock.maybeDoSlideEffects` for a player sliding down the side of + /// the honey block at `pos`: its slide sound, and the server's entity + /// event 53 (the five block particles at the player). + HoneySlide { + pos: BlockPos, + sound: bool, + particles: bool, + }, /// Entity event 67 (`LivingEntity.makeDrownParticles`): eight bubbles /// around a drowning player. Drown, @@ -220,6 +259,9 @@ pub struct PlayerMotion { pub history: MovementHistory, pub fall_distance: f64, pub stuck_multiplier: Vec3, + /// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving + /// block has pushed this entity this tick (0.51 at most on any axis). + pub piston: horizon_world::piston_movement::PistonMovement, pub yaw: f32, pub head_yaw: f32, pub jumping: bool, @@ -249,12 +291,16 @@ pub struct PlayerMotion { pub pitch: f32, /// Gliding (shared flag 7) and LivingEntity.fallFlyTicks. pub fall_flying: bool, + /// Asleep in a bed (`LivingEntity.isSleeping`): the sleeping pose. + pub sleeping: bool, pub fall_fly_ticks: i32, /// The server saw the glide end last tick (`updateFallFlying`'s `canGlide` /// failed): the cleared flag reaches the client at its next tick. pub glide_ending: bool, /// Glider durability to take this tick (updateFallFlying). pub glider_wear: u32, + /// Soul Speed's `location_changed` bookkeeping. + pub location_effects: LocationEffects, /// `LivingEntity.autoSpinAttackTicks` and `autoSpinAttackDmg`: Riptide's /// spin. The damage stays until the game has dealt the hit. pub auto_spin_attack_ticks: i32, @@ -276,6 +322,10 @@ pub struct PlayerMotion { /// `Entity.moveDist`, `flyDist` and `nextStep`: footsteps come once per /// metre walked, not once per move. pub steps: MovementDistances, + /// `Entity.crystalSoundIntensity` and `lastCrystalSoundPlayTick`: the + /// amethyst chime grows with steps taken close together. + pub crystal_sound_intensity: f32, + pub last_crystal_sound_play_tick: i32, pub needs_sync: bool, pub sync_position: bool, /// Moved by an ender pearl (`ServerPlayer.teleport`): a server sends the @@ -308,6 +358,9 @@ pub struct PlayerMotion { /// it travels on the server: `isLocalInstanceAuthoritative` is false, so /// its moves check no fall damage. pub server_copy: bool, + /// The span of `events` a wall hit on the server's copy pushed (sound and + /// damage): `server_travel` keeps them while it drops the simulated moves. + pub wall_hit_events: Option>, /// A client's copy of its own player (`Level.isClientSide`): only the /// server hurts it (`Entity.hurt` does nothing on a client) and ticks its /// food (`ServerPlayer.tick`). Its hurt animation comes from the server. @@ -422,6 +475,7 @@ impl PlayerMotion { history: MovementHistory::default(), fall_distance: 0.0, stuck_multiplier: Vec3::ZERO, + piston: Default::default(), yaw: 0.0, head_yaw: 0.0, jumping: false, @@ -440,9 +494,11 @@ impl PlayerMotion { glider: false, pitch: 0.0, fall_flying: false, + sleeping: false, fall_fly_ticks: 0, glide_ending: false, glider_wear: 0, + location_effects: LocationEffects::default(), auto_spin_attack_ticks: 0, auto_spin_attack_dmg: 0.0, spin_targets: Vec::new(), @@ -464,6 +520,8 @@ impl PlayerMotion { next_step: 1.0, ..MovementDistances::default() }, + crystal_sound_intensity: 0.0, + last_crystal_sound_play_tick: 0, needs_sync: false, sync_position: false, teleported: false, @@ -477,6 +535,7 @@ impl PlayerMotion { freeze_immune: false, old_position: position, server_copy: false, + wall_hit_events: None, client_side: false, block_effects: true, powder_snow_entered: Vec::new(), @@ -765,6 +824,10 @@ impl MotionWorld<'_, '_, '_> { .state(self.block(p)) .ok_or("missing scene state")? .name; + // A gliding player passes through vines (`CAN_GLIDE_THROUGH`). + if self.player.fall_flying && self.resources.glide_through.contains(name) { + return Ok(false); + } let climbable = self.resources.climbable.contains(name); if climbable { self.player.last_climbable = Some(p); @@ -834,9 +897,17 @@ impl MotionWorld<'_, '_, '_> { let can_glide = self.player.glider && !self.player.ground.on_ground && !self.player.flying + && self.player.status.levitation.is_none() && !self.player.fluids.was_touching_water && !self.player.is_in_lava(); - if jumping && !toggled && !was_jumping && !self.player.fall_flying && can_glide { + // LocalPlayer.aiStep: `!onClimbable()` before tryToStartFallFlying. + if jumping + && !toggled + && !was_jumping + && !self.climbable()? + && !self.player.fall_flying + && can_glide + { self.player.fall_flying = true; } // LivingEntity.tick fallFlyTicks, then updateFallFlying. @@ -848,8 +919,11 @@ impl MotionWorld<'_, '_, '_> { self.player.fall_distance = 1.0; } self.player.fall_fly_ticks += 1; - // canGlide alone: water and lava don't end a glide. - let still = self.player.glider && !self.player.ground.on_ground && !self.player.flying; + // canGlide alone: water and lava don't end a glide, levitation does. + let still = self.player.glider + && !self.player.ground.on_ground + && !self.player.flying + && self.player.status.levitation.is_none(); if !still { self.player.glide_ending = true; } else if (self.player.fall_fly_ticks + 1) % 10 == 0 @@ -984,6 +1058,7 @@ impl MotionWorld<'_, '_, '_> { ) }); self.player.try_add_frost(on_air)?; + self.update_soul_speed_block()?; if self.player.health.tick_count % 40 == 0 && self.player.is_fully_frozen() && self.player.can_freeze() @@ -996,6 +1071,22 @@ impl MotionWorld<'_, '_, '_> { } Ok(()) } + /// Refresh whether the player stands on a soul speed block, for the + /// `location_changed` enchantment effects (a server's player too, whose + /// client does the walking). + pub fn update_soul_speed_block(&mut self) -> Result<()> { + self.player.location_effects.on_soul_speed_block = self.on_soul_speed_block()?; + Ok(()) + } + /// `MovementAffectedByPredicate` for `#minecraft:soul_speed_blocks`. + fn on_soul_speed_block(&mut self) -> Result { + let on = self.support(movement_support::MOVEMENT_SUPPORT_OFFSET)?; + Ok(self + .assets + .registry + .state(self.block(on)) + .is_some_and(|s| self.resources.soul_speed_blocks.contains(&s.name))) + } /// Player.travel: swim steering, then LivingEntity.travel (fluids first, /// then gliding, then air), keeping 0.6 of the vertical speed while flying. fn player_travel(&mut self, input: Vec3) -> Result<()> { @@ -1021,7 +1112,14 @@ impl MotionWorld<'_, '_, '_> { { horizon_world::living_fluid::travel_in_fluid(self, input)?; } else if self.player.fall_flying { - self.travel_fall_flying()?; + // LivingEntity.travelFallFlying: on a climbable the glide ends and + // the player moves like in the air. + if self.climbable()? { + self.travel_in_air(input)?; + self.player.fall_flying = false; + } else { + self.travel_fall_flying()?; + } } else { self.travel_in_air(input)?; } @@ -1048,7 +1146,14 @@ impl MotionWorld<'_, '_, '_> { p.server_copy = false; (p.position, p.ground, p.collision) = (position, ground, collision); (p.history, p.fall_distance, p.steps) = (history, fall_distance, steps); + // The wall hit is the server's own: its sound and damage events stay. + let kept: Vec<_> = p + .wall_hit_events + .take() + .map(|span| p.events.drain(span).collect()) + .unwrap_or_default(); p.events.truncate(events); + p.events.extend(kept); travelled } /// `LivingEntity.checkAutoSpinAttack`: the first living entity in the @@ -1373,6 +1478,9 @@ impl MotionWorld<'_, '_, '_> { "minecraft:sweet_berry_bush" if damage_only => { self.sweet_berry_bush_damage(state)?; } + // HoneyBlock.entityInside: a server's player slides too, so + // its fall distance resets (its client moves it). + "minecraft:honey_block" => self.honey_block_inside(p)?, // PowderSnowBlock.entityInside's FREEZE. "minecraft:powder_snow" if damage_only => entered.push((step, p)), _ => {} @@ -1403,6 +1511,50 @@ impl MotionWorld<'_, '_, '_> { } Ok(iterations + 1) } + /// `HoneyBlock.entityInside`: a falling player touching a side of the block + /// slides down it (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects`). + fn honey_block_inside(&mut self, pos: BlockPos) -> Result<()> { + let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08; + let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32); + let position = self.player.position; + let velocity = self.player.velocity; + if self.player.ground.on_ground + || position.y > f64::from(pos.y) + 0.9375 - 1.0e-7 + || old_delta_y(velocity.y) >= -0.08 + { + return Ok(()); + } + let dx = (f64::from(pos.x) + 0.5 - position.x).abs(); + let dz = (f64::from(pos.z) + 0.5 - position.z).abs(); + let overlap = 0.4375 + f64::from(self.player.dimensions.width / 2.0); + if !(dx + 1.0e-7 > overlap || dz + 1.0e-7 > overlap) { + return Ok(()); + } + // doSlideMovement + let old = old_delta_y(velocity.y); + if old < -0.13 { + let factor = -0.05 / old; + self.player.velocity = + Vec3::new(velocity.x * factor, new_delta_y(-0.05), velocity.z * factor); + } else { + self.player.velocity = Vec3::new(velocity.x, new_delta_y(-0.05), velocity.z); + } + self.player.fall_distance = 0.0; + // maybeDoSlideEffects: the sound, then the server's event 53. + let random = &mut self.scene.random; + let sound = random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0; + // Only the server broadcasts event 53 (`!level.isClientSide()`). + let particles = !self.player.client_side + && random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0; + if sound || particles { + self.player.events.push(MotionEvent::HoneySlide { + pos, + sound, + particles, + }); + } + Ok(()) + } /// `Player.makeStuckInBlock`: the next move is scaled by `multiplier` /// (and the fall forgotten) unless flying; the impulse context resets /// either way. @@ -1536,7 +1688,7 @@ impl MotionWorld<'_, '_, '_> { let lean = pitch; let look_h = (look.x * look.x + look.z * look.z).sqrt(); let move_h = (v.x * v.x + v.z * v.z).sqrt(); - let gravity = 0.08; + let gravity = living_motion::effective_gravity(self)?; let lift = f64::from(lean.cos()).powi(2); let mut m = Vec3::new(v.x, v.y + gravity * (-1.0 + lift * 0.75), v.z); if m.y < 0.0 && look_h > 0.0 { @@ -1564,7 +1716,44 @@ impl MotionWorld<'_, '_, '_> { } self.player.velocity = Vec3::new(m.x * 0.99, m.y * 0.98, m.z * 0.99); let movement = self.player.velocity; - self.move_self(movement) + self.move_self(movement)?; + // LivingEntity.handleFallFlyingCollisions: only the server hurts. + if !self.player.client_side && self.player.collision.horizontal { + let new_h = { + let v = self.player.velocity; + (v.x * v.x + v.z * v.z).sqrt() + }; + let dmg = ((move_h - new_h) * 10.0 - 3.0) as f32; + if dmg > 0.0 { + let first = self.player.events.len(); + // getFallDamageSound((int) dmg) + let sound = if dmg as i32 > 4 { + crate::landing::SceneSound::BigFall + } else { + crate::landing::SceneSound::SmallFall + }; + self.player + .events + .push(MotionEvent::Fall(crate::landing::FallEvent::Sound { + sound, + position: None, + volume: 1.0, + pitch: 1.0, + })); + let id = self + .resources + .health + .damage + .id("minecraft:fly_into_wall") + .ok_or("missing fly_into_wall damage type")?; + let rules = self.scene.health_rules; + self.player.hurt(&self.resources.health, rules, id, dmg)?; + if self.player.server_copy { + self.player.wall_hit_events = Some(first..self.player.events.len()); + } + } + } + Ok(()) } /// `LocalPlayer.move`: the move, then `updateAutoJump` with the float /// horizontal displacement. @@ -1722,8 +1911,14 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { fn set_collision_flags(&mut self, value: CollisionFlags) { self.player.collision = value; } - fn limit_piston_movement(&mut self, _: Vec3) -> Result { - Err("piston movement requires a ticking entity runtime".into()) + /// `Entity.limitPistonMovement`: the per-tick 0.51-block budget shared by + /// every move on an axis. A failed game-time read leaves the accumulator + /// as it was, as native does. + fn limit_piston_movement(&mut self, movement: Vec3) -> Result { + let time = self.scene.game_time; + self.player + .piston + .limit(movement, || Ok::>(time)) } fn stuck_multiplier(&self) -> Vec3 { self.player.stuck_multiplier @@ -1842,6 +2037,10 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { if self.player.status.slow_falling || self.player.status.levitation.is_some() { self.player.fall_distance = 0.0; } + // LivingEntity.checkFallDamage: a landing runs onChangedBlock. + if grounded && self.player.fall_distance > 0.0 { + self.player.location_effects.landed = true; + } if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0 { self.player.events.push(MotionEvent::SmashLanding { @@ -1992,6 +2191,30 @@ impl MovementEmissionWorld for MotionWorld<'_, '_, '_> { state, in_water, }); + // Entity.walkingStepSound: the amethyst chime after the step. + let name = &self + .assets + .registry + .state(state) + .ok_or("missing scene state")? + .name; + let tick = self.player.health.tick_count; + if self.resources.crystal_sound.contains(name) + && tick >= self.player.last_crystal_sound_play_tick + 20 + { + // Entity.playAmethystStepSound (the level random stands in for + // the entity's). + let elapsed = f64::from(tick - self.player.last_crystal_sound_play_tick); + let decayed = self.player.crystal_sound_intensity * 0.997_f64.powf(elapsed) as f32; + let intensity = 1.0_f32.min(decayed + 0.07); + self.player.crystal_sound_intensity = intensity; + let pitch = 0.5 + intensity * self.scene.random.next_float() * 1.2; + let volume = 0.1 + intensity * 1.2; + self.player + .events + .push(MotionEvent::Chime { volume, pitch }); + self.player.last_crystal_sound_play_tick = tick; + } Ok(()) } fn step_event(&mut self, _: BlockStateId) -> Result<()> { @@ -3871,4 +4094,190 @@ mod tests { "{walked} steps over {metres} step-metres" ); } + /// Vanilla sprint is a state, not a held key: `LocalPlayer.aiStep` starts + /// it with the sprint key and stops it only on a vanilla stop condition. + /// Releasing the key while swimming keeps the sprint and its swim speed. + #[test] + fn a_sprint_survives_the_sprint_key_coming_up_while_swimming() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let water = assets.registry.default_state("minecraft:water").unwrap(); + let forward = crate::sprint::Keys { + forward: true, + ..Default::default() + }; + let sprinting = crate::sprint::Keys { + sprint: true, + ..forward + }; + // The same swim with the sprint key held, and with the key released + // once the sprint has started: the speed must not change. + let mut swims = Vec::new(); + for release in [false, true] { + let mut scene = World::test_scene(&assets, &render, 3).unwrap(); + for x in 19..=21 { + for y in 4..=8 { + for z in 19..=31 { + scene.set(x, y, z, water).unwrap(); + } + } + } + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap(); + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + let mut speed = Vec::new(); + for tick in 0..12 { + // The key comes up on the fourth tick, after the sprint has + // started and the player swims. + let keys = if release && tick >= 4 { + forward + } else { + sprinting + }; + w.controlled_simulation_tick(keys).unwrap(); + speed.push(w.player.velocity.z); + } + assert!( + w.player.sprinting, + "the sprint state survives the sprint key in water" + ); + assert!(w.player.swimming, "a sprinting player in water swims"); + swims.push(speed); + } + assert_eq!( + swims[0], swims[1], + "the swim speed must not drop when the sprint key comes up" + ); + } + + /// Vanilla: `LivingEntity.jumpFromGround` adds its 0.2 forward impulse + /// while the sprint state holds, so a jump taken after the sprint key + /// comes up is still a sprint jump. + #[test] + fn a_sprint_jump_keeps_its_impulse_when_the_sprint_key_comes_up() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 3).unwrap(); + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 4.0, 20.5)).unwrap(); + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + let forward = crate::sprint::Keys { + forward: true, + ..Default::default() + }; + let sprinting = crate::sprint::Keys { + sprint: true, + ..forward + }; + for _ in 0..20 { + w.controlled_simulation_tick(sprinting).unwrap(); + } + assert!(w.player.ground.on_ground, "the player settles on the floor"); + assert!(w.player.sprinting, "the sprint key starts the sprint"); + let running = w.player.velocity.z; + // The sprint key comes up and the player jumps. + w.controlled_simulation_tick(crate::sprint::Keys { + jump: true, + ..forward + }) + .unwrap(); + assert!( + w.player.sprinting, + "the sprint state survives the sprint key in the air" + ); + assert!( + w.player.velocity.z > running + 0.05, + "the sprint jump keeps its forward impulse: {:?} vs {running}", + w.player.velocity + ); + } + + /// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability + /// speed, doubled while the sprint state holds. The sprint key starts the + /// state; releasing it does not change the speed. + #[test] + fn the_flight_speed_follows_the_sprint_state_not_the_sprint_key() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("movement-properties.nbt").exists() { + return; + } + let assets = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + let mut scene = World::test_scene(&assets, &render, 3).unwrap(); + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap(); + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + w.player.may_fly = true; + w.player.flying = true; + let forward = crate::sprint::Keys { + forward: true, + ..Default::default() + }; + let sprinting = crate::sprint::Keys { + sprint: true, + ..forward + }; + assert_eq!( + w.flying_speed().unwrap(), + 0.05, + "a flying player without a sprint moves at the ability speed" + ); + // The sprint key starts the sprint, and the state doubles the speed. + w.controlled_simulation_tick(sprinting).unwrap(); + assert!(w.player.sprinting, "the sprint key starts the sprint"); + assert_eq!( + w.flying_speed().unwrap(), + 0.1, + "a sprinting player flies at twice the ability speed" + ); + // Releasing the sprint key keeps the state, so the speed stays. + w.controlled_simulation_tick(forward).unwrap(); + assert!( + w.player.sprinting, + "the sprint state survives the sprint key in the air" + ); + assert_eq!( + w.flying_speed().unwrap(), + 0.1, + "the flight speed follows the sprint state, not the sprint key" + ); + } } diff --git a/crates/horizon-client/src/motion/environment.rs b/crates/horizon-client/src/motion/environment.rs index fc3c63c77..5e9bdfec8 100644 --- a/crates/horizon-client/src/motion/environment.rs +++ b/crates/horizon-client/src/motion/environment.rs @@ -161,6 +161,29 @@ pub(crate) fn fire_contact( } damage } +/// `CactusBlock.entityInside`: the box (deflated by `1.0E-5`, as in +/// `Entity.checkInsideBlocks`) touches a cactus; its inside shape is the +/// whole block. +pub(crate) fn cactus_contact( + world: &World, + registry: &horizon_world::registry::BlockRegistry, + bounds: Aabb, +) -> bool { + let b = bounds.deflate(Vec3::new(1e-5, 1e-5, 1e-5)); + for x in b.min_position().x.floor() as i32..=b.max_position().x.floor() as i32 { + for y in b.min_position().y.floor() as i32..=b.max_position().y.floor() as i32 { + for z in b.min_position().z.floor() as i32..=b.max_position().z.floor() as i32 { + if registry + .state(world.block(x, y, z)) + .is_some_and(|s| s.name == "minecraft:cactus") + { + return true; + } + } + } + } + false +} impl LivingFluidWorld for MotionWorld<'_, '_, '_> { fn in_water(&mut self) -> Result { Ok(self.player.fluids.was_touching_water) diff --git a/crates/horizon-client/src/motion/fluids.rs b/crates/horizon-client/src/motion/fluids.rs index 894e6d907..e3751f0c6 100644 --- a/crates/horizon-client/src/motion/fluids.rs +++ b/crates/horizon-client/src/motion/fluids.rs @@ -119,6 +119,67 @@ impl PlayerMotion { !self.fluids.first_tick && self.fluids.interaction.is_in_fluid(&FluidTags, LAVA) } } +/// `BubbleColumnBlock.entityInside` for an entity that is not the player (a mob, +/// a dropped item, an ender pearl): the bubble columns its box touches push it +/// up or drag it down, harder at the surface (`Entity.handleOnAboveBubbleColumn` +/// and `handleOnInsideBubbleColumn`). Returns whether it was inside a column +/// below the surface, which resets its fall distance. +pub fn bubble_column_push( + world: &World, + registry: &horizon_world::registry::BlockRegistry, + facts: &crate::blocks::BlockFacts, + collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>, + bounds: Aabb, + velocity: &mut Vec3, +) -> bool { + let b = bounds.deflate(Vec3::new( + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + f64::from(1.0e-5f32), + )); + let (lo, hi) = (b.min_position(), b.max_position()); + let mut reset_fall = false; + for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) { + for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) { + for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) { + let state = world.block(x, y, z); + if &*facts.get(state).name != "minecraft:bubble_column" { + continue; + } + let drag = registry + .state(state) + .and_then(|s| s.properties.get("drag")) + .is_some_and(|d| d == "true"); + let above = world.block(x, y + 1, z); + // stateAbove.getCollisionShape(level, pos): the shape is asked + // at the column's own position. + let mut scene = SceneCollisions { + world, + binding: collision, + subject: None, + }; + let context = horizon_world::collision_context::CollisionContext::empty(false); + let nothing_above = collision + .collision_shape_with_context( + &mut scene, + above, + BlockPos::new(x, y, z), + &context, + ) + .is_ok_and(|shape| shape.is_empty()) + && facts.get(above).fluid == crate::blocks::Fluid::None; + velocity.y = match (nothing_above, drag) { + (true, true) => (-0.9_f64).max(velocity.y - 0.03), + (true, false) => 1.8_f64.min(velocity.y + 0.1), + (false, true) => (-0.3_f64).max(velocity.y - 0.03), + (false, false) => 0.7_f64.min(velocity.y + 0.06), + }; + reset_fall |= !nothing_above; + } + } + } + reset_fall +} /// Which fluids `FluidTags.WATER` and `FluidTags.LAVA` hold. pub struct FluidTags; impl FluidMembership for FluidTags { diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index 34daaf85c..93364ed91 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -68,6 +68,9 @@ pub enum Event { host: String, port: i32, }, + /// `ClientboundServerLinksPacket`: the links the pause menu's "Server + /// Links..." lists (ClientCommonPacketListenerImpl.handleServerLinks). + ServerLinks(Vec), /// The connection ended. `from_server`: the server gave the reason (or /// the connection was refused for good), rather than the connection /// failing (auto-reconnect retries only failures and shutdowns). @@ -565,6 +568,9 @@ fn receive_play( play::Clientbound::StoreCookie(p) => { cookies.lock().unwrap().insert(p.key, p.payload); } + play::Clientbound::ServerLinks(p) => { + let _ = send.send(Event::ServerLinks(p.links)); + } // ClientCommonPacketListenerImpl.handleTransfer: the connection // ends and the game connects to the other address. play::Clientbound::Transfer(p) => { @@ -644,6 +650,9 @@ fn configure( cfg::Clientbound::StoreCookie(p) => { cookies.lock().unwrap().insert(p.key, p.payload); } + cfg::Clientbound::ServerLinks(p) => { + let _ = send.send(Event::ServerLinks(p.links)); + } cfg::Clientbound::Transfer(p) => { let _ = send.send(Event::Transfer { host: p.host, diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 61fda9878..2b852a4af 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -4,6 +4,8 @@ //! OptionsList screens and drawn with the shared widgets. use std::time::Instant; +pub use crate::settings::Opt; + use crate::{ gamepad::{self, GlyphStyle}, hud::{HudAtlas, HudBatch, rgba}, @@ -14,93 +16,6 @@ use crate::{ ui, }; -/// One option instance. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Opt { - Fov, - RenderDistance, - SimulationDistance, - Sensitivity, - WheelSensitivity, - InvertY, - DiscreteScroll, - Volume(usize), - GuiScale, - ViewBobbing, - Gamma, - MaxFps, - Vsync, - Fullscreen, - SmoothLighting, - Clouds, - CloudRange, - Particles, - EntityDistance, - EntityShadows, - BiomeBlend, - Preset, - Mipmaps, - WeatherRadius, - ChunkFade, - Vignette, - Autosave, - CutoutLeaves, - ImprovedTransparency, - AttackIndicator, - InactivityFps, - ChunkBuilder, - MenuBlur, - ToggleCrouch, - ToggleSprint, - AutoJump, - ChatVisibility, - ChatColors, - ChatLinks, - ChatOpacity, - TextBackgroundOpacity, - ChatScale, - ChatWidth, - ChatHeightFocused, - ChatHeightUnfocused, - ChatLineSpacing, - AutoSuggestions, - ReducedDebugInfo, - HighContrast, - ShowSubtitles, - NotificationDisplayTime, - Narrator, - TextBackground, - ChatDelay, - DistortionEffects, - FovEffects, - DarknessPulsing, - DamageTilt, - GlintSpeed, - GlintStrength, - HideLightningFlashes, - MonochromeLogo, - PanoramaSpeed, - HideSplashTexts, - NarratorHotkey, - RotateWithMinecarts, - HighContrastBlockOutlines, - ForceUnicode, - JapaneseGlyphs, - /// Horizon's controller settings (`horizon-controller.txt`). - PadEnabled, - PadVibration, - PadAdaptiveTriggers, - PadLightbar, - PadLookSensitivity, - PadInvertX, - PadInvertY, - PadToggleSneak, - PadMoveDeadzone, - PadLookDeadzone, - PadGlyphs, - PadMenuNav, - PadKeyboard, -} fn tr(key: &str) -> Text { Text::translate(key, &[]) } @@ -167,11 +82,13 @@ impl Opt { | Opt::NarratorHotkey | Opt::ChatDelay | Opt::DistortionEffects - | Opt::GlintSpeed - | Opt::GlintStrength | Opt::MonochromeLogo | Opt::RotateWithMinecarts | Opt::HighContrastBlockOutlines + // Saved, but nothing in rendering reads them yet. + | Opt::ChunkFade + | Opt::ImprovedTransparency + | Opt::ChunkBuilder ) } fn file_key(self) -> Option<&'static str> { @@ -192,6 +109,7 @@ impl Opt { Opt::NarratorHotkey => "narratorHotkey", Opt::RotateWithMinecarts => "rotateWithMinecart", Opt::HighContrastBlockOutlines => "highContrastBlockOutline", + Opt::MusicFrequency => "musicFrequency", _ => return None, }) } @@ -237,6 +155,7 @@ impl Opt { Opt::ChatVisibility => "options.chat.visibility", Opt::ChatColors => "options.chat.color", Opt::ChatLinks => "options.chat.links", + Opt::ChatLinksPrompt => "options.chat.links.prompt", Opt::ChatOpacity => "options.chat.opacity", Opt::TextBackgroundOpacity => "options.accessibility.text_background_opacity", Opt::ChatScale => "options.chat.scale", @@ -248,6 +167,7 @@ impl Opt { Opt::ReducedDebugInfo => "options.reducedDebugInfo", Opt::HighContrast => "options.accessibility.high_contrast", Opt::ShowSubtitles => "options.showSubtitles", + Opt::MusicFrequency => "options.music_frequency", Opt::NotificationDisplayTime => "options.notifications.display_time", Opt::Narrator => "options.narrator", Opt::TextBackground => "options.accessibility.text_background", @@ -574,10 +494,16 @@ impl Opt { ][usize::from(s.chat_visibility.min(2))]), Opt::ChatColors => on_off(s.chat_colors), Opt::ChatLinks => on_off(s.chat_links), + Opt::ChatLinksPrompt => on_off(s.chat_links_prompt), Opt::AutoSuggestions => on_off(s.auto_suggestions), Opt::ReducedDebugInfo => on_off(s.reduced_debug_info), Opt::HighContrast => on_off(s.high_contrast), Opt::ShowSubtitles => on_off(s.show_subtitles), + Opt::MusicFrequency => tr([ + "options.music_frequency.default", + "options.music_frequency.frequent", + "options.music_frequency.constant", + ][s.file.int("musicFrequency").clamp(0, 2) as usize]), Opt::ForceUnicode => on_off(s.force_unicode), Opt::JapaneseGlyphs => on_off(s.japanese_glyphs), Opt::PadEnabled => on_off(s.controller.enabled), @@ -625,10 +551,12 @@ impl Opt { self.file_key().unwrap(), crate::options_file::V::Double(f64::from(v).powi(2)), ), - Opt::DamageTilt | Opt::PanoramaSpeed => s.file.set( - self.file_key().unwrap(), - crate::options_file::V::Double(f64::from(v)), - ), + Opt::DamageTilt | Opt::PanoramaSpeed | Opt::GlintSpeed | Opt::GlintStrength => { + s.file.set( + self.file_key().unwrap(), + crate::options_file::V::Double(f64::from(v)), + ) + } Opt::Fov => s.fov = step(30.0, 110.0), Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32, Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32, @@ -680,6 +608,7 @@ impl Opt { | Opt::CutoutLeaves | Opt::ImprovedTransparency | Opt::MenuBlur + | Opt::ChunkBuilder ) } /// Advance a cycle button (CycleButton.cycleValue). @@ -743,10 +672,16 @@ impl Opt { Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3), Opt::ChatColors => s.chat_colors = !s.chat_colors, Opt::ChatLinks => s.chat_links = !s.chat_links, + Opt::ChatLinksPrompt => s.chat_links_prompt = !s.chat_links_prompt, Opt::AutoSuggestions => s.auto_suggestions = !s.auto_suggestions, Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info, Opt::HighContrast => s.high_contrast = !s.high_contrast, Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles, + Opt::MusicFrequency => { + let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3); + s.file + .set("musicFrequency", crate::options_file::V::Int(i32::from(n))); + } Opt::ForceUnicode => s.force_unicode = !s.force_unicode, Opt::JapaneseGlyphs => s.japanese_glyphs = !s.japanese_glyphs, Opt::PadEnabled => s.controller.enabled = !s.controller.enabled, @@ -1412,6 +1347,9 @@ impl OptionsUi { let others: Vec = (1..SOUND_SOURCES.len()).map(Opt::Volume).collect(); rows.extend(pairs(&others)); rows.extend(pairs(&[Opt::ShowSubtitles])); + // SoundOptionsScreen: Music Frequency starts the next row (with + // Music Toast, not in Horizon yet). + rows.extend(pairs(&[Opt::MusicFrequency])); rows } Page::Controls => { @@ -1487,6 +1425,7 @@ impl OptionsUi { Opt::ChatVisibility, Opt::ChatColors, Opt::ChatLinks, + Opt::ChatLinksPrompt, Opt::ChatOpacity, Opt::TextBackgroundOpacity, Opt::ChatScale, @@ -2533,6 +2472,21 @@ impl OptionsUi { if opt == Opt::Preset { apply_preset(s); } + if opt == Opt::HighContrast { + // Options.highContrast: the built-in pack follows the option. + let on = s.resource_packs.iter().any(|id| id == "high_contrast"); + if on != s.high_contrast { + s.resource_packs.retain(|id| id != "high_contrast"); + if s.high_contrast { + s.resource_packs.push("high_contrast".into()); + } + // PackSelectionScreen reads the repository each time it + // opens: the next one starts from this selection. + self.pack_selection = s.resource_packs.clone(); + self.pack_screen = None; + events.push(Event::Packs(s.resource_packs.clone())); + } + } events.push(Event::Changed); } Act::Extra(item) => { @@ -2701,6 +2655,7 @@ impl OptionsUi { { let ids = p.commit(); s.resource_packs = ids.clone(); + self.pack_selection = ids.clone(); events.push(Event::Packs(ids)); } } diff --git a/crates/horizon-client/src/packs.rs b/crates/horizon-client/src/packs.rs index 1e012fd90..28952ba2c 100644 --- a/crates/horizon-client/src/packs.rs +++ b/crates/horizon-client/src/packs.rs @@ -4,9 +4,9 @@ //! and an overlay resource root that layers the enabled packs over the //! vanilla resources (later packs win), so every loader sees one tree. mod cache; -pub mod pattern; use crate::text::Style; use crate::{text::Text, zip::Zip}; +pub use horizon_client_core::pattern; use pattern::Pattern; use std::{ collections::{BTreeMap, BTreeSet}, diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index fa9dc9c6e..9d1c572a2 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -23,212 +23,7 @@ use std::{ path::Path, }; -macro_rules! particle_types { - ($($variant:ident = $name:literal),* $(,)?) => { - /// Vanilla particle types with a client implementation here. - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] - pub enum ParticleType { $($variant),* } - impl ParticleType { - pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*]; - /// Registry path (`minecraft:`), also the sprite-set file name. - pub fn name(self) -> &'static str { - match self { $(ParticleType::$variant => $name),* } - } - pub fn from_name(name: &str) -> Option { - let name = name.strip_prefix("minecraft:").unwrap_or(name); - match name { $($name => Some(ParticleType::$variant),)* _ => None } - } - } - }; -} -particle_types! { - Block = "block", - BlockCrumble = "block_crumble", - Trail = "trail", - DustPillar = "dust_pillar", - FallingDust = "falling_dust", - Smoke = "smoke", - LargeSmoke = "large_smoke", - WhiteSmoke = "white_smoke", - Flame = "flame", - SoulFireFlame = "soul_fire_flame", - CopperFireFlame = "copper_fire_flame", - SmallFlame = "small_flame", - Lava = "lava", - DrippingWater = "dripping_water", - FallingWater = "falling_water", - DrippingLava = "dripping_lava", - FallingLava = "falling_lava", - LandingLava = "landing_lava", - DrippingHoney = "dripping_honey", - FallingHoney = "falling_honey", - LandingHoney = "landing_honey", - FallingNectar = "falling_nectar", - FallingSporeBlossom = "falling_spore_blossom", - SporeBlossomAir = "spore_blossom_air", - DrippingObsidianTear = "dripping_obsidian_tear", - FallingObsidianTear = "falling_obsidian_tear", - LandingObsidianTear = "landing_obsidian_tear", - DrippingDripstoneWater = "dripping_dripstone_water", - FallingDripstoneWater = "falling_dripstone_water", - DrippingDripstoneLava = "dripping_dripstone_lava", - FallingDripstoneLava = "falling_dripstone_lava", - Splash = "splash", - Rain = "rain", - Bubble = "bubble", - SulfurBubbles = "sulfur_bubbles", - BubblePop = "bubble_pop", - BubbleColumnUp = "bubble_column_up", - CurrentDown = "current_down", - Underwater = "underwater", - Portal = "portal", - ReversePortal = "reverse_portal", - Enchant = "enchant", - Nautilus = "nautilus", - VaultConnection = "vault_connection", - Crit = "crit", - EnchantedHit = "enchanted_hit", - DamageIndicator = "damage_indicator", - Heart = "heart", - AngryVillager = "angry_villager", - HappyVillager = "happy_villager", - Composter = "composter", - Mycelium = "mycelium", - Dolphin = "dolphin", - EggCrack = "egg_crack", - Poof = "poof", - Explosion = "explosion", - ExplosionEmitter = "explosion_emitter", - CampfireCosySmoke = "campfire_cosy_smoke", - CampfireSignalSmoke = "campfire_signal_smoke", - Note = "note", - Dust = "dust", - TotemOfUndying = "totem_of_undying", - Firework = "firework", - EndRod = "end_rod", - Ash = "ash", - WhiteAsh = "white_ash", - CrimsonSpore = "crimson_spore", - WarpedSpore = "warped_spore", - CherryLeaves = "cherry_leaves", - PaleOakLeaves = "pale_oak_leaves", - TintedLeaves = "tinted_leaves", - Effect = "effect", - InstantEffect = "instant_effect", - EntityEffect = "entity_effect", - Witch = "witch", - Infested = "infested", - Cloud = "cloud", - Sneeze = "sneeze", - Snowflake = "snowflake", - Soul = "soul", - SculkSoul = "sculk_soul", - DragonBreath = "dragon_breath", - Gust = "gust", - SmallGust = "small_gust", - GustEmitterLarge = "gust_emitter_large", - GustEmitterSmall = "gust_emitter_small", - DustColorTransition = "dust_color_transition", - SculkCharge = "sculk_charge", - SculkChargePop = "sculk_charge_pop", - Shriek = "shriek", - SonicBoom = "sonic_boom", - Vibration = "vibration", - SweepAttack = "sweep_attack", - Glow = "glow", - WaxOn = "wax_on", - WaxOff = "wax_off", - ElectricSpark = "electric_spark", - Scrape = "scrape", - SquidInk = "squid_ink", - GlowSquidInk = "glow_squid_ink", - Item = "item", - ItemSlime = "item_slime", - ItemSnowball = "item_snowball", - ItemCobweb = "item_cobweb", - SulfurCubeGoo = "sulfur_cube_goo", - Flash = "flash", - Firefly = "firefly", - TrialSpawnerDetection = "trial_spawner_detection", - TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous", - TrialOmen = "trial_omen", - RaidOmen = "raid_omen", - OminousSpawning = "ominous_spawning", - Spit = "spit", - Fishing = "fishing", - DustPlume = "dust_plume", - RedPoplarLeaves = "red_poplar_leaves", - OrangePoplarLeaves = "orange_poplar_leaves", - YellowPoplarLeaves = "yellow_poplar_leaves", - PauseMobGrowth = "pause_mob_growth", - ResetMobGrowth = "reset_mob_growth", - BlockMarker = "block_marker", -} -impl ParticleType { - /// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`): - /// spawned regardless of distance. - pub fn override_limiter(self) -> bool { - matches!( - self, - Self::BlockMarker - | Self::DamageIndicator - | Self::ExplosionEmitter - | Self::Explosion - | Self::Gust - | Self::GustEmitterLarge - | Self::GustEmitterSmall - | Self::SonicBoom - | Self::SculkCharge - | Self::SculkChargePop - | Self::Vibration - | Self::Poof - | Self::Spit - | Self::SquidInk - | Self::SweepAttack - | Self::CampfireCosySmoke - | Self::CampfireSignalSmoke - | Self::GlowSquidInk - | Self::Glow - | Self::WaxOn - | Self::WaxOff - | Self::ElectricSpark - | Self::Scrape - | Self::TrialSpawnerDetection - | Self::TrialSpawnerDetectionOminous - | Self::VaultConnection - | Self::OminousSpawning - ) - } - /// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class - /// hardcodes the translucent layer, drawn after translucent terrain. - /// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their - /// layer from the sprite instead (`Layer.bySprite`); see the `translucent` - /// sprite set on the engine. - pub fn translucent_layer(self) -> bool { - match self { - // CampfireSmokeParticle. - Self::CampfireCosySmoke | Self::CampfireSignalSmoke - // PlayerCloudParticle. - | Self::Cloud | Self::Sneeze - // SpellParticle. - | Self::Effect | Self::InstantEffect | Self::EntityEffect - | Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen - // EmissiveRisingParticle. - | Self::Soul | Self::SculkSoul - // SculkChargeParticle, SculkChargePopParticle, ShriekParticle, - // VibrationSignalParticle, FireflyParticle, OverlayParticle. - | Self::SculkCharge | Self::SculkChargePop | Self::Shriek - | Self::Vibration | Self::Firefly | Self::Flash - // SimpleAnimatedParticle and its subclasses: EndRodParticle, - // SparkParticle (firework), SquidInkParticle, TotemParticle. - | Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk - | Self::TotemOfUndying - // FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque. - | Self::VaultConnection => true, - _ => false, - } - } -} +pub use horizon_client_core::particle_type::ParticleType; /// `ParticleOptions` with the parameters of the option classes used here. #[derive(Clone, Debug)] @@ -751,6 +546,17 @@ fn next_double_between(random: &mut impl RandomSource, min: f64, max: f64) -> f6 pub fn on_block_faces( random: &mut impl RandomSource, pos: [i32; 3], + emit: impl FnMut([f64; 3], [f64; 3]), +) { + on_block_faces_between(random, pos, 3, 5, emit); +} +/// `ParticleUtils.spawnParticlesOnBlockFaces` with `UniformInt.of(min, max)` +/// particles per face (3009 sniffer egg cracks use 3 to 6). +pub fn on_block_faces_between( + random: &mut impl RandomSource, + pos: [i32; 3], + min: i32, + max: i32, mut emit: impl FnMut([f64; 3], [f64; 3]), ) { const FACES: [[i32; 3]; 6] = [ @@ -762,7 +568,7 @@ pub fn on_block_faces( [1, 0, 0], ]; for face in FACES { - let count = random.next_int_bound(5 - 3 + 1).unwrap_or(0) + 3; + let count = random.next_int_bound(max - min + 1).unwrap_or(0) + min; for _ in 0..count { // getRandomSpeedRanges, then spawnParticleOnFace with step factor 0.55. let speed: [f64; 3] = std::array::from_fn(|_| next_double_between(random, -0.5, 0.5)); @@ -1188,6 +994,8 @@ pub struct ParticleEngine { emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>, /// Camera position for the 32-block spawn limiter. pub camera: [f64; 3], + /// The Particles option (`ParticleStatus`): 0 all, 1 decreased, 2 minimal. + pub particle_level: u8, /// FireworkParticles.Starter instances. starters: Vec, /// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch). @@ -1218,6 +1026,7 @@ impl ParticleEngine { emitters: Vec::new(), starters: Vec::new(), camera: [0.0; 3], + particle_level: 0, sounds: Vec::new(), made: Default::default(), item_sprites: HashMap::new(), @@ -1328,30 +1137,48 @@ impl ParticleEngine { &mut self.level_random } /// `ClientLevel.addParticle`: dropped beyond 32 blocks of the camera unless - /// the type overrides the limiter. + /// the type overrides the limiter, and thinned by the Particles option. pub fn add(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) { + self.add_with(options, p, v, false, false); + } + /// `ClientLevel.addParticle(particle, overrideLimiter, alwaysShow, ...)` + /// (`doAddParticle`). `always_show` is `addAlwaysVisibleParticle`'s flag. + pub fn add_with( + &mut self, + options: &ParticleOptions, + p: [f64; 3], + v: [f64; 3], + override_limiter: bool, + always_show: bool, + ) { let c = self.camera; let d2 = (p[0] - c[0]).powi(2) + (p[1] - c[1]).powi(2) + (p[2] - c[2]).powi(2); if let Some(requested) = &mut self.requested { requested.push((options.clone(), p, v)); return; } - if !options.ty.override_limiter() && d2 > 1024.0 { - return; + let minimal = self.calculate_particle_level(always_show) == 2; + if override_limiter || options.ty.override_limiter() || (d2 <= 1024.0 && !minimal) { + self.create(options, p, v); } - self.create(options, p, v); + } + /// `ClientLevel.calculateParticleLevel`: minimal still shows one in ten + /// always-visible particles as decreased, and decreased drops a third. + fn calculate_particle_level(&mut self, always_show: bool) -> u8 { + let mut level = self.particle_level.min(2); + if always_show && level == 2 && self.level_random.next_int_bound(10).unwrap_or(0) == 0 { + level = 1; + } + if level == 1 && self.level_random.next_int_bound(3).unwrap_or(0) == 0 { + level = 2; + } + level } /// `LevelEventHandler.singleBlockTeleportationParticles` towards /// `BlockUtil.unpackDifferenceInPosition(from, data, radius)`: the dragon /// egg's event 2015 packs offsets within (16, 8, 16), a consumable's /// teleport (2017) within (127, 127, 127). - pub fn block_teleportation( - &mut self, - from: [i32; 3], - data: i32, - radius: [i32; 3], - particle_level: u8, - ) { + pub fn block_teleportation(&mut self, from: [i32; 3], data: i32, radius: [i32; 3]) { let to = [ from[0].wrapping_add(((data >> 16) & 255) - radius[0]), from[1].wrapping_add(((data >> 8) & 255) - radius[1]), @@ -1369,28 +1196,21 @@ impl ParticleEngine { to[1] + t * (from[1] - to[1]) + r.next_double() - 0.5, to[2] + t * (from[2] - to[2]) + (r.next_double() - 0.5) + 0.5, ]; - // The option roll shares the level random and precedes the distance check. - let limited = match particle_level { - 1 => r.next_int_bound(3).unwrap_or(0) == 0, - 2 => true, - _ => false, - }; - if !limited { - self.add(&options, position, velocity); - } + self.add(&options, position, velocity); } } /// `ClientPacketListener.handleExplosion` + `ClientExplosionTracker`: the - /// explosion particle (emitter for radius >= 2) and up to 512 poof/smoke - /// particles thrown from air inside the radius. + /// explosion particle (the emitter unless `ServerExplosion.isSmall`) and up + /// to 512 poof/smoke particles thrown from air inside the radius. pub fn explosion( &mut self, center: [f64; 3], - radius: f32, + small: bool, block_count: usize, + radius: f32, is_air: &dyn Fn([i32; 3]) -> bool, ) { - let ty = if radius < 2.0 { + let ty = if small { ParticleType::Explosion } else { ParticleType::ExplosionEmitter @@ -1398,6 +1218,18 @@ impl ParticleEngine { self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]); self.explosion_blocks(center, radius, block_count, is_air); } + /// `LlamaSpit.recreateFromPacket`: seven spit particles thrown ahead of a + /// new spit, each faster than the last along x and z. + pub fn llama_spit_burst(&mut self, at: [f64; 3], movement: [f64; 3]) { + for i in 0..7 { + let k = 0.4 + 0.1 * f64::from(i); + self.add( + &ParticleOptions::simple(ParticleType::Spit), + at, + [movement[0] * k, movement[1], movement[2] * k], + ); + } + } /// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`. pub fn explosion_blocks( &mut self, @@ -1406,6 +1238,10 @@ impl ParticleEngine { block_count: usize, is_air: &dyn Fn([i32; 3]) -> bool, ) { + // ClientExplosionTracker.tick: only the All option throws the debris. + if self.particle_level != 0 { + return; + } for _ in 0..block_count.min(512) { let r = &mut self.random; let d = Vec3::new( @@ -3946,6 +3782,8 @@ impl ParticleEngine { }, color, light, + + glint_uv: [0.0, 0.0], }); } } @@ -3970,7 +3808,7 @@ mod tests { let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new()); engine.seed_randoms(0, 0, 0); // Destination offset (-15, 7, 15); the unused top byte is ignored. - engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16], 0); + engine.block_teleportation([4, 12, -5], 0xff01_0f1fu32 as i32, [16, 8, 16]); assert_eq!(engine.pending.len(), 128); assert!(engine.pending.iter().all(|p| p.ty == ParticleType::Portal)); let first = &engine.pending[0]; @@ -3997,7 +3835,7 @@ mod tests { // TeleportRandomlyConsumeEffect: BlockUtil.clampedPackDifferenceInPosition // of the move (-7, -3, 9) with radii 127, from (4, 12, -5) to (-3, 9, 4). let data = (-7 + 127) << 16 | (-3 + 127) << 8 | (9 + 127); - engine.block_teleportation([4, 12, -5], data, [127, 127, 127], 0); + engine.block_teleportation([4, 12, -5], data, [127, 127, 127]); assert_eq!(engine.pending.len(), 128); for p in &engine.pending { let [x, y, z] = p.position; @@ -4023,7 +3861,8 @@ mod tests { if distant { engine.camera = [1000.0; 3]; } - engine.block_teleportation([4, 12, -5], 0x010f1f, [16, 8, 16], option); + engine.particle_level = option; + engine.block_teleportation([4, 12, -5], 0x010f1f, [16, 8, 16]); assert_eq!(engine.pending.len(), if distant { 0 } else { count }); assert_eq!(engine.level_random.state(), state); } diff --git a/crates/horizon-client/src/poi_sections.rs b/crates/horizon-client/src/poi_sections.rs index 79ff0441d..a806706a1 100644 --- a/crates/horizon-client/src/poi_sections.rs +++ b/crates/horizon-client/src/poi_sections.rs @@ -79,6 +79,15 @@ impl PoiSections { } } +/// The POI types villagers use. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Poi { + /// A job site, by profession index. + Job(u8), + Home, + Meeting, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/pose.rs b/crates/horizon-client/src/pose.rs index e34b1f1fc..2a01579f3 100644 --- a/crates/horizon-client/src/pose.rs +++ b/crates/horizon-client/src/pose.rs @@ -15,8 +15,21 @@ pub enum Pose { Swimming, FallFlying, SpinAttack, + Sleeping, } impl Pose { + /// `Pose` by its network id (`Pose.id`); the poses a player never takes + /// are standing. + pub fn from_id(id: i32) -> Self { + match id { + 1 => Self::FallFlying, + 2 => Self::Sleeping, + 3 => Self::Swimming, + 4 => Self::SpinAttack, + 5 => Self::Crouching, + _ => Self::Standing, + } + } pub fn name(self) -> &'static str { match self { Self::Standing => "STANDING", @@ -24,18 +37,24 @@ impl Pose { Self::Swimming => "SWIMMING", Self::FallFlying => "FALL_FLYING", Self::SpinAttack => "SPIN_ATTACK", + Self::Sleeping => "SLEEPING", } } + /// `Avatar.POSES` (and `LivingEntity.SLEEPING_DIMENSIONS`): the box of a + /// player or mannequin in this pose; sleeping ignores the scale. pub fn dimensions(self, scale: f32) -> Dimensions { - let (height, eye_height) = match self { - Self::Standing => (1.8, 1.62), - Self::Crouching => (1.5, 1.27), - Self::Swimming | Self::FallFlying | Self::SpinAttack => (0.6, 0.4), + let (width, height, eye_height) = match self { + Self::Standing => (0.6 * scale, 1.8 * scale, 1.62 * scale), + Self::Crouching => (0.6 * scale, 1.5 * scale, 1.27 * scale), + Self::Swimming | Self::FallFlying | Self::SpinAttack => { + (0.6 * scale, 0.6 * scale, 0.4 * scale) + } + Self::Sleeping => (0.2, 0.2, 0.2), }; Dimensions { - width: 0.6 * scale, - height: height * scale, - eye_height: eye_height * scale, + width, + height, + eye_height, } } } @@ -73,6 +92,19 @@ impl PlayerMotion { } Ok(()) } + /// `LivingEntity.startSleeping` and `stopSleeping` set the pose at once + /// (the 0.2 box in the bed, standing again after). + pub fn set_sleeping(&mut self, sleeping: bool) -> Result<()> { + if self.sleeping != sleeping { + self.sleeping = sleeping; + self.set_pose(if sleeping { + Pose::Sleeping + } else { + Pose::Standing + })?; + } + Ok(()) + } pub fn moving_slowly(&self) -> bool { self.local_crouching || (self.pose == Pose::Swimming && !self.fluids.was_touching_water) } @@ -100,8 +132,10 @@ impl MotionWorld<'_, '_, '_> { if !self.can_fit_pose(Pose::Swimming)? { return Ok(()); } - // Player.getDesiredPose: swimming, gliding, Riptide's spin. - let desired = if self.player.swimming { + // Player.getDesiredPose: sleeping, swimming, gliding, Riptide's spin. + let desired = if self.player.sleeping { + Pose::Sleeping + } else if self.player.swimming { Pose::Swimming } else if self.player.fall_flying { Pose::FallFlying diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 219d59b6b..b3e9938c9 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -28,6 +28,10 @@ pub enum ProjectileKind { critical: bool, effect: Option<(&'static str, i32)>, }, + /// `ThrownTrident` of a mob (a drowned's): flies and sticks with + /// `AbstractArrow`'s tick; the trident itself is the arrow state's + /// `pickup_item`. + Trident, Snowball, Egg, EnderPearl, @@ -66,6 +70,7 @@ impl ProjectileKind { pub fn id(&self) -> &'static str { match self { Self::Arrow { .. } => "minecraft:arrow", + Self::Trident => "minecraft:trident", Self::Snowball => "minecraft:snowball", Self::Egg => "minecraft:egg", Self::EnderPearl => "minecraft:ender_pearl", @@ -104,6 +109,9 @@ pub struct Projectile { pub arrow: Option>, /// `AbstractHurtingProjectile.accelerationPower`. pub acceleration: f64, + /// `Entity.needsSync`: a server tells its clients about the change this + /// tick instead of at the next update interval. + pub needs_sync: bool, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -152,7 +160,7 @@ fn axis_of(d: u8) -> u8 { } impl Projectile { pub fn new(kind: ProjectileKind, position: Vec3, owner: Option) -> Self { - let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. }); + let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. } | ProjectileKind::Trident); Self { id: 0, kind, @@ -168,6 +176,7 @@ impl Projectile { left_owner: false, bonus: Default::default(), acceleration: 0.1, + needs_sync: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( @@ -186,12 +195,13 @@ impl Projectile { self } /// The shooter's bow enchantments; Flame spawns the arrow burning for 100 - /// seconds (`projectile_spawned` ignite). + /// seconds (`projectile_spawned` ignite), Piercing sets its pierce level. pub fn set_bonus(&mut self, bonus: crate::mobs::gear::ArrowBonus) { - if bonus.flame - && let Some(a) = self.arrow.as_mut() - { - a.ignite_for_seconds(100.0); + if let Some(a) = self.arrow.as_mut() { + if bonus.flame { + a.ignite_for_seconds(100.0); + } + a.pierce = bonus.pierce.min(127) as u8; } self.bonus = bonus; } @@ -252,7 +262,7 @@ impl Projectile { } fn gravity(&self) -> f64 { match self.kind { - ProjectileKind::Arrow { .. } => 0.05, + ProjectileKind::Arrow { .. } | ProjectileKind::Trident => 0.05, ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } | ProjectileKind::WitherSkull { .. } => 0.0, @@ -603,8 +613,8 @@ impl Projectile { | ProjectileKind::WitherSkull { .. } => { self.apply_hurting_inertia(water); } - // Arrows tick with `arrows::tick`. - ProjectileKind::Arrow { .. } => {} + // Arrows and tridents tick with `arrows::tick`. + ProjectileKind::Arrow { .. } | ProjectileKind::Trident => {} ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => {} // LlamaSpit.tick: gone in water; drag and gravity follow the move. ProjectileKind::LlamaSpit => { @@ -745,7 +755,8 @@ impl Projectile { ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => return None, ProjectileKind::WitherSkull { dangerous: false } => "entity/wither/wither", ProjectileKind::WitherSkull { dangerous: true } => "entity/wither/wither_invulnerable", - ProjectileKind::Arrow { .. } => return None, + // Drawn as their models by the game. + ProjectileKind::Arrow { .. } | ProjectileKind::Trident => return None, }) } /// Render scale of the item billboard (fireball renderers). diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 5926e0a1d..e746bf946 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -339,59 +339,7 @@ impl SaveTables { } } -/// A container's loot table and what Horizon rolled from it. Vanilla keeps -/// `LootTable` and `LootTableSeed` until the container is first opened; -/// Horizon rolls when it generates or loads the chunk, and saves the table -/// again while the container still holds exactly the rolled items. -#[derive(Clone, Debug, PartialEq)] -pub struct RolledLoot { - pub pos: (i32, i32, i32), - pub table: String, - /// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable). - pub seed: Option, - /// The rolled `Items`. - pub items: Tag, -} -impl RolledLoot { - /// Puts the loot table back in place of `Items` while they are still the - /// roll; false once the container holds anything else. - pub fn restore(&self, map: &mut Compound) -> bool { - if map.get(&"Items".into()) != Some(&self.items) { - return false; - } - map.remove(&"Items".into()); - map.insert("LootTable".into(), string(&self.table)); - if let Some(seed) = self.seed.filter(|s| *s != 0) { - map.insert("LootTableSeed".into(), Tag::Long(seed)); - } - true - } -} - -/// Save data loaded with a chunk that Horizon keeps but does not simulate. -#[derive(Clone, Debug, Default)] -pub struct ChunkExtras { - /// Chunk root keys written back as loaded (structures, PostProcessing, - /// InhabitedTime, blending data, ...). - pub root: Compound, - /// Block entities Horizon does not model. - pub block_entities: Vec, - /// The NBT of the modelled block entities as loaded, to restore the fields and - /// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`). - pub block_entity_sources: Vec<((i32, i32, i32), Tag)>, - /// Every saved block entity's position in the order of the save's list, - /// the order `SerializableChunkData` puts them in the chunk's maps. - pub block_entity_order: Vec<(i32, i32, i32)>, - /// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the - /// save time. Registered with the level's tick scheduler on arrival and - /// replaced with the scheduler's pending ticks when saving. - pub block_ticks: Vec, - pub fluid_ticks: Vec, - /// Game time written as `LastUpdate`. - pub last_update: i64, - /// Containers still holding their loot table's roll. - pub rolled_loot: Vec, -} +pub use crate::chunk::{ChunkExtras, RolledLoot, SavedProto}; fn tick_list(ticks: &[SavedTick], name: impl Fn(u32) -> Option) -> Tag { Tag::List( @@ -643,11 +591,22 @@ pub fn encode_block_entity( entries.push(("custom_name", name.clone())); } } + // TheEndGatewayBlockEntity.saveAdditional. + BlockEntity::EndGateway(g) => { + entries.push(("Age", Tag::Long(g.age))); + if let Some(exit) = g.exit_portal { + entries.push(("exit_portal", Tag::IntArray(exit.to_vec()))); + } + if g.exact_teleport { + entries.push(("ExactTeleport", Tag::Byte(1))); + } + } BlockEntity::Spawner(s) => entries.extend(s.save()), BlockEntity::TrialSpawner(s) => entries.extend(s.save()), BlockEntity::Vault(v) => entries.extend(v.save()), BlockEntity::Beehive(h) => entries.extend(h.to_nbt()), BlockEntity::CommandBlock(c) => entries.extend(c.save()), + BlockEntity::DaylightDetector => {} } Tag::compound(entries) } @@ -701,11 +660,24 @@ fn roll_loaded_loot( biome, )) } +/// `TheEndGatewayBlockEntity.loadAdditional`. +pub fn decode_end_gateway(tag: &Tag) -> crate::containers::EndGateway { + crate::containers::EndGateway { + age: number(tag.get("Age")).unwrap_or(0), + exit_portal: tag + .get("exit_portal") + .and_then(crate::block_entity_nbt::gateway_exit), + exact_teleport: number(tag.get("ExactTeleport")).unwrap_or(0) != 0, + } +} pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::containers::BlockEntity)> { use crate::containers::BlockEntity; let int = |k: &str| number(tag.get(k)).map(|v| v as i32); let id = text(tag.get("id"))?; let pos = (int("x")?, int("y")?, int("z")?); + if id == "minecraft:end_gateway" { + return Some((pos, BlockEntity::EndGateway(decode_end_gateway(tag)))); + } if id == "minecraft:jukebox" { let record = tag.get("RecordItem").and_then(item_from_nbt); let ticks = number(tag.get("ticks_since_song_started")).unwrap_or(0); @@ -845,7 +817,9 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain | BlockEntity::Lectern(_) | BlockEntity::Banner(_) | BlockEntity::CreakingHeart(_) - | BlockEntity::Skull(_) => {} + | BlockEntity::EndGateway(_) + | BlockEntity::Skull(_) + | BlockEntity::DaylightDetector => {} BlockEntity::Spawner(s) => **s = crate::mobs::spawner::Spawner::load(tag), BlockEntity::TrialSpawner(s) => **s = crate::mobs::trial_spawner::TrialSpawner::load(tag), BlockEntity::Vault(v) => **v = crate::vault::Vault::load(tag), @@ -1526,21 +1500,6 @@ fn decode_column( Some(chunk) } -/// A proto chunk saved before it became final. Decoration writes into a -/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks -/// past the finished ones hold writes that generating them again would lose -/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too. -pub struct SavedProto { - /// Blocks, scheduled ticks (in `saved`) and post-processing marks. - pub column: Chunk, - /// Decorated itself (`features`), not only written to by its neighbours - /// (`terrain`). - pub decorated: bool, - /// Block entity data in placement order, with `x`, `y` and `z`. - pub block_entities: Vec, - /// Entities placed by structures, with absolute `Pos`. - pub entities: Vec, -} pub fn encode_proto( proto: &SavedProto, registry: &BlockRegistry, @@ -2433,28 +2392,7 @@ fn write_saved_data(world: &Path, name: &str, data: Tag) -> Result<()> { Tag::compound([("data", data), ("DataVersion", Tag::Int(DATA_VERSION))]), ) } -pub fn uuid_text(u: [i32; 4]) -> String { - let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect(); - format!( - "{}-{}-{}-{}-{}", - &hex[0..8], - &hex[8..12], - &hex[12..16], - &hex[16..20], - &hex[20..32] - ) -} -pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> { - let hex: String = s.chars().filter(|c| *c != '-').collect(); - if hex.len() != 32 { - return None; - } - let mut out = [0i32; 4]; - for (i, v) in out.iter_mut().enumerate() { - *v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32; - } - Some(out) -} +pub use horizon_client_core::uuid::{uuid_from_text, uuid_text}; /// A random (version 4) UUID as vanilla's four-int form. pub fn random_uuid() -> [i32; 4] { let t = std::time::SystemTime::now() @@ -2761,7 +2699,7 @@ pub struct Level { pub xp_seed: i32, /// ServerPlayer.seenCredits: the end poem has been shown. pub seen_credits: bool, - /// Player.active_effects: (id, duration, amplifier, ambient, show_icon). + /// Player.active_effects: (id, duration, amplifier, ambient, show_particles). pub active_effects: Vec<(String, i32, i32, bool, bool)>, /// Player.EnderItems: the ender chest inventory (27 slots). pub ender_items: Vec>, @@ -3041,7 +2979,7 @@ impl Level { get("duration").unwrap_or(0) as i32, get("amplifier").unwrap_or(0) as i32, get("ambient").unwrap_or(0) != 0, - get("show_icon").is_none_or(|v| v != 0), + get("show_particles").is_none_or(|v| v != 0), )) }) .collect(), @@ -3143,14 +3081,14 @@ impl Level { Tag::List( self.active_effects .iter() - .map(|(id, duration, amplifier, ambient, icon)| { + .map(|(id, duration, amplifier, ambient, particles)| { Tag::compound([ ("id", string(id)), ("duration", Tag::Int(*duration)), ("amplifier", Tag::Byte(*amplifier as i8)), ("ambient", Tag::Byte(i8::from(*ambient))), - ("show_particles", Tag::Byte(1)), - ("show_icon", Tag::Byte(i8::from(*icon))), + ("show_particles", Tag::Byte(i8::from(*particles))), + ("show_icon", Tag::Byte(i8::from(*particles))), ]) }) .collect(), @@ -3839,6 +3777,9 @@ impl ChunkGenerator for Persistent { fn is_decorated(&self, proto: &crate::streaming::Proto) -> bool { self.inner.is_decorated(proto) } + fn light_view(&self, proto: &crate::streaming::Proto) -> Option { + self.inner.light_view(proto) + } fn save_protos(&self, protos: &[&crate::streaming::Proto]) { self.save .tables @@ -4658,6 +4599,79 @@ mod tests { } use super::*; + #[test] + fn end_gateway_block_entity_round_trips() { + use crate::containers::{BlockEntity, EndGateway}; + let plain = BlockEntity::for_block("minecraft:end_gateway").expect("gateway"); + assert!(matches!(&plain, BlockEntity::EndGateway(g) if *g == EndGateway::default())); + for exit in [None, Some([8, 100, 0])] { + for exact in [false, true] { + let gateway = EndGateway { + age: 201, + exit_portal: exit, + exact_teleport: exact, + }; + let tag = encode_block_entity( + (702, 100, 0), + &BlockEntity::EndGateway(gateway.clone()), + "minecraft:end_gateway", + ); + assert_eq!( + text(tag.get("id")).as_deref(), + Some("minecraft:end_gateway") + ); + assert_eq!(tag.get("Age"), Some(&Tag::Long(201))); + assert_eq!( + tag.get("exit_portal"), + exit.map(|p| Tag::IntArray(p.to_vec())).as_ref() + ); + assert_eq!( + tag.get("ExactTeleport"), + exact.then_some(Tag::Byte(1)).as_ref() + ); + let (pos, back) = decode_block_entity(&tag).expect("gateway"); + assert_eq!(pos, (702, 100, 0)); + assert!(matches!(back, BlockEntity::EndGateway(g) if g == gateway)); + } + } + } + + #[test] + fn end_gateway_exit_requires_spawnable_bounds() { + for (exit, valid) in [ + (vec![-30_000_000, -20_000_000, -30_000_000], true), + (vec![29_999_999, 19_999_999, 29_999_999], true), + (vec![30_000_000, 0, 0], false), + (vec![0, 0, 30_000_000], false), + (vec![-30_000_001, 0, 0], false), + (vec![0, 0, -30_000_001], false), + (vec![0, 20_000_000, 0], false), + (vec![0, -20_000_001, 0], false), + (vec![i32::MIN, 0, 0], false), + (vec![0, 100], false), + ] { + let tag = Tag::compound([ + ("id", string("minecraft:end_gateway")), + ("x", Tag::Int(702)), + ("y", Tag::Int(100)), + ("z", Tag::Int(0)), + ("exit_portal", Tag::IntArray(exit)), + ]); + let (_, crate::containers::BlockEntity::EndGateway(g)) = + decode_block_entity(&tag).unwrap() + else { + panic!("gateway"); + }; + assert_eq!(g.exit_portal.is_some(), valid); + let canonical = crate::block_entity_nbt::canonical(&tag).expect("gateway NBT"); + assert_eq!(canonical.get("exit_portal").is_some(), valid); + } + assert_eq!( + decode_end_gateway(&Tag::Compound(Compound::new())), + crate::containers::EndGateway::default() + ); + } + #[test] fn skull_block_entity_keeps_its_profile() { // A placed `player_head` as vanilla saves it: the owning player's @@ -5123,6 +5137,30 @@ mod tests { }; assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3)); } + /// The world's save (Level.write) writes the rules as they are now, and a + /// world opened again reads them back (#74). + #[test] + fn level_save_keeps_changed_rules() { + use crate::gamerules::{GameRules, Value}; + let dir = + std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut level = super::Level::default(); + level.write(&dir).unwrap(); + // Rules changed while playing, then saved and loaded again. + let mut rules = GameRules::load(&dir); + assert_eq!(rules, GameRules::default()); + assert!(rules.set("advance_time", Value::Bool(false))); + assert!(rules.set("advance_weather", Value::Bool(false))); + assert!(rules.set("random_tick_speed", Value::Int(0))); + level.game_rules = rules.by_name(); + level.write(&dir).unwrap(); + assert_eq!(GameRules::load(&dir), rules); + let read = super::Level::read(&dir).unwrap(); + assert_eq!(read.game_rules, rules.by_name()); + assert_eq!(read.game_rule("advance_time"), 0); + let _ = std::fs::remove_dir_all(dir); + } /// A save written before the key was namespaced holds an id twice, once bare: vanilla /// refuses such a furnace, so a load merges them under the full id. diff --git a/crates/horizon-client/src/settings.rs b/crates/horizon-client/src/settings.rs index 74c428923..0c59c18f9 100644 --- a/crates/horizon-client/src/settings.rs +++ b/crates/horizon-client/src/settings.rs @@ -5,11 +5,7 @@ use crate::keybinds::KeyBinds; use crate::options_file::{OptionsFile, V}; use std::{fs, path::Path}; -/// SoundSource names in vanilla order. -pub const SOUND_SOURCES: [&str; 11] = [ - "master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient", - "voice", "ui", -]; +pub use horizon_client_core::sound_source::SOUND_SOURCES; /// Per-source volumes for the mixer (soundCategory_* except master). static SOURCE_VOLUMES: std::sync::RwLock<[f32; 11]> = std::sync::RwLock::new([1.0; 11]); @@ -149,6 +145,7 @@ pub struct Settings { pub chat_visibility: u8, pub chat_colors: bool, pub chat_links: bool, + pub chat_links_prompt: bool, pub chat_opacity: f32, pub text_background_opacity: f32, pub chat_scale: f32, @@ -234,6 +231,7 @@ impl Default for Settings { chat_visibility: 0, chat_colors: true, chat_links: true, + chat_links_prompt: true, chat_opacity: 1.0, text_background_opacity: 0.5, chat_scale: 1.0, @@ -427,6 +425,7 @@ impl Settings { chat_visibility: f.int("chatVisibility") as u8, chat_colors: f.bool("chatColors"), chat_links: f.bool("chatLinks"), + chat_links_prompt: f.bool("chatLinksPrompt"), chat_opacity: f.double("chatOpacity") as f32, text_background_opacity: f.double("textBackgroundOpacity") as f32, chat_scale: f.double("chatScale") as f32, @@ -510,6 +509,7 @@ impl Settings { put_i(&mut f, "chatVisibility", i32::from(self.chat_visibility)); put_b(&mut f, "chatColors", self.chat_colors); put_b(&mut f, "chatLinks", self.chat_links); + put_b(&mut f, "chatLinksPrompt", self.chat_links_prompt); put_f(&mut f, "chatOpacity", self.chat_opacity); put_f( &mut f, @@ -608,6 +608,96 @@ impl Settings { } } +/// One option instance. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Opt { + Fov, + RenderDistance, + SimulationDistance, + Sensitivity, + WheelSensitivity, + InvertY, + DiscreteScroll, + Volume(usize), + GuiScale, + ViewBobbing, + Gamma, + MaxFps, + Vsync, + Fullscreen, + SmoothLighting, + Clouds, + CloudRange, + Particles, + EntityDistance, + EntityShadows, + BiomeBlend, + Preset, + Mipmaps, + WeatherRadius, + ChunkFade, + Vignette, + Autosave, + CutoutLeaves, + ImprovedTransparency, + AttackIndicator, + InactivityFps, + ChunkBuilder, + MenuBlur, + ToggleCrouch, + ToggleSprint, + AutoJump, + ChatVisibility, + ChatColors, + ChatLinks, + ChatLinksPrompt, + ChatOpacity, + TextBackgroundOpacity, + ChatScale, + ChatWidth, + ChatHeightFocused, + ChatHeightUnfocused, + ChatLineSpacing, + AutoSuggestions, + ReducedDebugInfo, + HighContrast, + ShowSubtitles, + MusicFrequency, + NotificationDisplayTime, + Narrator, + TextBackground, + ChatDelay, + DistortionEffects, + FovEffects, + DarknessPulsing, + DamageTilt, + GlintSpeed, + GlintStrength, + HideLightningFlashes, + MonochromeLogo, + PanoramaSpeed, + HideSplashTexts, + NarratorHotkey, + RotateWithMinecarts, + HighContrastBlockOutlines, + ForceUnicode, + JapaneseGlyphs, + /// Horizon's controller settings (`horizon-controller.txt`). + PadEnabled, + PadVibration, + PadAdaptiveTriggers, + PadLightbar, + PadLookSensitivity, + PadInvertX, + PadInvertY, + PadToggleSneak, + PadMoveDeadzone, + PadLookDeadzone, + PadGlyphs, + PadMenuNav, + PadKeyboard, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/shadows.rs b/crates/horizon-client/src/shadows.rs index dece7f726..613eb0bb1 100644 --- a/crates/horizon-client/src/shadows.rs +++ b/crates/horizon-client/src/shadows.rs @@ -119,6 +119,8 @@ pub fn shadow( layer: crate::gfx::SHADOW_OVERLAY, color, light: 240 | 240 << 8, + + glint_uv: [0.0, 0.0], }); } } diff --git a/crates/horizon-client/src/signs.rs b/crates/horizon-client/src/signs.rs index 07217ed79..799210797 100644 --- a/crates/horizon-client/src/signs.rs +++ b/crates/horizon-client/src/signs.rs @@ -147,10 +147,10 @@ fn component_text(tag: &Tag) -> String { _ => s, } } - Tag::Compound(_) => match tag.get("text") { - Some(Tag::String(t)) => t.to_string_lossy(), - _ => String::new(), - }, + // Component.getString: translations and key binds resolved. + Tag::Compound(_) => { + crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(tag)).plain() + } _ => String::new(), } } diff --git a/crates/horizon-client/src/skins.rs b/crates/horizon-client/src/skins.rs index ee6851e00..740208973 100644 --- a/crates/horizon-client/src/skins.rs +++ b/crates/horizon-client/src/skins.rs @@ -116,20 +116,9 @@ pub fn unpack(value: &str, signature: Signature, valid_uris: &[&str]) -> Texture /// heads and mannequins read their profile this way, and `SkinManager` lets /// those textures be unsigned (`getInsecureSkinInformation`). pub fn profile_skin_url(profile: &serde_json::Value) -> Option { - let value = profile - .get("properties")? - .as_array()? - .iter() - .find_map(|p| { - if p.get("name")?.as_str()? == "textures" { - p.get("value")?.as_str() - } else { - None - } - })?; - unpack(value, Signature::Unsigned, &TEXTURE_URIS).skin + let (value, signature) = parse_profile(profile).textures?; + unpack(&value, signature, &TEXTURE_URIS).skin } - /// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`): /// the same `properties` list, held as a compound. A name-only profile (a /// string tag) has no properties and no skin URL. @@ -164,13 +153,329 @@ pub fn profile_uuid_nbt(profile: &horizon_world::nbt::Tag) -> Option { } } -fn nbt_text(tag: Option<&horizon_world::nbt::Tag>) -> Option { +pub(crate) fn nbt_text(tag: Option<&horizon_world::nbt::Tag>) -> Option { match tag { Some(horizon_world::nbt::Tag::String(s)) => Some(s.to_string_lossy()), _ => None, } } +/// `Either`: the key a `Dynamic` `ResolvableProfile` resolves +/// by (`ResolvableProfile.nameOrId`). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum NameOrId { + Name(String), + Id(u128), +} + +/// `GameProfile` as `fetchProfile` returns it: the account's id, name and +/// property map (its `textures` property is the skin). +#[derive(Clone, Debug)] +pub struct ResolvedProfile { + pub id: u128, + pub name: String, + /// `(name, value, signature)` properties. + pub properties: Vec<(String, String, Option)>, +} +impl ResolvedProfile { + /// `SessionService.getPackedTextures`: the `textures` property. + pub fn textures(&self) -> Option<(String, Signature)> { + self.properties + .iter() + .find(|(n, _, _)| n == "textures") + .map(|(_, v, s)| { + ( + v.clone(), + if s.is_some() { + Signature::Signed + } else { + Signature::Unsigned + }, + ) + }) + } +} + +/// Where a `Dynamic` profile's resolution stands (`resolveProfile`'s future). +#[derive(Clone, Debug)] +pub enum Resolve { + Pending, + /// The services' answer: `None` for no profile or no answer, which the + /// cache holds like any result (`Optional.empty`). + Done(Option), +} + +/// `ProfileResolver.Cached`: resolves a `Dynamic` profile through the +/// services — `profileCacheByName` asks `profiles`/`getByName` for the id, +/// `profileCacheById` asks `session`/`getProfileById` for the profile — +/// at most 256 entries kept 10 minutes after their last use, fetched on +/// background threads. +pub struct Resolver { + entries: std::collections::HashMap, + done: std::sync::mpsc::Receiver<(NameOrId, Option)>, + send: std::sync::mpsc::Sender<(NameOrId, Option)>, + last_sweep: std::time::Instant, +} +struct Entry { + touched: std::time::Instant, + /// `None` while the fetch is in flight. + resolved: Option>, +} +/// `ProfileResolver.Cached`'s cache bounds. +const MAX_PROFILES: usize = 256; +const PROFILE_TTL: std::time::Duration = std::time::Duration::from_secs(10 * 60); + +/// `StringUtil.isValidPlayerName` (the check `fetchByName` makes first). +fn valid_player_name(name: &str) -> bool { + name.encode_utf16().count() <= 16 && name.encode_utf16().all(|c| c > 32 && c < 127) +} + +/// `fetchByNameOrId`: `getByName` answers `{id, name}`; `getProfileById` +/// (`fetchProfile(id, requireSecure)`) answers `{id, name, properties}` — +/// asked `unsigned=false` so the `textures` property is signed. +fn resolve_profile(key: &NameOrId, doc: &serde_json::Value) -> Option { + let id = match key { + NameOrId::Id(id) => *id, + NameOrId::Name(name) => { + let url = crate::auth::endpoint(doc, "profiles", "getByName")? + .replace("{name}", &name.to_lowercase()); + let body = http_get(&url)?; + let v: serde_json::Value = serde_json::from_slice(&body).ok()?; + crate::auth::parse_uuid(v.get("id")?.as_str()?)? + } + }; + let url = crate::auth::endpoint(doc, "session", "getProfileById")? + .replace("{profileId}", &format!("{id:032x}")); + let separator = if url.contains('?') { '&' } else { '?' }; + let body = http_get(&format!("{url}{separator}unsigned=false"))?; + let v: serde_json::Value = serde_json::from_slice(&body).ok()?; + Some(ResolvedProfile { + id: crate::auth::parse_uuid(v.get("id")?.as_str()?)?, + name: v.get("name")?.as_str()?.to_owned(), + properties: v + .get("properties")? + .as_array()? + .iter() + .map(|p| { + Some(( + p.get("name")?.as_str()?.to_owned(), + p.get("value")?.as_str()?.to_owned(), + p.get("signature") + .and_then(|s| s.as_str()) + .map(str::to_owned), + )) + }) + .collect::>>()?, + }) +} + +impl Default for Resolver { + fn default() -> Self { + Self::new() + } +} +impl Resolver { + pub fn new() -> Self { + let (send, done) = std::sync::mpsc::channel(); + Self { + entries: Default::default(), + done, + send, + last_sweep: std::time::Instant::now(), + } + } + /// `CacheBuilder.expireAfterAccess` with `maximumSize`. + fn sweep(&mut self) { + if self.last_sweep.elapsed() < std::time::Duration::from_secs(30) { + return; + } + self.last_sweep = std::time::Instant::now(); + self.entries + .retain(|_, e| e.touched.elapsed() < PROFILE_TTL); + while self.entries.len() > MAX_PROFILES + && let Some(oldest) = self + .entries + .iter() + .min_by_key(|(_, e)| e.touched) + .map(|(k, _)| k.clone()) + { + self.entries.remove(&oldest); + } + } + /// How the resolution of `key` stands, starting it the first time. + pub fn state(&mut self, key: &NameOrId) -> Resolve { + while let Ok((key, resolved)) = self.done.try_recv() { + self.entries.insert( + key, + Entry { + touched: std::time::Instant::now(), + resolved: Some(resolved), + }, + ); + } + self.sweep(); + if let Some(entry) = self.entries.get_mut(key) { + entry.touched = std::time::Instant::now(); + return match &entry.resolved { + Some(resolved) => Resolve::Done(resolved.clone()), + None => Resolve::Pending, + }; + } + // `fetchByName` refuses an invalid name without a fetch; like every + // other empty answer that is the cached result. + let resolved = matches!(key, NameOrId::Name(name) if !valid_player_name(name)); + self.entries.insert( + key.clone(), + Entry { + touched: std::time::Instant::now(), + resolved: resolved.then_some(None), + }, + ); + if resolved { + return Resolve::Done(None); + } + let (key, send) = (key.clone(), self.send.clone()); + std::thread::spawn(move || { + let resolved = crate::auth::discovery() + .ok() + .and_then(|doc| resolve_profile(&key, &doc)); + let _ = send.send((key, resolved)); + }); + Resolve::Pending + } +} + +/// `ResolvableProfile` (an item's `minecraft:profile` component, a skull's +/// `profile` tag): the `textures` property it carries, the key a `Dynamic` +/// profile resolves by, and `partialProfile.id` — the fallback skin's key. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ProfileInfo { + /// `GameProfile.id` (`DefaultPlayerSkin.get`). + pub id: u128, + /// The `textures` property's value and signature state, when present. + pub textures: Option<(String, Signature)>, + /// `Dynamic` profiles resolve by name or id. + pub dynamic: Option, +} + +/// A profile's `id`: `UUIDUtil.CODEC` (an int array of four, or a UUID +/// string). +fn profile_id(v: &serde_json::Value) -> Option { + match v { + serde_json::Value::Array(a) => { + let ints: Option> = a.iter().map(|x| x.as_i64()).collect(); + let i = ints?; + (i.len() == 4).then(|| { + ((i[0] as u32 as u128) << 96) + | ((i[1] as u32 as u128) << 64) + | ((i[2] as u32 as u128) << 32) + | i[3] as u32 as u128 + }) + } + serde_json::Value::String(s) => crate::auth::parse_uuid(s), + _ => None, + } +} + +/// `ResolvableProfile`'s read of a `profile` value: a bare name is +/// `createUnresolved` (the `PLAYER_NAME` codec's alternative), a compound +/// `name` + `id` + `properties` (`Partial.MAP_CODEC` or a `GameProfile`). +pub fn parse_profile(v: &serde_json::Value) -> ProfileInfo { + if let serde_json::Value::String(name) = v { + return ProfileInfo { + id: horizon_protocol::crypto::offline_uuid(name), + textures: None, + dynamic: valid_player_name(name).then(|| NameOrId::Name(name.clone())), + }; + } + let name = v.get("name").and_then(|n| n.as_str()).map(str::to_owned); + let id = v.get("id").and_then(profile_id); + let properties = v.get("properties").and_then(|p| p.as_array()); + let textures = properties + .into_iter() + .flatten() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("textures")) + .and_then(|p| { + Some(( + p.get("value")?.as_str()?.to_owned(), + if p.get("signature").is_some() { + Signature::Signed + } else { + Signature::Unsigned + }, + )) + }); + // `ResolvableProfile.create`: a Partial is Dynamic with no properties + // and exactly one of a name or an id. + let dynamic = + (properties.is_none_or(|p| p.is_empty()) && name.is_some() != id.is_some()).then(|| { + name.as_deref().map_or_else( + || NameOrId::Id(id.unwrap()), + |n| NameOrId::Name(n.to_owned()), + ) + }); + ProfileInfo { + // `createPartialProfile`: the id, else the name's offline UUID, else nil. + id: id + .or_else(|| name.as_deref().map(horizon_protocol::crypto::offline_uuid)) + .unwrap_or(0), + textures, + dynamic, + } +} + +/// `ResolvableProfile.create`'s `Dynamic` key from a skull's `profile` tag: +/// a bare name string is `createUnresolved`, and a compound without +/// properties and with exactly one of a name or an id is `Dynamic`. +pub fn dynamic_nbt(profile: &horizon_world::nbt::Tag) -> Option { + if let horizon_world::nbt::Tag::String(s) = profile { + let name = s.to_string_lossy(); + return valid_player_name(&name).then_some(NameOrId::Name(name)); + } + if matches!(profile.get("properties"), Some(horizon_world::nbt::Tag::List(l)) if !l.is_empty()) + { + return None; + } + match (nbt_text(profile.get("name")), profile_uuid_nbt(profile)) { + (Some(name), None) => Some(NameOrId::Name(name)), + (None, Some(id)) => Some(NameOrId::Id(id)), + _ => None, + } +} + +/// `PlayerSkinRenderCache.getOrDefault` for a head: the resolved skin once +/// its texture is in the atlas (`atlas`), else `partialProfile`'s default. +pub fn head_skin( + profile: &serde_json::Value, + resolver: &mut Resolver, + downloads: &mut Downloader, + atlas: impl Fn(&str) -> bool, +) -> String { + let p = parse_profile(profile); + let property = p.textures.or_else(|| { + let key = p.dynamic.as_ref()?; + match resolver.state(key) { + Resolve::Done(Some(profile)) => profile.textures(), + _ => None, + } + }); + if let Some((value, signature)) = property + && let Some(url) = unpack(&value, signature, &TEXTURE_URIS).skin + && let Some(hash) = texture_hash(&url) + { + let location = skin_location(&hash); + match downloads.state(Kind::Skin, &url) { + Download::Ready if atlas(&location) => return location, + // Its slot was evicted: offer the image again. + Download::Ready => { + downloads.again(&location); + } + _ => {} + } + } + crate::tab_list::default_skin(p.id).to_owned() +} + /// `MinecraftProfileTexture.getHash`: the base name of the URL's path. pub fn texture_hash(url: &str) -> Option { let rest = url.split_once("://")?.1; @@ -313,7 +618,16 @@ pub struct Downloader { done: std::sync::mpsc::Receiver<((Kind, String), Option>)>, send: std::sync::mpsc::Sender<((Kind, String), Option>)>, ready: Vec<(String, Vec)>, + /// Locations already in `ready`, so `again` does not double-queue. + queued: std::collections::HashSet, + /// Decoded skin images by location, kept so a skin evicted from the + /// atlas uploads again (`again`); the disk cache backs anything dropped. + images: std::collections::HashMap>, + image_order: std::collections::VecDeque, } +/// `images` is bounded only so a long session on a busy server does not grow +/// without end; the disk cache holds everything it drops. +const MAX_SKIN_IMAGES: usize = 1024; impl Downloader { pub fn new(root: std::path::PathBuf) -> Self { let (send, done) = std::sync::mpsc::channel(); @@ -323,6 +637,9 @@ impl Downloader { done, send, ready: Vec::new(), + queued: Default::default(), + images: Default::default(), + image_order: Default::default(), } } /// How the download of `url` stands, starting it the first time. @@ -331,7 +648,8 @@ impl Downloader { let state = match image { Some(rgba) => { if key.0 == Kind::Skin { - self.ready.push((skin_location(&key.1), rgba)); + let location = skin_location(&key.1); + self.queue(location, rgba); } Download::Ready } @@ -370,8 +688,53 @@ impl Downloader { }); Download::Pending } + /// Keeps a decoded skin for `again`, dropping the oldest past the bound. + fn remember(&mut self, location: String, rgba: Vec) { + if !self.images.contains_key(&location) { + self.image_order.push_back(location.clone()); + while self.image_order.len() > MAX_SKIN_IMAGES + && let Some(old) = self.image_order.pop_front() + { + self.images.remove(&old); + } + } + self.images.insert(location, rgba); + } + /// An image off to the atlas: remembered for `again` and marked queued + /// so nothing uploads the same location twice. + fn queue(&mut self, location: String, rgba: Vec) { + self.remember(location.clone(), rgba.clone()); + self.queued.insert(location.clone()); + self.ready.push((location, rgba)); + } + /// A ready skin whose atlas slot was evicted (`show_skin` answered no): + /// its image for upload again, from memory or the disk cache. + pub fn again(&mut self, location: &str) -> bool { + if self.queued.contains(location) { + return true; + } + let Some(digest) = location.strip_prefix("skins/") else { + return false; + }; + let image = self.images.get(location).cloned().or_else(|| { + let file = self + .root + .join(if digest.len() > 2 { &digest[..2] } else { "xx" }) + .join(digest); + let (w, h, rgba) = crate::hud::decode_bytes(&std::fs::read(&file).ok()?).ok()?; + process_legacy_skin(w, h, &rgba) + }); + let Some(image) = image else { + return false; + }; + self.queue(location.to_owned(), image); + true + } /// Skins that finished since the last call: texture id and 64x64 RGBA. pub fn take_ready(&mut self) -> Vec<(String, Vec)> { + for (location, _) in &self.ready { + self.queued.remove(location); + } std::mem::take(&mut self.ready) } } @@ -452,6 +815,134 @@ pub fn sha1(data: &[u8]) -> [u8; 20] { #[cfg(test)] mod tests { + #[test] + fn profiles_parse_like_resolvable_profiles() { + use super::{NameOrId, Signature, parse_profile}; + // A bare name is `createUnresolved`: dynamic by name, and its partial + // id is the name's offline UUID. + let p = parse_profile(&serde_json::json!("Notch")); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + assert_eq!(p.id, horizon_protocol::crypto::offline_uuid("Notch")); + assert!(p.textures.is_none()); + // A name-only compound resolves the same way. + let p = parse_profile(&serde_json::json!({"name": "Notch"})); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + // An id-only compound resolves by id (int array or UUID string). + let id = 0x0123_4567_89ab_cdef_0123_4567_89ab_cdef_u128; + let ints: Vec = [ + (id >> 96) as i32 as i64, + (id >> 64) as i32 as i64, + (id >> 32) as i32 as i64, + id as i32 as i64, + ] + .into(); + let p = parse_profile(&serde_json::json!({"id": ints})); + assert_eq!(p.dynamic, Some(NameOrId::Id(id))); + let p = parse_profile(&serde_json::json!({"id": "01234567-89ab-cdef-0123-4567abcdef00"})); + assert_eq!( + p.dynamic, + Some(NameOrId::Id(0x0123_4567_89ab_cdef_0123_4567_abcd_ef00)) + ); + // Name and id but no properties is Static: it never resolves, and its + // default skin keys off the id. + let p = parse_profile(&serde_json::json!({"name": "Notch", "id": ints})); + assert!(p.dynamic.is_none()); + assert_eq!(p.id, id); + // A `textures` property makes it Static (unsigned and signed). + let p = parse_profile(&serde_json::json!({ + "properties": [{"name": "textures", "value": "v"}] + })); + assert!(p.dynamic.is_none()); + assert_eq!(p.textures, Some(("v".into(), Signature::Unsigned))); + let p = parse_profile(&serde_json::json!({ + "properties": [{"name": "textures", "value": "v", "signature": "s"}] + })); + assert_eq!(p.textures, Some(("v".into(), Signature::Signed))); + // Empty properties leave it dynamic. + let p = parse_profile(&serde_json::json!({"name": "Notch", "properties": []})); + assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into()))); + } + + /// `getByName` and `getProfileById` answer from the services the + /// discovery document names; a profile resolves through both. + #[test] + fn profiles_resolve_through_the_services() { + use std::io::{BufRead, BufReader, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + let reply = |status: &str, body: &str| { + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + }; + for stream in listener.incoming().take(3) { + let mut stream = stream.unwrap(); + let mut line = String::new(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + reader.read_line(&mut line).unwrap(); + while reader.read_line(&mut String::new()).unwrap() > 2 {} + let body = if line.contains("/users/profiles/minecraft/") { + r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch"}"# + } else if line.contains("/session/minecraft/profile/") + && line.contains("unsigned=false") + { + r#"{"id":"069a79f444e94726a5befca90e38aaf5","name":"Notch","properties":[{"name":"textures","value":"dmFsdWU=","signature":"c2ln"}]}"# + } else { + "{}" + }; + stream.write_all(reply("200 OK", body).as_bytes()).unwrap(); + } + }); + let doc = serde_json::json!({"discovery": { + "profiles": {"endpoints": {"getByName": {"uri": format!("{base}/users/profiles/minecraft/{{name}}")}}}, + "session": {"endpoints": {"getProfileById": {"uri": format!("{base}/session/minecraft/profile/{{profileId}}")}}}, + }}); + let resolved = + super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).unwrap(); + assert_eq!(resolved.id, 0x069a_79f4_44e9_4726_a5be_fca9_0e38_aaf5); + assert_eq!(resolved.name, "Notch"); + assert_eq!( + resolved.properties, + vec![( + "textures".to_owned(), + "dmFsdWU=".to_owned(), + Some("c2ln".to_owned()) + )] + ); + let by_id = super::resolve_profile(&super::NameOrId::Id(resolved.id), &doc).unwrap(); + assert_eq!(by_id.name, "Notch"); + // A discovery without `profiles`/`getByName` resolves no name. + let doc = serde_json::json!({"discovery": { + "session": {"endpoints": {"getProfileById": {"uri": format!("{base}/session/minecraft/profile/{{profileId}}")}}}, + }}); + assert!(super::resolve_profile(&super::NameOrId::Name("Notch".into()), &doc).is_none()); + } + + /// A skin evicted from the atlas re-uploads (`again`), once per ask. + #[test] + fn evicted_skins_upload_again() { + let mut d = super::Downloader::new(std::env::temp_dir().join("horizon-test-skins")); + d.remember("skins/abc".into(), vec![1, 2, 3]); + assert!(d.again("skins/abc")); + assert!(!d.again("skins/missing")); + // Once queued, more asks do not pile up uploads. + assert!(d.again("skins/abc")); + assert_eq!( + d.take_ready(), + vec![("skins/abc".to_owned(), vec![1, 2, 3])] + ); + assert!(d.take_ready().is_empty()); + assert!(d.again("skins/abc")); + assert_eq!(d.take_ready().len(), 1); + // The first upload marks the location queued too: an `again` before + // `take_ready` does not put the same skin in the atlas twice. + d.queue("skins/def".into(), vec![4]); + assert!(d.again("skins/def")); + assert_eq!(d.take_ready(), vec![("skins/def".to_owned(), vec![4])]); + } + #[test] fn profile_components_supply_unsigned_head_skins() { let value = "eyJ0ZXh0dXJlcyI6IHsiU0tJTiI6IHsidXJsIjogImh0dHBzOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2FiYzEyMyJ9fX0="; diff --git a/crates/horizon-client/src/spans.rs b/crates/horizon-client/src/spans.rs index ce69be8ce..33c4bb933 100644 --- a/crates/horizon-client/src/spans.rs +++ b/crates/horizon-client/src/spans.rs @@ -52,8 +52,9 @@ pub fn thread_cpu() -> Option { Some(Duration::new(t.tv_sec as u64, t.tv_nsec as u32)) } #[cfg(not(unix))] - None + horizon_threads::thread_cpu() } + /// The frame's spans, summed by name, slowest first; clears them. pub fn take() -> Vec { let spans = SPANS.with(|s| std::mem::take(&mut *s.borrow_mut())); @@ -135,7 +136,7 @@ mod tests { /// The thread's CPU clock moves with work done on it, not with time /// spent asleep. - #[cfg(unix)] + #[cfg(any(unix, all(windows, target_arch = "x86_64")))] #[test] fn thread_cpu_counts_work_not_sleep() { let start = super::thread_cpu().unwrap(); diff --git a/crates/horizon-client/src/sprites.rs b/crates/horizon-client/src/sprites.rs index 6d754894a..b720c5bfd 100644 --- a/crates/horizon-client/src/sprites.rs +++ b/crates/horizon-client/src/sprites.rs @@ -342,10 +342,25 @@ pub(crate) fn test_frames(layer: u32, frames: Vec>, metadata: &str) -> S } } +/// How many reduced copies a sprite atlas keeps: the Mipmap Levels option plus the +/// resampling-only levels an upscaled pack needs, within what the atlas holds. +pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 { + // Only the atlas native minimum determines how many resampling-only mips to skip. + let upscale = (size / minimum.max(1)).max(1).ilog2(); + requested.saturating_add(upscale).min(available - 1) +} + #[cfg(test)] mod tests { use super::*; #[test] + fn mipmap_option_keeps_real_16px_reductions() { + for levels in 0..=4 { + assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1); + assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels); + } + } + #[test] fn identical_still_textures_share_a_layer() { let root = std::env::temp_dir().join(format!("horizon-sprite-share-{}", std::process::id())); @@ -398,7 +413,7 @@ mod tests { assert_eq!((sprites.size, sprites.minimum_size), (32, 32)); for level in 0..=4 { assert_eq!( - crate::gfx::sprite_mipmap_limit(level, sprites.size, sprites.minimum_size, 6), + sprite_mipmap_limit(level, sprites.size, sprites.minimum_size, 6), level ); } @@ -408,7 +423,7 @@ mod tests { sprites.finish(); assert_eq!((sprites.size, sprites.minimum_size), (32, 16)); assert_eq!( - crate::gfx::sprite_mipmap_limit(0, sprites.size, sprites.minimum_size, 6), + sprite_mipmap_limit(0, sprites.size, sprites.minimum_size, 6), 1 ); } diff --git a/crates/horizon-client/src/stations.rs b/crates/horizon-client/src/stations.rs index a307b35cf..14dfedb49 100644 --- a/crates/horizon-client/src/stations.rs +++ b/crates/horizon-client/src/stations.rs @@ -151,8 +151,12 @@ pub struct AnvilResult { pub repair_item_count_cost: u32, pub only_renaming: bool, } +/// `ItemStack.isDamageableItem`: durability, and not `minecraft:unbreakable`. pub fn is_damageable(items: &Items, stack: &ItemStack) -> bool { items.get(&stack.item).is_some_and(|i| i.max_damage > 0) + && stack + .data() + .is_none_or(|d| d.other("minecraft:unbreakable").is_none()) } fn max_damage(items: &Items, stack: &ItemStack) -> i32 { items.get(&stack.item).map_or(0, |i| i.max_damage as i32) @@ -448,6 +452,7 @@ pub fn grindstone_experience( #[derive(Clone, Debug)] pub enum Smithing { Transform { + id: String, template: Ingredient, base: Ingredient, addition: Ingredient, @@ -527,6 +532,7 @@ impl StationRecipes { ); match v["type"].as_str().unwrap_or("") { "minecraft:smithing_transform" => smithing.push(Smithing::Transform { + id, template: ingredient(&v["template"], items), base: ingredient(&v["base"], items), addition: ingredient(&v["addition"], items), @@ -630,6 +636,7 @@ impl StationRecipes { base: b, addition: a, result, + .. } => { if t.test(template) && b.test(base) && a.test(addition) { // SmithingTransformRecipe: transmute the base keeping its components. diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 643a604bc..6588ac32d 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -70,11 +70,16 @@ pub trait ChunkGenerator: Send + Sync + 'static { false } /// What to save of a proto chunk dropped before it became final. - fn saved_proto(&self, _proto: &Proto) -> Option { + /// A decorated proto chunk's blocks as a column, to light a finished + /// neighbour against (vanilla lights with neighbours at FEATURES). + fn light_view(&self, _proto: &Proto) -> Option { + None + } + fn saved_proto(&self, _proto: &Proto) -> Option { None } /// Put a saved proto chunk's state back onto `proto`, generated again. - fn restore_proto(&self, _proto: &mut Proto, _saved: crate::save::SavedProto) {} + fn restore_proto(&self, _proto: &mut Proto, _saved: crate::chunk::SavedProto) {} /// Save proto chunks decoration wrote to, so that they come back as they /// were: generating them again would lose those writes. fn save_protos(&self, _protos: &[&Proto]) {} @@ -175,6 +180,11 @@ pub enum Done { } struct Queued { priority: i64, + /// The column the job is for and what it adds to the column's distance + /// (`Streaming::submit`): the priority is computed again from them when + /// the centre moves (`Streaming::requeue`). None for saves, which go first. + at: Option<(i32, i32)>, + bias: i64, order: u64, job: Job, } @@ -207,6 +217,7 @@ struct Shared { } pub struct Streaming { shared: Arc, + light_view: Arc Option + Send + Sync>, results: mpsc::Receiver, threads: Vec>, order: u64, @@ -222,6 +233,9 @@ pub struct Streaming { /// radius it was last ranked for. decoration_order: DecorationOrder, order_area: Option<((i32, i32), i32)>, + /// What the queued jobs' priorities were computed from (`requeue`): the + /// centre, the loading screen, the band, and the extra areas. + queue_basis: (((i32, i32), bool, Option), Vec<((i32, i32), i32)>), finishing: FxHashSet<(i32, i32)>, decorates: bool, lighting: FxHashMap<(i32, i32), u64>, @@ -531,8 +545,13 @@ impl Streaming { .expect("spawn worker") }) .collect(); + let light_view = { + let generator = generator.clone(); + Arc::new(move |p: &Proto| generator.light_view(p)) + }; Self { shared, + light_view, results, threads, order: 0, @@ -544,6 +563,7 @@ impl Streaming { saving: 0, decoration_order: DecorationOrder::default(), order_area: None, + queue_basis: Default::default(), finishing: Default::default(), decorates: generator.decorates(), lighting: Default::default(), @@ -648,15 +668,39 @@ impl Streaming { world.dirty_sections.take_column(x, z); world.chunks.remove(&(x, z)) } - fn submit(&mut self, priority: i64, job: Job) { + /// Queue a job for the column `at`, nearest first: its distance (`distance`) + /// times four plus `bias`, which orders a column's jobs. A job without a + /// column (a save) goes ahead of every other. + fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) { self.order += 1; + let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias); self.shared.queue.lock().unwrap().push(Queued { priority, + at, + bias, order: self.order, job, }); self.shared.ready.notify_one(); } + /// Rank the queued jobs again when what their priorities were computed + /// from changed: the centre, the extra areas, the band, or the loading + /// screen (ring order, `Ranges::rings`, whose values are far above the + /// distances used after it). Vanilla's chunk task queue follows the + /// current ticket levels in the same way. Without it, generation queued + /// while the loading screen was up waited behind every job queued after + /// it, and with it the decoration of the chunks around the player: at + /// render distance 32 nothing was decorated until every proto chunk had + /// been generated. + fn requeue(&mut self) { + let basis = (self.center, self.loading, self.band); + if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra { + return; + } + self.queue_basis = (basis, self.extra.clone()); + let mut queue = self.shared.queue.lock().unwrap(); + rank_again(&mut queue, |x, z| self.distance(x, z)); + } fn receive_decorated(&mut self, x: i32, z: i32, region: [Proto; 9]) { self.generation_scan.exhausted = false; self.decorating -= 1; @@ -686,7 +730,7 @@ impl Streaming { self.saving += 1; // Ahead of every other job: a column generated again meanwhile // would not find them saved. - self.submit(i64::MIN, Job::Save(protos)); + self.submit(None, 0, Job::Save(protos)); } } fn receive_generated(&mut self, mut chunk: Chunk, world: &mut World) { @@ -746,6 +790,26 @@ impl Streaming { fn distance(&self, x: i32, z: i32) -> i64 { self.ranges().priority(x, z, self.loading) } + /// The 3x3 a light job reads: the finished columns, and for a neighbour + /// that will not finish (past the area), its decorated proto chunk, as + /// vanilla lights its last FULL ring against neighbours at FEATURES. + fn light_region(&self, world: &World, x: i32, z: i32) -> Option> { + let mut region: Region = Default::default(); + for dz in 0..3 { + for dx in 0..3 { + let n = (x + dx as i32 - 1, z + dz as i32 - 1); + region[dz][dx] = Some(match world.chunks.get(&n) { + Some(chunk) => chunk.clone(), + None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => { + let proto = self.protos.get(&n)?.as_ref()?; + Arc::new((self.light_view)(proto)?) + } + None => return None, + }); + } + } + Some(Box::new(region)) + } fn region(world: &World, x: i32, z: i32) -> Option> { let mut region: Region = Default::default(); for dz in 0..3 { @@ -829,6 +893,7 @@ impl Streaming { return (uploads, Vec::new()); } ExtraCover::refresh(&mut self.cover, &self.extra); + self.requeue(); self.relight_edits(world); let deferred = std::mem::take(&mut self.deferred); let received = std::iter::from_fn(|| self.results.try_recv().ok()); @@ -866,6 +931,11 @@ impl Streaming { } else { apply_relighting(chunk, sections, &mut changed); } + // Saved unlit (at the edge of what was loaded), or its light + // changed: saved again. + if (first && !chunk.light_loaded) || !changed.is_empty() { + world.modified.insert((x, z)); + } chunk.lit = true; if first { for dz in -1..=1 { @@ -984,7 +1054,7 @@ impl Streaming { .expect("present proto"); self.finishing.insert((x, z)); self.written.remove(&(x, z)); - self.submit(self.distance(x, z) * 4, Job::Finish(proto)); + self.submit(Some((x, z)), 0, Job::Finish(proto)); } } // Decorate chunks whose 3x3 proto neighbourhood is available, in @@ -1052,7 +1122,7 @@ impl Streaming { w.0 = self.decoration_order.rank_or_append(w.2, w.3); } wanted.sort_unstable(); - for (_, d, x, z) in wanted { + for (_, _, x, z) in wanted { if self.decorating >= workers * 2 { break; } @@ -1091,7 +1161,7 @@ impl Streaming { Err(_) => continue, }; self.decorating += 1; - self.submit(d * 4 + 1, Job::Decorate(x, z, region)); + self.submit(Some((x, z)), 1, Job::Decorate(x, z, region)); } } self.schedule_generation(world, workers); @@ -1102,7 +1172,7 @@ impl Streaming { world.dirty_light.remove(&(x, z)); continue; } - let Some(region) = Self::region(world, x, z) else { + let Some(region) = self.light_region(world, x, z) else { continue; }; world.dirty_light.remove(&(x, z)); @@ -1111,7 +1181,8 @@ impl Streaming { self.light_snapshots .insert((x, z), self.light_edit_sequence); self.submit( - self.distance(x, z) * 4 + 1, + Some((x, z)), + 1, Job::Light(x, z, region, self.sequence, self.sky_light), ); } @@ -1183,7 +1254,8 @@ impl Streaming { self.sequence += 1; self.meshing.insert((x, sy, z), self.sequence); self.submit( - self.distance(x, z) * 4 + if first { 2 } else { 0 }, + Some((x, z)), + if first { 2 } else { 0 }, Job::Mesh( x, sy, @@ -1222,9 +1294,9 @@ impl Streaming { loading: self.loading, }; if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) { - for (d, x, z) in wanted { + for (_, x, z) in wanted { self.generating.insert((x, z)); - self.submit(d * 4, Job::Generate(x, z)); + self.submit(Some((x, z)), 0, Job::Generate(x, z)); } } } @@ -1381,6 +1453,14 @@ impl Streaming { pub fn band(&self) -> Option { self.band } + /// Rank the chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)` + /// decorates in vanilla's order (`forced_order`), after every chunk + /// ranked before. + pub fn rank_forced(&mut self, x0: i32, z0: i32, x1: i32, z1: i32) { + for (x, z) in horizon_world::worldgen::decoration_order::forced_order(x0, z0, x1, z1) { + self.decoration_order.rank_or_append(x, z); + } + } /// Generate, light and keep the chunks of `band` (none: stop), and let /// go of what earlier bands held as it falls out of range. The chunks a /// band reaches are ranked for decoration row by row, as they first come @@ -1424,7 +1504,7 @@ impl Streaming { counts[1] += 1; } else { counts[2] += 1; - if Self::region(world, key.0, key.1).is_some() { + if self.light_region(world, key.0, key.1).is_some() { world.dirty_light.insert(key); } } @@ -1738,10 +1818,78 @@ fn section_indexes(mut bits: u32) -> impl Iterator { }) }) } +/// The queued jobs' priorities computed again from each one's column +/// `distance` (`Streaming::submit`). +fn rank_again(queue: &mut BinaryHeap, distance: impl Fn(i32, i32) -> i64) { + if queue.is_empty() { + return; + } + let mut jobs = std::mem::take(queue).into_vec(); + for job in &mut jobs { + if let Some((x, z)) = job.at { + job.priority = distance(x, z) * 4 + job.bias; + } + } + *queue = BinaryHeap::from(jobs); +} #[cfg(test)] mod tests { use super::*; + #[test] + fn queued_jobs_follow_the_centre_and_the_loading_screen() { + let forced = HashSet::new(); + let ranges = |center| Ranges { + center, + radius: 32, + extra: &[], + forced: &forced, + cover: None, + band: None, + }; + let generate = |x, z, priority, order| Queued { + priority, + at: Some((x, z)), + bias: 0, + order, + job: Job::Generate(x, z), + }; + let loading = ranges((0, 0)); + let queued = || { + BinaryHeap::from(vec![ + // Queued while the loading screen was up: ring order. + generate(3, 1, loading.priority(3, 1, true) * 4, 1), + // Queued after it closed: plain distances, far below any ring past the first. + generate(20, 0, loading.priority(20, 0, false) * 4, 2), + Queued { + priority: i64::MIN, + at: None, + bias: 0, + order: 3, + job: Job::Save(Vec::new()), + }, + ]) + }; + let pops = |mut queue: BinaryHeap| { + std::iter::from_fn(move || queue.pop()) + .map(|q| q.at) + .collect::>() + }; + assert_eq!( + pops(queued()), + [None, Some((20, 0)), Some((3, 1))], + "the stale order" + ); + let mut queue = queued(); + rank_again(&mut queue, |x, z| loading.priority(x, z, false)); + assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]); + // The player moved: the job near the new centre goes first. + let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]); + let moved = ranges((19, 0)); + rank_again(&mut queue, |x, z| moved.priority(x, z, false)); + assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0))); + } + #[test] fn view_distance_is_vanillas_shape() { // ChunkTrackingView.isInViewDistance at render distance 12: a column diff --git a/crates/horizon-client/src/structure_blocks.rs b/crates/horizon-client/src/structure_blocks.rs index f77696277..8183b415a 100644 --- a/crates/horizon-client/src/structure_blocks.rs +++ b/crates/horizon-client/src/structure_blocks.rs @@ -9,7 +9,7 @@ use horizon_world::{ }; use std::{ path::Path, - sync::{Arc, OnceLock}, + sync::{Arc, Mutex}, }; /// The `custom_data` key holding an exploration map's search until the game @@ -63,6 +63,12 @@ pub fn loot_item_with_components(s: &horizon_world::loot::LootStack) -> ItemStac if let Some(level) = s.ominous_amplifier.filter(|l| *l > 0) { item.data_mut().ominous_amplifier = Some(level.clamp(0, 4) as u8); } + // set_stew_effect's `suspicious_stew_effects`. + for (effect, duration) in &s.stew_effects { + item.data_mut() + .stew_effects + .push((Arc::from(effect.as_str()), *duration)); + } item } @@ -108,11 +114,13 @@ pub fn has_pending_map(item: &ItemStack) -> bool { }) } -static LOOT: OnceLock>> = OnceLock::new(); +/// None until `loot_data` runs; `Some(None)` when the tables failed to load. +static LOOT: Mutex>>> = Mutex::new(None); /// Loot data of the resource root, loaded once per process. pub fn loot_data(root: &Path) -> Option> { - LOOT.get_or_init(|| match LootData::load(root) { + let mut loot = LOOT.lock().unwrap_or_else(|e| e.into_inner()); + loot.get_or_insert_with(|| match LootData::load(root) { Ok(data) => Some(Arc::new(data)), Err(e) => { log::warn!("Horizon: loot tables unavailable, structure containers stay empty: {e}"); @@ -124,7 +132,16 @@ pub fn loot_data(root: &Path) -> Option> { /// The loot data `loot_data` loaded, if it ran. pub fn loaded_loot_data() -> Option> { - LOOT.get().cloned().flatten() + LOOT.lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .flatten() +} + +/// Let go of the loot data: for a preload no world used +/// (game/world_loading.rs). The next `loot_data` loads it again. +pub fn forget_loot_data() { + *LOOT.lock().unwrap_or_else(|e| e.into_inner()) = None; } /// `RandomizableContainer.unpackLootTable` of a container at `pos`: `table` @@ -179,23 +196,27 @@ fn text(nbt: &Compound, key: &str) -> Option { /// The client block entity for one of a chunk's generated block entities, /// before a container's loot is unpacked: `StructureTemplate.placeInWorld` -/// hands the template's block entity NBT to the level's block entity. +/// hands the template's block entity NBT to `loadWithComponents`, which is +/// what a chunk load does with a saved one. fn generated_block_entity(block: &str, nbt: &Compound) -> Option { - let mut entity = BlockEntity::for_block(block)?; - match &mut entity { - // MonsterRoomFeature and the structures' setEntityId. - BlockEntity::Spawner(s) => { - **s = crate::mobs::spawner::Spawner::load(&Tag::Compound(nbt.clone())); - } - BlockEntity::TrialSpawner(s) => { - **s = crate::mobs::trial_spawner::TrialSpawner::load(&Tag::Compound(nbt.clone())); - } - BlockEntity::Vault(v) => **v = crate::vault::Vault::load(&Tag::Compound(nbt.clone())), - // BannerBlockEntity.loadAdditional: the End cities' black triangles. - BlockEntity::Banner(b) => *b = crate::save::decode_banner(&Tag::Compound(nbt.clone())), - _ => {} + let fallback = BlockEntity::for_block(block)?; + let mut tag = nbt.clone(); + // RandomizableContainer.tryLoadLootTable: with a table the items are not read. + if text(&tag, "LootTable").is_some() { + tag.remove(&JavaString::from("Items")); } - Some(entity) + // Programmatic block entities (and a template that saved none) carry no id. + if !tag.contains_key(&JavaString::from("id")) { + tag.insert("id".into(), Tag::String(fallback.id().into())); + } + for axis in ["x", "y", "z"] { + tag.insert(axis.into(), Tag::Int(0)); + } + // A saved kind the client cannot decode keeps the block's own entity. + Some( + crate::save::decode_block_entity(&Tag::Compound(tag)) + .map_or(fallback, |(_, entity)| entity), + ) } /// Client block entities for the generated block entities of a chunk, and the @@ -236,6 +257,23 @@ mod tests { use super::*; use horizon_world::loot::{ExplorationMap, LootStack}; + #[test] + fn generated_end_gateway_keeps_its_exit() { + let Tag::Compound(nbt) = Tag::compound([ + ("exit_portal", Tag::IntArray(vec![100, 50, 0])), + ("ExactTeleport", Tag::Byte(1)), + ]) else { + unreachable!() + }; + let Some(BlockEntity::EndGateway(g)) = + generated_block_entity("minecraft:end_gateway", &nbt) + else { + panic!("gateway"); + }; + assert_eq!(g.exit_portal, Some([100, 50, 0])); + assert!(g.exact_teleport); + } + #[test] fn placed_chest_loot_keeps_its_enchantments_and_potions() { let stack = @@ -248,6 +286,7 @@ mod tests { map: false, exploration: None, ominous_amplifier: None, + stew_effects: Vec::new(), }; // An End city chest's enchant_with_levels roll. let boots = loot_item_with_components(&stack( @@ -309,6 +348,7 @@ mod tests { "zoom": 1 })))), ominous_amplifier: None, + stew_effects: Vec::new(), }; let mut item = loot_item(&stack); assert!(has_pending_map(&item)); diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index c0cf44bb5..3aea6fc1a 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -258,7 +258,7 @@ fn sky_along_ray(d: vec3) -> vec3 { } // rendertype_lines.vsh: screen-space stroke and camera-space depth separation. -fn outline_clip(vertex: u32, start: vec3, end: vec3) -> vec4 { +fn outline_clip(vertex: u32, start: vec3, end: vec3, width: f32) -> vec4 { let position = select(start, end, vertex >= 2u); let normal = normalize(end - start); let view_shrink = 1.0 - 1.0 / 256.0; @@ -268,7 +268,7 @@ fn outline_clip(vertex: u32, start: vec3, end: vec3) -> vec4 { let ndc2 = line_end.xyz / line_end.w; let screen = globals.outline_params.xy; let direction = normalize((ndc2.xy - ndc1.xy) * screen); - var offset = vec2(-direction.y, direction.x) * globals.outline_params.z / screen; + var offset = vec2(-direction.y, direction.x) * width / screen; if offset.x < 0.0 { offset = -offset; } if vertex % 2u != 0u { offset = -offset; } return vec4((ndc1 + vec3(offset, 0.0)) * line_start.w, line_start.w); @@ -277,18 +277,22 @@ struct LineOut { @builtin(position) clip: vec4, @location(0) spherical: f32, @location(1) cylindrical: f32, + @location(2) color: vec4, } @vertex fn vs_line(@builtin(vertex_index) vertex: u32, - @location(0) start: vec3, @location(1) end: vec3) -> LineOut { + @location(0) start: vec3, @location(1) end: vec3, + @location(2) argb: vec4, @location(3) width: f32) -> LineOut { let position = select(start, end, vertex >= 2u); var out: LineOut; - out.clip = outline_clip(vertex, start, end); + out.clip = outline_clip(vertex, start, end, width); out.spherical = length(position); out.cylindrical = max(length(position.xz), abs(position.y)); + // ARGB in a little-endian u32 reads as B, G, R, A. + out.color = argb.zyxw; return out; } @fragment fn fs_line(in: LineOut) -> @location(0) vec4 { - return apply_fog(vec4(0.0, 0.0, 0.0, 0.4), in.spherical, in.cylindrical); + return apply_fog(in.color, in.spherical, in.cylindrical); } // Gizmo lines (rendertype_lines with a per-vertex colour and LineWidth in // pixels; no layering transform). diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index 87bd05fd0..b2fcfe8f4 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -1,6 +1,7 @@ //! Title, world selection and world creation screens (TitleScreen, //! SelectWorldScreen, CreateWorldScreen layouts), drawn as HUD quads. use crate::{ + game_rules_screen::{Exit, RulesScreen}, hud::{HudAtlas, HudBatch, rgba}, lang, save::Level, @@ -13,10 +14,12 @@ use std::{ }; mod create_world; +mod flat; #[cfg(test)] mod tests; pub mod world_management; pub use create_world::{DIFFICULTIES, WorldOptions}; +pub use flat::{FlatLayer, FlatSettings, State as FlatEditor}; /// SelectWorldScreen's `HeaderAndFooterLayout(8 + 9 + 8 + 20 + 4, 60)`. const LIST_TOP: f32 = 49.0; @@ -101,6 +104,16 @@ pub enum Mode { return_to: Box, }, Multiplayer(crate::multiplayer::Screen), + /// CreateFlatWorldScreen over Create New World: the fields that screen + /// keeps and the superflat editor on top of them. + CreateFlat { + name: String, + seed: String, + creative: bool, + focus: u8, + options: WorldOptions, + flat: flat::State, + }, } #[derive(Clone, Debug, PartialEq)] pub enum Action { @@ -128,6 +141,8 @@ pub struct Title { /// Direct Connection's address, synchronized with `options.txt`'s `lastServer`. pub last_server: String, pub saves: PathBuf, + /// The prepared resource root: the flat preset list and block registry. + resources: PathBuf, pub started: std::time::Instant, search: String, search_focused: bool, @@ -144,6 +159,8 @@ pub struct Title { splash: Option, /// The saves listed for Home, re-read every couple of seconds. recent: std::cell::RefCell)>>, + /// WorldCreationGameRulesScreen, over Create New World while it is open. + rules: RefCell>, } pub(crate) struct Button { x: f32, @@ -231,6 +248,7 @@ impl Title { mode, last_server: String::new(), saves, + resources: resources.to_owned(), started: now, search: String::new(), search_focused: false, @@ -252,6 +270,7 @@ impl Title { (today.month(), today.day()), ), recent: std::cell::RefCell::new(None), + rules: RefCell::new(None), } } /// Vanilla bypasses the selector when there are no saves. @@ -316,6 +335,64 @@ impl Title { self.return_to_list(None); } } + /// A press on the superflat screens: their list, share box or buttons. + fn flat_click(&mut self, x: f32, y: f32, size: (f32, f32), hit: Option) { + match hit { + Some(70) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.remove_selected(); + } + } + Some(71) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.open_presets(); + } + } + Some(72) => self.close_flat(true), + Some(73) => self.close_flat(false), + Some(74) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.apply_share(); + } + } + Some(75) => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat.back_to_layers(); + } + } + None => { + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::click(flat, (x, y), size); + } + } + _ => {} + } + } + /// Return to Create New World, keeping (`apply`) or dropping the layers. + fn close_flat(&mut self, apply: bool) { + let mode = std::mem::replace(&mut self.mode, Mode::Main); + if let Mode::CreateFlat { + name, + seed, + creative, + focus, + mut options, + flat, + } = mode + { + if apply { + options.flat = true; + options.flat_settings = Some(flat.settings); + } + self.mode = Mode::Create { + name, + seed, + creative, + focus, + options, + }; + } + } fn matches_search(&self, world: &WorldEntry) -> bool { let filter = self.search.to_lowercase(); world.name.to_lowercase().contains(&filter) || world.folder.to_lowercase().contains(&filter) @@ -360,9 +437,16 @@ impl Title { } /// The pointer moved with the button held (`mouseDragged`). pub fn drag(&mut self, cursor: (f32, f32), size: (f32, f32)) { + if let Some(rules) = self.rules.get_mut() { + return rules.drag(cursor.1, size.1.ceil()); + } if let Mode::Multiplayer(screen) = &mut self.mode { return screen.drag(cursor.1, size); } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::drag(flat, cursor, size); + return; + } if let Mode::Select { worlds, .. } = &self.mode { let (top, bottom, _, content) = self.world_list(worlds, size); let mut scroll = self.scroll.get(); @@ -372,17 +456,30 @@ impl Title { } /// The button was released (`onRelease`). pub fn release(&mut self) { + if let Some(rules) = self.rules.get_mut() { + rules.release(); + } if let Mode::Multiplayer(screen) = &mut self.mode { screen.release(); } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::release(flat); + } self.scroll.get_mut().release(); } /// Mouse wheel over the classic world and server lists. pub fn scroll_worlds(&mut self, delta: f32, cursor: (f32, f32), size: (f32, f32)) { + if let Some(rules) = self.rules.get_mut() { + return rules.wheel(delta, size.1.ceil()); + } if let Mode::Multiplayer(screen) = &mut self.mode { screen.wheel(delta, cursor, size); return; } + if let Mode::CreateFlat { flat, .. } = &mut self.mode { + flat::wheel(flat, delta, cursor, size); + return; + } let Mode::Select { worlds, .. } = &self.mode else { return; }; @@ -566,6 +663,10 @@ impl Title { folder } fn buttons(&self, w: f32, h: f32) -> Vec