Compare commits

...
Author SHA1 Message Date
Ludovic Marie d53a0adb41 Merge pull request #2562 from levzzz5154/fix/potion-contents-in-the-creative-inventory
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): creative potions keep their contents (includes #2561). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:36 +01:00
Ludovic Marie 91d84ba3c2 Merge pull request #2561 from levzzz5154/fix/container-press-state-lifetime
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the container press state ends with the screen (lan_e2e chest, containers pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:32 +01:00
Ludovic Marie 4234918255 Merge pull request #2550 from levzzz5154/fix/sprint-state-persistence
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): sprint-state regression tests. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @levzzz5154!
2026-10-27 09:03:28 +01:00
Ludovic Marie 4af2c671c7 Merge pull request #2634 from ASOwnerYT/claude/1515-end-portal-entities-643979
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): items and mobs go through End portals. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ASOwnerYT!
2026-10-27 08:59:20 +01:00
Ludovic Marie e92c9ea743 Merge pull request #2610 from DanielGodriNeto/turbo/pr-thread-cpu
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Windows thread CPU time: checked and clippy-clean for x86_64-pc-windows-msvc from the Mac mini; not run on Windows. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:59:16 +01:00
Ludovic Marie 9635fbb0a9 Merge pull request #2605 from socksprox/fix/client-effects-cleared-on-join
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): a client's effects are forgotten on join; the client keeps ticking effects until the server removes them, as vanilla (lan_e2e effects passes). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:59:12 +01:00
Ludovic Marie b409d5f624 Merge pull request #2585 from RoyalPixelsDE/fix/fox-rabbit-goat-fields
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): foxes, rabbits, goats and horses keep their state (includes #2541). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:59:09 +01:00
Ludovic Marie 9502716a07 Merge pull request #2704 from ludovic111/test/mod-content-shared-assets
🤖 Agent: verify passed at 80751276f (7 min 38 s, in the range of the last merges: 5-7.5 min). Parity scope selected no harness. Test-only change: the 5 full-content-root mod_content tests are #[ignore]d, and pr_ready runs them when the diff touches what they load. Ready to merge.
2026-10-27 08:56:10 +01:00
Ludovic Marie 723ab2aa50 Merge pull request #2613 from socksprox/t3/anticheat-parity-elytra-swap-noslow
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): movement, offhand swap, eat gating and farmland push (includes #2564; lan_e2e eating and helduse pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:56:06 +01:00
Ludovic Marie 4d5ee970d7 Merge pull request #2536 from Murilinho145SG/feat/launcher-wayland-controls
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): Wayland window controls: launcher clippy and tests pass on macOS and the Linux build type-checks; playhorizon lacks the xcb/xkbcommon libraries to link and run it. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Murilinho145SG!
2026-10-27 08:55:12 +01:00
Ludovic Marie b4db58ab8e Merge pull request #2413 from mahirsn/fix/set-held-slot-echo
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): held-slot answer as vanilla. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @mahirsn!
2026-10-27 08:55:09 +01:00
Ludovic Marie 3833886df3 Merge pull request #2320 from pqhaz3925/fix/server-book-pages
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server books: page indicator and component-list pages. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @pqhaz3925!
2026-10-27 08:55:04 +01:00
Ludovic Marie 104ef68ee6 Merge pull request #2315 from rdbtCVS/t3/reject-occupied-horse-mount
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): an occupied horse gives no inventory, feeding or ride (lan_e2e occupied_horse, riding, mounts pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:54:59 +01:00
Ludovic Marie 31ebe6db6c Merge pull request #2478 from kacpherek/feat/far-view-mod-api
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: mods can now turn on the game's own distant terrain, and Far View is an official mod. I merged main into the branch. render.set-far-terrain is now API level 7, because main added levels 4 to 6 (world, recipes, browser) in the meantime. The horizon-mods sandbox tests (32), the far-view wasm build, and clippy with the mods feature all pass. Without the mods feature, the LOD path is unchanged.
2026-10-27 08:54:55 +01:00
Ludovic Marie 9e12caaabc Merge pull request #2312 from rdbtCVS/t3/preserve-release-issue-wrapping
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): release notes keep wrapped known issues. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:48 +01:00
Ludovic Marie 23aa18322c Merge pull request #2308 from rdbtCVS/t3/recipe-book-settings-persistence
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): recipe book settings persist on the server. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:44 +01:00
Ludovic Marie 617665a176 Merge pull request #2304 from senjinthedragon/hdr-sun-mask
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR marks only the sun and moon discs (off by default; no HDR display here). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:40 +01:00
Ludovic Marie 2036f0f517 Merge pull request #2292 from senjinthedragon/hdr-pq
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): HDR output builds and validates; HDR stays off by default (no HDR display here to see it). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @senjinthedragon!
2026-10-27 08:51:36 +01:00
Ludovic Marie f770f15616 Merge pull request #2272 from rdbtCVS/rdbt/fix-1705-closed-stdout-d4113cff
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): server logging survives a closed stdout. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @rdbtCVS!
2026-10-27 08:51:32 +01:00
Ludovic Marie 0b95d39d86 Merge pull request #2607 from DanielGodriNeto/turbo/pr-relight
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the relight cache: light parity harnesses and the randomized equivalence test pass; the speed-up is the author's measurement (relight -70% in a microbenchmark, no FPS change), not re-measured here. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @DanielGodriNeto!
2026-10-27 08:46:47 +01:00
Ludovic Marie 3de1a56f1f Merge pull request #2606 from socksprox/t3/fix-zombie-stuck-flowing-water
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): jumpOutOfFluid needs a dry box and no collidable entity (Entity.isFree). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:43 +01:00
Ludovic Marie 9dc425d357 Merge pull request #2603 from socksprox/fix/shulker-teleport-and-look
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): shulker placement, push and look rules, with no_collision counting only canBeCollidedWith entities. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:46:39 +01:00
Ludovic Marie b16ab9e8c4 Merge pull request #2565 from Louistuis/fix/turtle-feet
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): turtles walk at their own pace. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:35 +01:00
Ludovic Marie b2edfb2c5b Merge pull request #2564 from Louistuis/fix/eat-held-after-full
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): USE_ITEM_COMPLETE finishes the use; eating on the client (lan_e2e eat_full, eating, helduse, consume-release pass). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @Louistuis!
2026-10-27 08:46:31 +01:00
Ludovic Marie 644cca2a10 Merge pull request #2619 from RoyalPixelsDE/fix/light-edge-from-protos
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): the last finished ring is lit against decorated neighbours (our follow-up #2699 carries a neighbour-check fix). It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:43:03 +01:00
Ludovic Marie 07dd22212e Merge pull request #2543 from RoyalPixelsDE/fix/minecart-first-tick
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): minecarts save HasTicked and Fire. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:10 +01:00
Ludovic Marie 2d3b53a785 Merge pull request #2541 from RoyalPixelsDE/fix/animal-variants
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): farm animals and frogs keep their variant; sound variants saved. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @FusselTV!
2026-10-27 08:42:05 +01:00
Ludovic Marie 1e3c9c9ef7 Merge pull request #2317 from socksprox/fix/entity-view-scale
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): vanilla view scale for entity render distance. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:42:02 +01:00
Ludovic Marie d930105423 Merge pull request #2302 from socksprox/feat/server-links
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): pause menu server links. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @socksprox!
2026-10-27 08:41:58 +01:00
Ludovic Marie a6b61e9a3c Merge pull request #2297 from ESHAYAT102/fix/cushion-break-particles
🤖 Agent: verified by Ludovic's AI agent (contrib gate 20): cushion break particles reach multiplayer players. It passed in this wave's union with main on the Mac mini: the selected parity harnesses (332/332, then 122/122 on the final union), clippy -D warnings and the tests, and lan_e2e with the host on its server thread and in-frame (18/18 each). Thanks @ESHAYAT102!
2026-10-27 08:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 80751276f3 test: run the full content-root tests only where they matter
The five mod_content tests that load the whole content root took minutes of
every `cargo test -p horizon-client --lib` on prepared data. They are now
#[ignore]d; pr_ready runs them when the diff touches what they load, and CI's
verify job runs them filtered with --ignored on its existing build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:37:40 +01:00
Ludovic MarieandClaude Opus 5.5 23743968e1 Merge main into feat/far-view-mod-api
render.set-far-terrain moves to API level 7: main added levels 4 to 6
(world, recipes, browser) since this branch started.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:24:26 +01:00
Ludovic Marie 6af60956ec Merge pull request #2637 from ludovic111/feat/launcher-language-home
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 08:01:36 +01:00
Ludovic Marie 8c1fa65f35 Merge pull request #2636 from ludovic111/feat/launcher-themes
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:51:55 +01:00
Ludovic Marie bc1549a438 Merge pull request #2635 from ludovic111/feat/launcher-language
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:42:16 +01:00
Ludovic Marie b6f18dc8f3 Merge pull request #2632 from ludovic111/feat/launcher-skins
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:32:21 +01:00
Ludovic Marie 504c9daf20 Merge pull request #2631 from ludovic111/feat/launcher-accounts
🤖 Agent: launcher-features-1: base main after #2627 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:21:08 +01:00
Ludovic Marie 5a6bf9705c Merge pull request #2696 from ludovic111/perf/parity-solo-window-harnesses
🤖 Agent: game-window harnesses run alone on 4-CPU machines (playhorizon runner); CI-like mix 8 of 8 on playhorizon.
2026-10-27 07:14:40 +01:00
Ludovic Marie 8f0797f258 Merge pull request #2627 from ludovic111/feat/launcher-linux-toggles
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:11:29 +01:00
Ludovic Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +01:00
Ludovic Marie c1ebf5d1b0 Merge pull request #2626 from ludovic111/feat/launcher-instance-commands
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:00:52 +01:00
levzzz 802368fb95 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 10:53:15 +05:00
levzzz 5815560b0b Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 10:53:01 +05:00
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic MarieandClaude Opus 5.5 83ee5c303b parity: game-window harnesses run alone on 4-CPU machines
On playhorizon's runner (CPUs 4-7, 4 harness slots, frames drawn on the CPU)
remote_fireworks still failed in #2215's job after #2589 and #2629: a
screenshot took ~1.5 s (58 ticks for two) beside three other harnesses. The
harnesses in parity_hosts.json's new solo list run alone there, after the
others; bigger machines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:49 +01:00
Ludovic Marie 407053f37c Merge #2561
# Conflicts:
#	crates/horizon-client/src/menu.rs
2026-10-27 06:41:04 +01:00
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +01:00
Ludovic Marie 89225ec081 Merge pull request #2279 from kacpherek/fix/2276-dead-hurt-tilt
🤖 Agent: verified against vanilla 26.3: LivingEntity.baseTick counts hurtTime down for a dead or riding player too, so the hurt tilt no longer restarts every frame and shakes the camera on the server-thread path. Tested on a tree of main + #2350 + this PR: the 33 harnesses its files reach passed, and the LAN hurt, death-while-mining and respawn checks passed with the host in-frame and on its server thread. Thanks @kacpherek!
2026-10-27 06:39:59 +01:00
Ludovic Marie 7b11005688 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 40f8b9600d Shulker canStayAt: only collidable entities block the shell
Level.noCollision(shulker, box) keeps the entities whose
canBeCollidedWith is true for the shulker: living shulkers and adult
happy ghasts (boats and minecarts too, outside the mob world). Players
and other mobs never stop a shulker from opening or landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:02 +01:00
Ludovic Marie 6044c6ff88 Merge pull request #2623 from ludovic111/feat/launcher-hide-on-play
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:30:43 +01:00
Ludovic MarieandClaude Opus 5.5 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +01:00
Ludovic MarieandClaude Opus 5.5 6cb5e4954e An occupied horse gives no inventory or feeding either
AbstractHorse.mobInteract (and Horse/ZombieHorse/AbstractChestedHorse)
hand an isVehicle horse to Mob.mobInteract before the inventory, the
feeding and the ride, so the passenger check goes before all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:51 +01:00
Ludovic Marie d74d39cb05 Merge pull request #2622 from ludovic111/feat/launcher-console
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:21:26 +01:00
Ludovic MarieandClaude Opus 5.5 2ba33bd146 jumpOutOfFluid: only entities a mob collides with block the hop
EntityGetter.getEntityCollisions keeps the entities that canBeCollidedWith
the mob: among mobs that is a living shulker, not every mob nearby.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:20 +01:00
Ludovic MarieandClaude Opus 5.5 4f6e4e72b1 Keep a client's effects until the server removes them
26.3's client LivingEntity.tickEffects only runs tickClient on each effect;
the server's ClientboundRemoveMobEffectPacket removes one. The join reset
stays. The AGENTS.md wording is the maintainer's to change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:20:42 +01:00
Ludovic MarieandClaude Opus 5.5 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
Ludovic Marie 91e332a5eb Merge pull request #2621 from ludovic111/feat/launcher-import-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:10:22 +01:00
Ludovic Marie a8fe843daa Merge pull request #2620 from ludovic111/feat/launcher-import
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:01:09 +01:00
Ludovic Marie 37d31da543 Merge pull request #2473 from kacpherek/fix/piglin-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Reviewed and approved; verify passed. The two parity failures (server_player_sculk, remote_fireworks) are the load flakes fixed by #2629, which merged after this run started (03:30Z); both pass on the Mac mini at this head. Thanks @kacpherek!
2026-10-27 05:49:18 +01:00
Ludovic Marie c5165a98cf Merge pull request #2618 from ludovic111/feat/launcher-export-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:48:52 +01:00
Ludovic Marie e1f7ad93ee Merge pull request #2617 from ludovic111/feat/launcher-export
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:30:25 +01:00
Ludovic Marie 7731b4274a Merge pull request #2616 from ludovic111/feat/launcher-datapack-browser
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:21:08 +01:00
Ludovic Marie 7df621fdb5 Merge pull request #2614 from ludovic111/feat/launcher-mods-per-instance
🤖 Agent: launcher-features-1: base main after #2612 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:19:29 +01:00
Ludovic MarieandClaude Opus 5.5 39c8a2aa0e launcher: Home and the sidebar in French and Russian
The second part of the launcher's translation: Home's banner, rows, crash notice and
Join a friend, the instance row and its actions, the sidebar's headings and instance
states, the partner label, play times and "last played" dates, and the resources line.
Play's label clears its icon in wider languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:13:43 +01:00
asownerytandClaude Opus 5.5 21fd3a6b06 Turn End portal travellers as their teleport does
Checked against the decompiled 26.3 sources: EndPortalBlock's transition
out of the End is Relative.ROTATION plus the respawn data's yaw and pitch,
so a mob or item turns by the world spawn's angles instead of keeping its
own. Into the End it faces west and keeps its pitch, and calculateAbsolute
clamps the pitch to +-90 either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:13:36 +13:00
Ludovic Marie 3eb3d1fc5f Merge pull request #2612 from ludovic111/feat/launcher-datapacks
🤖 Agent: launcher-features-1: base main after #2609 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:10:09 +01:00
Ludovic Marie 3052cc41db Merge pull request #2629 from ludovic111/fix/window-harnesses-under-load
🤖 Agent: playhorizon: remote.sh runs on CPUs 0-3 (CI keeps 4-7); world_reopen and server_player_sculk count ticks; 6 of 6 under load.
2026-10-27 05:10:05 +01:00
Ludovic MarieandClaude Opus 5.5 8beaa57331 launcher: an accent colour and a picture behind Home
Settings > Launcher picks an accent (violet by default, blue, teal, green, amber, red, pink)
and the player's own picture behind Home. An accent turns the violet tokens to its hue and
adjusts their lightness until text on the Play button and the accent on every background read
as well as the violet's. The picture is checked, copied under a name of its hash and shown
cropped under the banner's veils. CLI: settings accent NAME, settings background PATH|default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:07:38 +01:00
Ludovic Marie 43f5388038 Merge pull request #2464 from kacpherek/fix/damage-command-player-source
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/damage` builds `DamageSource(type, cause, by)` with the location or the direct entity's position for the knockback, and the causing mob gets the kill credit. It touched the same code as #2452, so the merged tree uses your `hurt_player_from` for both. Main merged in at f1f587e7c after #2452 landed (world_cmds.rs and creatures.rs: one `hurt_player_from` path that also records the direct entity, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 05:02:10 +01:00
Ludovic MarieandClaude Opus 5.5 b3709b9766 launcher: French and Russian, following the system's language
A string table per language (launcher/lang/<code>.json, English text to its translation)
and tr()/trf() in i18n.rs. The window follows the system's language unless Settings >
Launcher > Language picks one; a missing string shows in English. This first part
translates the top bar, the account switch and the Settings page's account, update and
launcher rows; buttons, segments, section titles and page headers translate through their
widgets. CLI: settings language system|en|fr|ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:58:31 +01:00
Ludovic Marie 08492a3007 Merge pull request #2609 from ludovic111/feat/launcher-update-all
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 04:50:37 +01:00
Ludovic Marie 3d0ef4da75 Merge pull request #2441 from kacpherek/fix/effect-swirls
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickEffects` draws the swirls from `DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID`, only for visible effects, and your follow-up keeps `hideParticles` out of both, which matches. Main merged in at 0ab24d080 after #2432 landed (sprint dust and effect swirls both kept in client_ambient.rs and lan.rs, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 b2199f5a0e launcher: a skin and a cape for each offline name
Settings > Account's Skin... shows the front of an offline name's skin and cape, takes a
skin PNG (64x64 or 64x32) and a cape PNG (64x32 or 22x17), and picks classic or slim arms.
The launcher keeps copies in skins/ and gives the game HORIZON_OFFLINE_SKIN,
HORIZON_OFFLINE_SKIN_MODEL and HORIZON_OFFLINE_CAPE when that name plays. CLI and MCP:
account skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:48:35 +01:00
asownerytandClaude Opus 5.5 dfb28e42d0 Send items and mobs through End portals
The non-player portal path only knew Nether portals, so a cow or a dropped
item standing in an end_portal block never travelled (#1515). It now also
finds End portal blocks (EndPortalBlock.entityInside, its 6/16..12/16
entity-inside shape met along the move), and an entity through one waits as
an arrival with a fixed destination (getPortalDestination): the End's spawn
point above a rebuilt obsidian platform, facing west, or out of the End the
top of the Overworld's shared spawn column. Motion and pitch are kept, the
portal cooldown applies, and saved arrivals keep their End plans.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:46:07 +13:00
Ludovic MarieandClaude Opus 5.5 a2f62b1545 parity_hosts: playhorizon's workers run on CPUs 0-3, 3 harnesses at a time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:42:19 +01:00
Ludovic MarieandClaude Opus 5.5 f1f34edb80 launcher: offline names and a quick switch of who plays
The top bar's account chip opens a menu of every Microsoft account, the
offline names the player keeps and the game's own name; picking one plays
as it. Settings > Account adds and forgets offline names, which reach the
game as --name. CLI and MCP: account offline [NAME], account select
(accounts or offline names), account forget-offline. Share log strips the
offline names too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:37:07 +01:00
Ludovic MarieandClaude Opus 5.5 cb20bb508b remote.sh: runs get CPUs 0-3 by default; benchmarks hold them with --exclusive
playhorizon's CI runner has CPUs 4-7, and remote.sh pinned agents' runs there
too: CPUs 4-7 sat at 100% while 0-3 idled, and window harnesses in pull
requests' parity jobs lost ticks. Runs share a lock per CPU set; an
--exclusive run (a benchmark) holds it alone. world_reopen also waits for
the world to load before its first count, as after each reopen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:31:40 +01:00
Ludovic Marie 4168590221 Merge remote-tracking branch 'origin/main' into fix/window-harnesses-under-load 2026-10-27 04:30:35 +01:00
Ludovic MarieandClaude Opus 5.5 9bc8e12ad1 launcher: GameMode, MangoHud and dedicated GPU switches on Linux
The Launch panel shows, per instance, a switch for each one this computer
has: GameMode when gamemoderun is on the PATH, MangoHud when mangohud is,
and the dedicated graphics card with two cards (DRI_PRIME, or NVIDIA's
render offload variables with its driver). They wrap the game like the
wrapper does, outermost first, and do nothing where the tool is gone.
CLI and MCP: instance launch --gamemode/--mangohud/--dedicated-gpu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:42 +01:00
Ludovic MarieandClaude Opus 5.5 2a7b79dfb6 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:26:15 +01:00
Ludovic MarieandClaude Opus 5.5 f1f587e7c2 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:24:30 +01:00
Ludovic Marie 51447edda4 Merge pull request #2604 from ludovic111/feat/launcher-test-seams
🤖 Agent: launcher-features-1 checked this: base main after #2602 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:23:13 +01:00
Ludovic Marie 4018fa9559 Merge pull request #2452 from kacpherek/fix/magic-death-message
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `DamageSource.getLocalizedDeathMessage` (the causing entity, else the direct one: the potion or cloud) and `indirectMagic`. I pushed one fix: with no entity at all, vanilla sends the one-argument message and `TranslatableContents.decompose` shows a template it can't fill as written, so I removed the fallback to `magic` / `generic`. Main merged in at e338690bf after #2442 landed (potions.rs: `splash` keeps both the owner and the block): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:23:09 +01:00
Ludovic MarieandClaude Opus 5.5 7115c708e1 launcher: pre-launch command, wrapper and environment per instance
Home's Instance row gets Launch...: a shell command run in the instance's
folder before the game (which starts only if it succeeds), a wrapper the
game runs through, and environment variables, kept in instances.json.
The game stays one process: /bin/sh runs the command and execs the
wrapper and game through "$@" on Unix, cmd /C on Windows. The window's
Play and `play` both use them. CLI and MCP: instance launch (confirm
needed over MCP). The Instance row's actions move under its name, and
editing an instance that shares its name with an import works again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:22:34 +01:00
Ludovic Marie 95e7cd69dd Merge pull request #2602 from ludovic111/feat/launcher-packs-ui
🤖 Agent: launcher-features-1 checked this: base main after #2601 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:16:39 +01:00
Ludovic Marie 5a6ae76ec3 Merge pull request #2486 from ludovic111/fix/parity-reap-orphans
🤖 Agent: parity.py reaps the harness processes a failed or timed-out run leaves behind; parity passed on playhorizon.
2026-10-27 04:16:36 +01:00
Ludovic Marie 4a523b5ce8 Merge pull request #2484 from kacpherek/fix/2463-break-block-lagback
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: breaking the block under you no longer sends a position correction the client didn't need. Fixes #2463. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:16:33 +01:00
Ludovic Marie eae5b13fc6 Merge pull request #2483 from kacpherek/fix/held-item-outline
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the block outline uses the shape for the held item (light, scaffolding) and the shulker box's lid, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:39 +01:00
Ludovic Marie 1ad1e957a3 Merge pull request #2477 from kacpherek/fix/polar-bear-attack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `PolarBear` (its goals, `PolarBearMeleeAttackGoal`'s rearing and warning sound, `PolarBearHurtByTargetGoal`, `PolarBearAttackPlayersGoal`, the stand animation and `DATA_STANDING_ID`). Left for later: the height doesn't grow while standing, the anger isn't saved, and anger at LAN guests isn't tracked. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:35 +01:00
Ludovic Marie b5f1e65162 Merge pull request #2467 from kacpherek/fix/execute-on-origin-leasher
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `execute on origin` (the thrower or shooter) and `on leasher`; your follow-up records the thrower of a guest's drops and the player behind a lead. Left for later: a fence knot leasher isn't an entity in Horizon, so it finds nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:31 +01:00
Ludovic Marie 39910efabf Merge pull request #2466 from kacpherek/fix/autosave-indicator
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Gui.renderSavingIndicator` (the fade in and out around the autosave), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:28 +01:00
Ludovic Marie b0103bd62f Merge pull request #2465 from kacpherek/fix/baby-walk-position
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the baby leg animation uses the interpolated `walkAnimation` position, not the tick value, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:24 +01:00
Ludovic Marie 44eb7008f1 Merge pull request #2461 from kacpherek/fix/guest-text-commands
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/tellraw`, `/title` and `/playsound` reach a server's players as their packets, and your follow-up plays a remote sound in the packet's category (`handleSoundEvent`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:11 +01:00
Ludovic Marie 179e410923 Merge pull request #2460 from kacpherek/fix/bonus-chest
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `BonusChestFeature.place` (shuffled columns, the chest with the `spawn_bonus_chest` loot table, a torch on each side that survives). I pushed one fix: `setInitialSpawn` passes the level random and the feature sets the blocks with flags 2 (`UPDATE_CLIENTS`). Left for later: it is skipped when the spawn chunk is not lit yet. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:07 +01:00
Ludovic Marie b0a6e63ce7 Merge pull request #2459 from kacpherek/fix/jigsaw-distance-padding-defaults
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `JigsawStructure`'s codec defaults (`max_distance_from_center` 80, the default `dimension_padding`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:04 +01:00
Ludovic Marie 4517d75a65 Merge pull request #2458 from kacpherek/fix/drowned-thrown-trident
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ThrownTrident` (8 damage, `dealtDamage`, the 0.99 water inertia, the hit and ground sounds, `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`) and the drowned's `TridentAttackGoal`. I pushed one fix: the damage goes through `EnchantmentHelper.modifyDamage` of the trident, so Impaling adds only for `#sensitive_to_impaling` (its `minecraft:damage` requirement), not for every mob that swims. Fixes #2447. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:00 +01:00
Ludovic Marie 4b042eae28 Merge pull request #2457 from kacpherek/fix/template-block-entity-nbt
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeInWorld` loads each block's template NBT into its block entity, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:11:57 +01:00
Ludovic Marie 30c6a4ac7b Merge pull request #2456 from kacpherek/fix/particle-count-zero
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ClientPacketListener.handleParticleEvent` with count 0 makes one particle moving at max speed times the delta. I pushed one fix: both are floats in the packet, multiplied as floats before the widening. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:46 +01:00
Ludovic Marie ce73764585 Merge pull request #2455 from kacpherek/fix/horse-tail-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractHorse.aiStep`'s 1-in-200 `moveTail` and its 8-tick `tailCounter`, with the model's tail swing, match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:41 +01:00
Ludovic Marie 59d0b45349 Merge pull request #2454 from kacpherek/fix/score-text-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ScoreContents` resolution in `/tellraw` and `/title`; your follow-up gives armor stands, frames, paintings, boats, carts and others their UUID name, as `getScoreboardName` does. Left for later: the `*` holder resolves to the acting player, not to each recipient. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:37 +01:00
Ludovic Marie 7373ee8da5 Merge pull request #2453 from kacpherek/fix/entity-shadows-option
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the Entity Shadows option gates `EntityRenderDispatcher`'s shadow pass, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:33 +01:00
Ludovic Marie a6af0d647f Merge pull request #2445 from kacpherek/fix/2384-sort-cursor-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the client's `AbstractContainerMenu.clicked` predicts the sort and tweak clicks like any other, so the last sorted item stays under the cursor until the server answers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 8b87d128e6 parity: world_reopen and server_player_sculk count ticks, not request round trips
On playhorizon (frames drawn on the CPU, control requests answered slowly)
world_reopen read the minecarts before the mineshaft's last one arrived (3
then 4 on every reopen), and the sculk walk polled the sensor with separate
requests that missed its 30-tick active window. The minecart list is read
once it stops changing; both clients sample the sensor every tick from one
ctl script, alongside the walk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:08:32 +01:00
Ludovic Marie 8a2c7eee4a Merge pull request #2444 from kacpherek/fix/enchanted-hit
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.attack` sends the enchanted hit (animate action 2, `MAGIC_CRITICAL_HIT`) when the weapon's enchantments add damage, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:17 +01:00
Ludovic Marie 8a572c9239 Merge pull request #2443 from kacpherek/fix/mob-death-poof
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickDeath` sends entity event 60 at 20 ticks and the client's `makePoofParticles` matches. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:12 +01:00
Ludovic Marie 6ea9f9580c Merge pull request #2440 from kacpherek/fix/bundle-selected-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BundleItem` / `ClientBundleTooltip` mouse-wheel selection (`ServerboundSelectBundleItemPacket`, cleared when the cursor leaves the slot, now on a server's client too). Left for later: the selected item's name line in the tooltip. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:09 +01:00
Ludovic Marie e9bcd10869 Merge pull request #2435 from kacpherek/fix/landing-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.checkFallDamage` sends the landing `block` particles (`150 * scale` of them, scaled by the fall distance, at speed 0.15) where the fall ends, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:05 +01:00
Ludovic Marie 3bd504c6c4 Merge pull request #2433 from kacpherek/fix/llama-spit-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LlamaSpit.recreateFromPacket` adds seven spit particles at 0.4 + 0.1 i of its movement, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:01 +01:00
Ludovic Marie a0343bceab Merge pull request #2431 from kacpherek/fix/explosion-emitter-size
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerExplosion.isSmall` (radius under 2, or the blocks kept) picks the small particle, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:41 +01:00
Ludovic Marie 25ad1db6d3 Merge pull request #2430 from kacpherek/fix/item-tooltip-lines
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ItemStack.addDetailsToTooltip`: the order here (instrument, bees, container, banner patterns, written book, charged projectiles with their indented details, fireworks, potion with `When Applied:`, jukebox, trim, stored and plain enchantments, dyed, lore, attributes, unbreakable, block state, spawner, then the advanced durability, id and component count) is vanilla's, and the crossbow lines match `ChargedProjectiles.addProjectileTooltip`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:38 +01:00
Ludovic Marie 65cc4c6f6b Merge pull request #2429 from kacpherek/fix/lan-player-pose-flags
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `Entity.DATA_SHARED_FLAGS_ID` bits (on fire, crouching, swimming, invisible, glowing, fall flying) and the pose for LAN players; your follow-up also sends the crouching pose under a low ceiling, which matches `Player.updatePlayerPose`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:34 +01:00
Ludovic Marie 8892f0fabf Merge pull request #2427 from kacpherek/fix/creaking-heart-explosion
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `CreakingHeartBlock` / `CreakingHeartBlockEntity.removeProtector` on an explosion kills the bound creaking. Left for later: no XP for a heart broken by a player's TNT (Horizon doesn't track the TNT's owner yet). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:31 +01:00
Ludovic Marie d6fc0f6e43 Merge pull request #2424 from kacpherek/fix/note-block-particle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NoteBlock.triggerEvent` adds the note particle 1.2 above the block with note / 24 as its colour, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:27 +01:00
Ludovic MarieandClaude Opus 5.5 82c8398c6b launcher: desktop shortcut that plays an instance
Home's Instance row gets Shortcut / Remove shortcut, and the CLI and MCP
instance shortcut [instance] [--remove]. The shortcut runs
horizon-launcher play --instance <id>: a .desktop file on Linux (and in
the applications menu), a script app with Horizon's icon on macOS, and a
Shell Link written from Microsoft's published MS-SHLLINK layout on
Windows. HORIZON_LAUNCHER_DESKTOP points it at a scratch folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Ludovic Marie 517cbe76bd Merge pull request #2423 from kacpherek/fix/entity-broadcast-range
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ChunkMap.TrackedEntity.getEffectiveRange` ends in `scaledRange`, which applies `entity-broadcast-range-percentage`, as done here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:02:02 +01:00
Ludovic Marie 236371d06d Merge pull request #2422 from kacpherek/fix/honey-block-slide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `HoneyBlock` (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects` with its 1-in-5 sound and particle rolls, entity event 53). Your follow-up runs it for a server's player too, with the sound to the others and event 53 to everyone, which is how vanilla splits it. Left for later: mobs don't slide yet, and there is no `honey_block_slide` criterion. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:58 +01:00
Ludovic Marie 06c2371360 Merge pull request #2421 from kacpherek/fix/bee-wither-rose-eyeblossom-contact
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WitherRoseBlock`'s effect (wither for 40 ticks on the mobs inside it) and the eyeblossom's poison on bees. Since then you added `MobEffectInstance.update`'s rule (a stronger effect replaces, an equal one keeps the longer duration, a weaker one changes nothing), which matches. Left for later: a weaker, longer effect is not kept hidden to resume afterwards (`hiddenEffect`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:55 +01:00
Ludovic Marie b3a1f66c35 Merge pull request #2419 from kacpherek/fix/animate-tick-extras
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's particle providers and `animateTick`s: honey drips (`DripParticle`), the poplar and azalea leaf particles, falling dust under gravity blocks and the campfire food smoke match vanilla's rates, sprites and velocities. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:51 +01:00
Ludovic Marie cca3b735cc Merge pull request #2462 from kacpherek/fix/mob-block-layers
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SnowGolem.DATA_PUMPKIN_ID` and `MushroomCow.DATA_TYPE` with their layers (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 e338690bf9 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 0ab24d0805 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:55 +01:00
Ludovic Marie dfa907d28e Merge pull request #2601 from ludovic111/feat/launcher-packs-core
🤖 Agent: launcher-features-1 checked this: base main, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 03:57:27 +01:00
Ludovic Marie d9dc1c866b Merge pull request #2442 from kacpherek/fix/splash-potion-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LevelEvent` 2002 / 2007 (`LevelEventHandler`: the item crumbs, then the coloured spell or instant effect particles) and the 1053 / 1054 break sounds. It conflicted with main in `game.rs` (the zombie door sounds 1019 / 1021 landed there), so I merged main in, keeping both lists. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:51 +01:00
Ludovic Marie ec8d5f1e06 Merge pull request #2432 from kacpherek/fix/sprint-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Entity.canSpawnSprintParticle` and `spawnSprintParticle` (the block under `getOnPosLegacy`, clamped into it, fences and walls taken from the supporting block) match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:48 +01:00
Ludovic MarieandClaude Opus 5.5 cde33a8e42 launcher: minimise or hide the window while the game runs
Settings > While playing: Stay (the default), Minimise, or Hide (macOS).
The window goes away when the game starts and comes back to the front
when it closes, with the run's play time, errors and log. CLI and MCP:
settings while-playing stay|minimize|hide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 0ccf3e110b launcher: live game console with search, follow, copy and share log
Home's Console shows the game's output of this run as it comes, with a
search, Follow, Copy and Open logs folder. Share log uploads it to mclo.gs
only after the player confirms, with the home folder, user name and
account names taken out first, and can delete the paste again. CLI:
share-log and unshare-log (MCP tools, the second needs confirm).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 b8b353c92e launcher: import a file dropped anywhere on the window
A file dropped on the window goes where it belongs:
- a shared instance becomes a new instance, which gets selected on Home;
- a Java modpack goes to the Modpacks import;
- a resource or shader pack goes into the selected instance;
- a data pack points to the world rows.

A note in the corner says what happened, until it is closed. The Packs
section and the world rows still take their own drops, and now stop the
drop there so it isn't imported twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 e28bcba3d3 launcher: import a shared instance, or route any dropped file
`horizon_launcher::import` works out what a file is from its contents:
- a shared instance: a .mrpack needing no mod loader (as `instance
  export` writes) or a game-folder zip;
- a Java modpack: a .mrpack with a loader, or a CurseForge zip;
- a resource or shader pack;
- a data pack.

A shared instance becomes a new instance named after the pack, with
"(2)" if the name is taken. Its overrides/ (or the zip's entries) are
copied in, but only the files an instance keeps, and only paths that
stay inside its folder. Its .mrpack files are downloaded from Modrinth
and checked against their SHA-512. If anything fails, the half-made
folder is removed.

CLI and MCP: `horizon-launcher instance import <file> [--instance <i>]`
does what dropping the file on the window will do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:31:52 +01:00
Ludovic MarieandClaude Opus 5.5 4482ff63f3 launcher: Export… on Home's Instance row
The Instance row gets Export…, which opens a panel to pick .mrpack or
.zip and switch on the worlds to include. The file goes to exports/ in
the launcher's data folder under a free name, and the panel offers Show
in folder. The panel closes when another instance is picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:25:19 +01:00
Ludovic MarieandClaude Opus 5.5 a9e5214ae4 launcher: export an instance as a .mrpack or a zip
`horizon-launcher instance export [<instance>] [--format mrpack|zip]
[--world <w>]... [--out <file>]` (and its MCP tool) writes what makes an
instance, or a port: options.txt, horizon-*.txt, servers.dat, resource
and shader packs, horizon-mods/ and config/, plus the worlds named. It
never includes logs, screenshots, backups, caches or account files. A
world leaves out playerdata/, stats/ and advancements/, and its level.dat
is written without the player's UUID.

A .mrpack follows Modrinth's published format: modrinth.index.json
lists the resource packs the browser installed from Modrinth that are
unchanged, by their download with SHA-1 and SHA-512 asked from
Modrinth's API. The rest goes under overrides/. An instance with nothing
to share is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:24:53 +01:00
Ludovic MarieandClaude Opus 5.5 2a7420651e launcher: find data packs on Modrinth and install one into a world
The Mods page gains a Data packs tab: Modrinth's data packs for 26.3,
each marked Partly with what Horizon runs of a data pack today
(functions, function tags, predicates, item modifiers). Install puts the
project's data pack file (not its mod build), checksum checked, into the
datapacks/ of a world picked on the tab, and says what of that zip runs
and what isn't read yet. A pack with nothing Horizon runs (worldgen
only) is refused. The world's list on Home shows it.

CLI and MCP: `mods search --kind datapack`, `mods install <pack> --world
<world>`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:15:49 +01:00
FusselTVandClaude Opus 5.5 d601715126 Streaming: the last finished ring is lit against decorated neighbours
A chunk was lit only once all eight neighbours were finished, so the
outermost finished ring of a loaded area (a /forceload square, a /pregen
band's edge, a player's last ring) stayed unlit: saved as FULL with no
light at all, and with no generation animals, which wait for light.
Vanilla's LIGHT step needs its neighbours only at INITIALIZE_LIGHT
(ChunkPyramid), so its last FULL ring is lit against decorated chunks.

A light job now reads a neighbour that will not finish (past the area)
from its decorated proto chunk (ChunkGenerator::light_view). A chunk
saved unlit is saved again once lit, and one whose light changes when a
neighbour finishes later is saved again. `execute if loaded` needs the
chunk lit, as vanilla's needs it ENTITY_TICKING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:08:53 +01:00
Ludovic MarieandClaude Opus 5.5 9cb2779608 launcher: install mods and packs into the selected instance
The Mods page and `mods` commands installed everything into the default
game folder whichever instance was picked. Mods, resource packs and
shader packs now go into the selected instance's folder, and the
Installed list, updates, Remove and the mod switches are that
instance's; picking another instance reads its list. The page says which
instance it installs into when there are several. `mods list|install|
update|remove|enable|disable` take `--instance` (default: the selected
one). Java mods' checks stay shared in the default folder for the
search results.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:02:09 +01:00
socksproxandDevin e70ead5bfe Multiplayer parity: four client/server movement and use divergences
- A remote client no longer tramples farmland or turtle eggs locally:
  BlockLandingWorld.is_server_level/is_client_side now reflect the
  player's client_side flag, as vanilla's fallOn gates trampling and
  entity events on `level instanceof ServerLevel`. The server's block
  change and teleport drive the change, so a player no longer ends up
  inside the grown block (phasing through when farmland turns to dirt).
- key.swapOffhand, key.drop and a mount's inventory key on a server now
  wait for the tick's input phase instead of sending a packet straight
  from the window event, like the click queue: no action packet leaks
  between CLIENT_TICK_END and a later pong.
- remote_use_item treats a refused consume like vanilla's
  InteractionResult.FAIL: a full player who right-clicks food no longer
  starts a use the server will refuse (the eat/noslow desync), the off
  hand gets its turn, and no use cooldown starts.
- The host's "moved too quickly" check subtracts the guest's
  server-simulated velocity like vanilla's expectedDist term, so a
  fast legitimate arrival (e.g. landing with elytra) isn't flagged.

Co-Authored-By: Devin <[email protected]>
2026-10-27 02:51:55 +01:00
Ludovic MarieandClaude Opus 5.5 aa6fdac501 Launcher: data packs into a world
datapacks.rs lists a world's datapacks/ (zips and folders with
pack.mcmeta, as the game discovers them; off when datapacks.json lists them
disabled), adds a data pack zip under a free name (refusing resource packs:
no data/ folder) and removes one to the trash, forgetting it in
datapacks.json. The game turns a new pack on when the world opens.

Home: each world gets "Data packs", which opens its packs under the row
with Add data pack… and Remove; a zip dropped on a world goes into it.
CLI: `worlds datapack-list|datapack-add|datapack-remove <world>`;
datapack-remove needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:49:42 +01:00
Ludovic MarieandClaude Opus 5.5 cac63dedca Launcher: Update all for installed packs
The Mods page's Installed list gets "Update all (N)" when newer versions
were found: every pack with one is updated in turn on a thread (each
download checked as an install is), with "1 of 2: …" progress, and one that
fails is named without stopping the others. The CLI already does this with
`mods update` and no pack name (MCP: mods_update).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:42:09 +01:00
DanielGodriNetoandClaude 9ae37fcb50 feat(threads): measure thread CPU time on Windows from cycle counts
thread_cpu() returned None on Windows. Implement it from thread cycle counts,
inside horizon-threads so horizon-client keeps forbid(unsafe_code).

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:40:34 -03:00
socksproxandDevin b39c4b8dd4 mobs: jumpOutOfFluid wants a dry, entity-free box
Entity.isFree is noCollision(this, box) && !containsAnyLiquid(box); the
check only looked at block collisions, so a mob pressed against a wall by
water flow hopped out where vanilla refuses, and it ignored other
collidable entities entirely.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:36:53 +01:00
Ludovic MarieandClaude Opus 5.5 3c257bb3e6 AGENTS.md: launcher test runs set HORIZON_LAUNCHER_TRASH and HORIZON_LAUNCHER_CLIPBOARD
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:31 +01:00
Ludovic MarieandClaude Opus 5.5 2ef155348f Launcher: keep test runs away from the real trash and clipboard
desktop_seams.rs holds the launcher's two hard-to-undo desktop effects.
HORIZON_LAUNCHER_TRASH=<folder> makes Delete (worlds, screenshots, packs)
move into that folder instead of the system's trash, and
HORIZON_LAUNCHER_CLIPBOARD=<file> makes every Copy in the window write into
that file instead of the clipboard. Test runs set both inside their scratch
folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:32:14 +01:00
DanielGodriNetoandClaude 8866e618b5 perf(light): cache touched sections during relight
Relight now runs over a Region that clones each touched section's Arc once,
copies its light on first write and writes back only sections whose light
changed. The increase loop reads state and light in one lookup. Output is
identical; a randomized border test checks it against a full relight.

Co-Authored-By: Claude <[email protected]>
2026-10-26 22:31:59 -03:00
socksproxandDevin 7732ce986f Drop a client's remembered effects when it joins a world
A rejoining player kept the previous session's effect list, so the HUD
could show effects the server no longer had (nothing applied in combat,
no icon was earned). Vanilla's new LocalPlayer starts empty and gets its
effects from UpdateMobEffect; match that, and let the client-side tick
drop effects whose duration ran out, as its removeIf does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:31:11 +01:00
Ludovic MarieandClaude Opus 5.5 e5c3d7cff7 Launcher: Packs section on Home, with drag and drop
Home lists the selected instance's resource and shader packs with a switch
each (disabled while the game runs: it rewrites options.txt itself), Remove
after a confirmation, and Add pack… or a .zip/.hshader dropped on the
section. switch() now takes any id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 ddaa514f3a Launcher: resource and shader packs per instance (library and CLI)
packs.rs lists an instance's resource packs (resourcepacks/, a .zip or a
folder with pack.mcmeta) and shader packs (shaderpacks/: Horizon's own and
the shaders/ format, as the game's Shader Packs page lists them), and turns
them on or off the game's own way: resourcePacks in options.txt (a pack
turned on goes on top) and pickedShaderPack in horizon-graphics.txt (one at
a time); every other line of both files stays. add() copies a dropped file
into the right folder under a free name; remove() turns it off and moves it
to the trash.

CLI: `packs [list|add|on|off|remove] [--kind] [--instance]`; `packs remove`
needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Devin 42ef865c34 Give shulkers vanilla placement, push and look rules
A shulker used to check only that its cell was air and the support
block had a full collision box. canStayAt now mirrors vanilla: a moving
piston in the shell's own cell does not force a teleport, the support's
face toward the shell must be sturdy, and the fully opened shell's box
must be clear of blocks and entities. teleportSomewhere likewise checks
the target cell with noCollision and refuses faces tagged
minecraft:shulker_does_not_teleport_to, emits level event 2016 (whose
particles now render), and forces a position sync.

The shell easing toward its peek now pushes collidable entities out of
the swept box (onPeekAmountChange / MoverType.SHULKER) through a new
MobEvent::Shift, so standing on a shulker lifts you instead of wedging
you inside the lid.

ShulkerLookControl is ported too: the head yaw is computed in the
attach face's plane (new LookKind::Shulker and look_yaw) instead of the
generic world-yaw, and hurtByTarget now ignores damage from other
shulkers as toIgnoreDamage does.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:11:05 +01:00
socksproxandDevin (SWE-2 High) e8891849a4 AGENTS.md: name posters by GitHub handle, never a real name
The "names its own person" rule was ambiguous about which identity an
agent should attribute a post to; handles are the public identity.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:43:34 +01:00
socksproxandDevin ee0af20e16 Cap the view scale's render distance by the server's
streaming.radius reverts to the client setting in apply_settings, so
an options change on a server with a lower view distance raised every
entity's render range past vanilla's. min(render_distance,
remote.view_distance) is Options.getEffectiveRenderDistance.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:27:41 +01:00
levzzz 17fa6e826c Merge remote-tracking branch 'upstream/main' into fix/potion-contents-in-the-creative-inventory 2026-10-27 04:50:23 +05:00
levzzz 66bec37b55 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 04:50:18 +05:00
levzzz 0fe747ad51 Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 04:48:57 +05:00
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

- Fox, Rabbit and Goat.addAdditionalSaveData: Type, RabbitType,
  IsScreamingGoat, HasLeftHorn and HasRightHorn are saved from the model
  and read back (an unknown rabbit type is brown, horns default to
  present, as vanilla reads them).
- The fields Horizon does not model (a fox's Trusted, Sleeping, Sitting,
  Crouching, a rabbit's MoreCarrotTicks, a horse's EatingHaystack and
  Bred) keep a loaded value or get vanilla's default.
- A loaded horse or llama gets its health clamped to its max health, as
  LivingEntity.setHealth does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:17 +01:00
levzzzandminimax-m3.1-flash 7ae20a5b29 A potion from the creative inventory keeps its contents in the hotbar
#1832 reported that a potion taken from the creative inventory and put in
the hotbar turned into the uncraftable potion. The hand-over keeps the
stack as it is on `main` (#1796 and #1874), so this adds the test that
keeps it that way: the item tab's stack is the real one, the creative
screen hands it over unchanged, and the hotbar slot holds the brew.

Fixes #1832

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:07:36 +05:00
levzzzandminimax-m3.1-flash 67b6e75c0b The container screen's press state does not survive the screen
AbstractContainerScreen keeps `isQuickCrafting` and the drag it belongs to
on the screen, so every screen starts with its own state. Horizon keeps
both on `Game`, and #2052 only dropped them when the pointer was grabbed
again, so a press whose release never arrived (the pointer left the
window) kept the drag running on the next screen: `Clicks::press` answers
`None` for every later press, so the Shift quick move and the clicks on
the inventory went dead.

The press state now goes with the pointer: `Game::capture` drops it
whichever way the pointer changes hands (a screen opens or closes, the
window loses focus), and `Clicks::cancel_drag` names the drop.

Fixes #1737

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:01:42 +05:00
Louis NordbøandClaude Opus 5.5 64cd55cf74 Finish a use on the server's USE_ITEM_COMPLETE, and eat on the client too
On a server client (single player's server thread, LAN guests, dedicated
servers) the window ended a consumable's use on its own timer and never ate
the food: its FoodData waited for the server's next ClientboundSetHealth.
With the button still held, the next startUseItem found the food bar below
20 and began another bite, so the eating animation went on after the bar
was full (#2471).

Vanilla's client never completes a use by itself: LivingEntity.updateUsingItem
completes it only on the server, and ServerPlayer.completeUsingItem first
sends the player entity event 9 (USE_ITEM_COMPLETE). Player.handleEntityEvent(9)
then runs completeUsingItem on the client, where Consumable.onConsume calls
the FoodProperties listener on both sides: the client's FoodData eats at once.

- The server sends event 9 to the acting guest when a consumable, a potion or
  a spyglass use completes. Like the guest's packets, the events are queued
  during their turn (kept across nested turns) and sent with their entity id.
- The client keeps using until that event, then shows the last crumbs, eats
  the food (the next ClientboundSetHealth replaces it) and starts the item's
  use_cooldown, which ItemStack.use only starts at once for an item with no
  use time.
- What vanilla's client checks so that a use can't outlive the server's:
  MultiPlayerGameMode.useItem passes on an item cooling down (chorus fruit
  held through its cooldown), LivingEntity.updatingUsingItem stops when the
  hand no longer holds the item the use began with (ItemStack.isSameItem),
  a respawn is a new player with no use, and Consumable.startConsuming
  starts none for a consumable with no use time.
- ItemCooldowns tick where Player.tick ticks them: after LivingEntity.tick's
  use on the server and after Minecraft.handleKeybinds on the client. Both
  sides ticked them first, so a guest's entry ended a tick later on the
  server than on their client, whose held retry was refused and never ended.

lan_e2e's new eat_full check eats melon slices up to a full bar with the
button still held: before, a new bite had started (use ticks 26, 2, 9, 15);
now nothing is in use. Held on two chorus fruits, both are eaten across the
cooldown. consume-release holds its completed use a little longer, as the
use now ends with the server's event.

Fixes #2471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:59:03 +01:00
Louis NordbøandClaude Opus 5.5 5b5ebfcce3 Walk turtles at their own pace so their feet stop flailing (#1776)
Turtles used the default MoveControl, which sets the speed straight to
speedModifier * movement speed (0.25 on a stroll). Vanilla gives them
TurtleMoveControl: updateSpeed halves the speed every tick on land (never
below 0.06), and in water lifts the turtle 0.005 and cuts the speed away
from home (0.08) or as a baby (0.06); tick then eases the speed an eighth
of the way to the wanted one, faces the wanted point with the body, lifts
towards it and never jumps. On land that settles at 0.08375, so a
strolling turtle covered about nine times the ground per tick that
vanilla's does (the input is speed squared). TurtleModel swings the legs
by 8 * walkSpeed at walkPos * 5, so the walk animation fed by that motion
spun the feet erratically.

This only fixes the move control. Swimming still differs from vanilla:
Turtle.travelInWater (moveRelative(0.1F), 0.9 drag, sinking 0.005 without
a target unless heading home within 20 blocks of it), TurtlePathNavigation
and the turtle goal set are not ported yet; turtles keep the generic
fluid travel and goals for now.

Vanilla: Turtle$TurtleMoveControl.tick / updateSpeed, Turtle.<init>,
TurtleModel.setupAnim, LivingEntity.updateWalkAnimation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:50:24 +01:00
levzzzandminimax-m3.1-flash ef0714a99a Lock in the sprint state that outlives the sprint key
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, so releasing the
key keeps it while forward is held. The server-side overwrite that rewrote
the flag from the held keys is gone, so the underwater swim, the sprint
jump and the flight speed follow the state on main. This adds the
end-to-end regression tests that were missing for those three paths: the
swim speed with the key released, the jump impulse with the key released,
and the flight speed that follows the sprint state and not the sprint key.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:22:24 +05:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
FusselTVandClaude Opus 5.5 a55c825467 Minecarts save HasTicked and Fire 0
AbstractMinecart saves Entity.firstTick as HasTicked, and a cart's fire
stays 0: Entity.move sets it to -getFireImmuneTicks(), which is 0 for
everything but players. A mineshaft's chest minecart was saved without
HasTicked and with Fire -1; vanilla's generated chunk saves it with
HasTicked 1 and Fire 0, and a cart that has ticked with HasTicked 0 and
Fire 0.

The mineshaft writes both as the proto chunk saves a new cart, and a
cart keeps first_tick from its save until its first tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
FusselTVandClaude Opus 5.5 6c716f457b Mobs: farm animals and frogs keep their variant; sound variants are saved
finalizeSpawn picks the variant of a pig, cow, chicken or frog and the
sound variant of a pig, cow, chicken, cat or wolf, but the save wrote
neither for these: pigs, cows, chickens and frogs came back temperate
after a reload, and every cat and wolf was saved with the classic sounds
whatever it rolled. Chickens also lost their egg timer.

Every mob with a variant component now saves and loads `variant`
through finalize::species_variant_component / variant_index (the cat and
wolf code used its own copy). The sound variant is kept as an index into
the species' registry (None is the default, classic) and is saved and
read back; Chicken also saves `EggLayTime`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
Ludovic MarieandClaude Opus 5.5 3f0e70882c Death messages: no fallback for a message with no entity behind it
DamageSource.getLocalizedDeathMessage sends the one-argument message, and
TranslatableContents.decompose shows a template it cannot fill as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 54ae097faa Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:46 +01:00
Ludovic MarieandClaude Opus 5.5 1b0937abff Piglin celebration: the dance roll and wasHurtBy as in PiglinAi
wantsToDance draws from RandomSource.createThreadLocalInstance(gameTime),
not the piglin's random, and wasHurtBy ignores another piglin's hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:05 +01:00
Ludovic MarieandClaude Opus 5.5 34f57dbcac Trident: Impaling through the enchantment's damage effect
ThrownTrident.onHitEntity runs EnchantmentHelper.modifyDamage on the
trident, so Impaling adds to the 8 for the entity types in
#sensitive_to_impaling (its minecraft:damage requirement), not for every
mob that swims or breathes water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 83b544f04f Bonus chest: draw from the level random and set blocks with UPDATE_CLIENTS
MinecraftServer.setInitialSpawn passes level.getRandom() to
BonusChestFeature.place, which sets the chest and torches with flags 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 1258c2473b Multiply the particle speed and delta as floats, as handleParticleEvent does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:54:53 +01:00
Kacpherek 3bb6e40304 Clear a bundle's selection when the cursor leaves it on a server's client too 2026-10-26 21:46:13 +01:00
Kacpherek eab8d0f911 Slide on honey for the server's player and show the slide effects to multiplayer clients 2026-10-26 21:45:02 +01:00
Kacpherek d7fece5825 Keep hideParticles of mob effects: no swirls drawn or sent for them 2026-10-26 21:42:52 +01:00
Kacpherek bab4251715 Give armor stands, frames, paintings, boats, carts and other entities a scoreboard name 2026-10-26 21:37:47 +01:00
Kacpherek 9eb913affe Show the crouching pose of a guest who is not holding sneak 2026-10-26 21:37:33 +01:00
Kacpherek c808fae288 Keep a stronger active effect when a weaker one is added 2026-10-26 21:37:30 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek abb4e7b5cd Record the thrower of guest drops and the player behind a lead 2026-10-26 21:21:40 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
Ludovic MarieandClaude Opus 5.5 3755bb7d77 parity: stop harnesses on SIGTERM, and reap orphaned harness JVMs
Nine vanilla Java servers from harness runs had been orphaned on the Mac mini for 2 to 5 days. Each harness runs in
its own session (killpg on a timeout), so when a CI job was cancelled, its harnesses and their servers outlived
parity.py.
- parity.py: SIGTERM and SIGHUP exit through the finally blocks, which now stop the local executor's harnesses
  too, not only the remote hosts'. The worker (--worker) stops its harnesses the same way.
- runner-gc.sh (every 6 h from launchd): stops Java processes whose parent is gone (launchd, init or
  systemd --user) and whose command line names a runner's checkout or a worktree's .local/, once ORPHAN_HOURS
  (default 3) old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:10:41 +01:00
Kacpherek 94748162bc Handle a dig packet before the move queued behind it
The client breaks the block under its feet and falls in the same tick. The
hub checked that move against the block that was still there and sent the
player back. Moves queued behind a dig packet now wait for the next tick,
after the break.
2026-10-26 21:08:59 +01:00
Kacpherek 06b046d8fc Light, scaffolding and shulker box outlines follow the held item and the lid 2026-10-26 21:07:17 +01:00
Kacpherek c85f6f2e39 Give polar bears their attack AI and rearing pose
Polar bears only panicked and strolled. They now follow vanilla's goals: melee attack that rears up and growls before each strike, hurt-by and cub-defending targets, anger at the player, and fox hunting. A hurt cub calls the adults. The standing flag is synched and the client animates it.
2026-10-26 20:50:13 +01:00
Kacpherek 36c06d6133 Let a piglin celebrate a killed target and dance over a hoglin 2026-10-26 20:41:38 +01:00
Kacpherek ffc343aadd Make execute on origin and leasher find their entity 2026-10-26 20:37:39 +01:00
Kacpherek 4cf9ffd847 Draw the autosave indicator 2026-10-26 20:36:40 +01:00
Kacpherek e23fcb1040 Scale a baby's walk position when it is read, as vanilla does 2026-10-26 20:35:46 +01:00
Kacpherek 27b81d9a8f Give /damage on a player its source, knockback and kill credit 2026-10-26 20:35:42 +01:00
Kacpherek 7b3595b8f2 Draw the snow golem's pumpkin and the mooshroom's mushrooms 2026-10-26 20:34:18 +01:00
Kacpherek ae3737b1c2 Mod API: render.set-far-terrain, and the official Far View mod
A mod can now turn on the game's distant terrain (LOD) with
render.set-far-terrain: a distance of 64 to 1024 chunks and Far Detail's
Reduced. The game builds and draws the tiles as it does for the LOD
Distance setting, which wins while it is on. One far terrain shows at a
time, the first loaded mod's. API level 4.

mods/official/far-view is the official mod on it: Distance (256 chunks by
default) and Far detail on its settings page, kept in its storage.
2026-10-26 20:34:08 +01:00
Kacpherek c417556834 Send /tellraw, /title and /playsound to LAN guests 2026-10-26 20:33:36 +01:00
Kacpherek bf62ccdabc Place the bonus chest in new worlds 2026-10-26 20:33:16 +01:00
Kacpherek 41d8b9e48a Jigsaw structures: default max distance vertical and padding top like vanilla 2026-10-26 20:32:20 +01:00
Kacpherek 0e658e3e62 Make drowned throw real tridents instead of arrows 2026-10-26 20:31:21 +01:00
Kacpherek 041ecb7b7a Structures: load the template's block entity NBT into every container and sign 2026-10-26 20:31:16 +01:00
Kacpherek 41ca489d4e Move /particle count 0 particles at speed times delta 2026-10-26 20:31:14 +01:00
Kacpherek 82d2b61a4a Swing a horse's tail now and then 2026-10-26 20:30:33 +01:00
Kacpherek f9b8b40a7d Show score components in /tellraw and /title 2026-10-26 20:30:13 +01:00
Kacpherek 08cd05ed6e Make the Entity Shadows option turn shadows off 2026-10-26 20:30:05 +01:00
Kacpherek f312aae7db Name the potion, cloud or thrower in magic death messages 2026-10-26 20:29:22 +01:00
Kacpherek 591b7b74da Predict the sort and tweak clicks on a server like an ordinary click 2026-10-26 20:15:10 +01:00
Kacpherek d78238f765 Show the enchanted hit when a melee weapon's enchantments add damage
Player.attack shows magic crit particles whenever the enchantments added
damage. Horizon showed only the critical hit, for the window's player and
for a guest's attack.
2026-10-26 20:14:32 +01:00
Kacpherek 303281ed09 Puff a poof where a dead mob's body goes
A mob removes its body twenty ticks after dying and tells everyone with
entity event 60, which draws twenty poofs about it. Horizon removed dead
mobs silently.
2026-10-26 20:12:28 +01:00
Kacpherek 18f8a6868b Burst a breaking potion into item crumbs and coloured spell particles
A thrown potion or experience bottle that breaks sends level events 2002 or
2007 with its colour and the splash sound (1053, 1054). Horizon spawned only
eight terrain crumbs, on the server's own side, so a server player saw
nothing.
2026-10-26 20:09:23 +01:00
Kacpherek 43d1f4b801 Draw the swirls of potion effects around players and mobs
A client draws the particles of an entity's visible effects itself, from
the synched effect particle list (LivingEntity.tickEffects). Horizon drew
none and sent no such list: now the window's own player, a server's mobs
and players, and the mobs and guests of a game in the frame all swirl.
2026-10-26 20:06:26 +01:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Kacpherek 96b538d48f Throw up landing dust where a hard fall lands
A landing that hurts sends the block's crumbs from the server
(LivingEntity.checkFallDamage). Horizon built the event but dropped it, for
the window's player and for every server player.
2026-10-26 19:59:59 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Kacpherek 4efa49ae82 Raise sprint dust under running players
A sprinting player kicks up dust of the block under their feet every tick
(Entity.spawnSprintParticle). Horizon drew none, for the window's player
or for the other players it sees.
2026-10-26 19:58:22 +01:00
Kacpherek 85db9a23a9 Use the small explosion particle when an explosion keeps blocks 2026-10-26 19:57:54 +01:00
Kacpherek 00cb813dc8 Show the missing item tooltip lines in vanilla order
Custom names are italic. Unbreakable, dyed, written book author and
generation, music disc songs, goat horn instruments, loaded crossbow
projectiles, hive bees and honey, the potion "When Applied" block and
enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency)
now appear, and advanced tooltips (F3+H) end with the item id and the
component count. Attribute lines use the attribute's sentiment colour.
2026-10-26 19:57:18 +01:00
Kacpherek 9e848670db Send swimming, gliding, fire, invisible and glowing for LAN players 2026-10-26 19:57:11 +01:00
Kacpherek dbd24e2e16 A creaking heart blown up by an explosion makes its creaking die 2026-10-26 19:57:02 +01:00
Kacpherek 5a70691d63 Show the note particle above a note block when it plays 2026-10-26 19:56:39 +01:00
Kacpherek e7e6e5bc07 Scale entity tracking range by entity-broadcast-range-percentage
The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance.
2026-10-26 19:56:22 +01:00
Kacpherek 4ebfaef0d3 Slide slowly down the sides of honey blocks 2026-10-26 19:56:07 +01:00
Kacpherek 7112d0cd64 Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison 2026-10-26 19:55:27 +01:00
Kacpherek 818b377c02 Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke 2026-10-26 19:54:32 +01:00
mahirsn 766bfbcdfa fix(client): answer the server's held slot like vanilla
ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do.
2026-10-26 21:48:55 +03:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

Pages sent as a component list (Hypixel through ViaVersion) were dropped
by item_data::read_text, which only took a string or a compound, so the
book opened blank. ComponentSerialization.CODEC also takes a list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:43:35 +03:00
socksproxandDevin 2c8b933be4 Apply vanilla's view scale to entity render distance
Entity.shouldRenderAtSqrDistance scales the bounding box's mean edge
by 64 * viewScale, and LevelExtractor sets viewScale to the entity
distance option times clamp(renderDistance / 8, 1, 2.5). Horizon used
the option alone, so above render distance 8 entities vanished sooner
than vanilla: a chicken at 32 blocks instead of 48 at the default 12.
RemotePlayer's box also counts ten times.

Fixes #2273

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:53 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol f661443fa9 Read LAN passenger fixture output through the host
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:22:50 +00:00
rdbtCVSandOpenCode agentrouter/gpt-6.1-sol 442d52a038 Release notes: preserve wrapped known issues
Co-Authored-By: OpenCode agentrouter/gpt-6.1-sol <[email protected]>
2026-10-26 17:19:04 +00:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 004ad993d9 Reject boarding a horse with an existing mob passenger
Fixes #1759

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 17:16:15 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 42a57b8286 Merge main and keep both recipe book regressions
Preserve the settings persistence regression alongside main's independent recipe-target check.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:15:59 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol de141bb7cb Fix recipe book settings persistence on the server
Send open/filter changes to the authoritative player's recipe book and cover packet settings plus save/reopen in both single-player modes.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 17:11:51 +00:00
SenjinandClaude Opus 5.5 8526305faa HDR: mark only the sun's and moon's discs, not their whole quads
The celestial pass wrote alpha 0 over each quad, so HDR output lifted a
square of bright daytime sky around the sun. It now draws premultiplied
(one, one), which looks the same, and its alpha is how much a texel adds;
the blend takes that off the frame's alpha, so only the discs shine. The
dim End sky, drawn by the same pass, stays unmarked. fs_celestial keeps
its own blend for lightning and additive entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:04:55 +01:00
socksproxandDevin b37528f5d6 Pause menu: server links
ClientboundServerLinksPacket (configuration and play) decoded and kept,
the pause menu gains its "Server Links..." button when a server sends
links, opening the server_links dialog screen: scrollable link buttons,
ConfirmLinkScreen on click (chatLinks/chatLinksPrompt honoured), and the
custom-screens warning with its disconnect. A dedicated server sends its
`bug-report-link` property as the BUG_REPORT link, like vanilla.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 17:51:54 +01:00
Eshayat Al-WasiuandCodex 2a98f584df Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <[email protected]>
2026-10-26 22:40:40 +06:00
SenjinandClaude Opus 5.5 cfd2dc8392 HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise
HDR10 is what games usually present and what the display receives: a
compositor composes in it or converts to it anyway. The NVIDIA driver
offers it on Wayland next to scRGB. A 10-bit swapchain also halves the
last pass's bandwidth; the frame before it is 8-bit, so nothing is lost.

hdr.wgsl converts the same linear light to BT.2020 (BT.2087) and encodes
it with the ST 2084 curve. HORIZON_HDR_OUTPUT=pq or scrgb allows only
that one, for comparing them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 9e71837df9 Keep server logging from panicking on a closed stdout pipe
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 14:56:15 +00:00
227 changed files with 16873 additions and 1227 deletions

No files matched your search

+3
View File
@@ -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
+3 -1
View File
@@ -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
@@ -65,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
Generated
+7
View File
@@ -930,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"
+1
View File
@@ -35,6 +35,7 @@ members = [
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
@@ -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()
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
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 {
@@ -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::<String>()
);
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();
+137 -11
View File
@@ -35,8 +35,9 @@ 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).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,6 +50,8 @@ enum Animate {
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> 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),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
// 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))
}
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"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);
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> 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;
@@ -504,6 +543,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 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint) => {
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
@@ -705,7 +791,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 {
@@ -977,6 +1063,13 @@ fn block_animate_tick(
}
}
}
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 [
[0, -1, 0],
@@ -1209,21 +1302,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<Item = &'a ([i32; 3], u32)>,
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 +1335,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);
+47 -4
View File
@@ -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)]
@@ -169,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,
@@ -211,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(),
@@ -451,12 +459,14 @@ 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();
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
@@ -501,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();
@@ -549,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 {
@@ -687,6 +707,8 @@ 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;
@@ -757,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.
+1 -1
View File
@@ -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.)] {
+48 -8
View File
@@ -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<ItemStack>,
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<Self> {
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<ItemStack> {
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<usize>,
/// 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]);
+11 -8
View File
@@ -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<u32> {
self.columns.state(x, y, z)
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
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);
@@ -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::<usize>::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)
);
}
}
+26
View File
@@ -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| {
+15 -2
View File
@@ -219,9 +219,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
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<f32> {
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.WATER_MASK: depth only (the pipeline masks the colour off).
+204 -24
View File
@@ -244,7 +244,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);
@@ -260,10 +304,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 {
@@ -273,10 +313,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::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.
@@ -311,6 +347,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()));
}
@@ -800,6 +840,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
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") {
@@ -935,20 +981,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, 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;
@@ -959,14 +1030,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, 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(),
@@ -2567,6 +2630,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<String, Arc<MobType>> = 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, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.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<String, Arc<MobType>> = 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)]
+319 -46
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod bundles;
mod campfire;
mod campfire_douse;
mod cartography;
@@ -156,6 +157,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -507,6 +509,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
@@ -1275,6 +1283,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
@@ -1442,6 +1452,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<ExperienceOrb>,
@@ -1668,12 +1682,13 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
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<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, 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)
@@ -1691,6 +1706,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<str>, 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<i8>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -1725,6 +1743,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).
@@ -1821,9 +1841,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
@@ -2118,6 +2175,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;
@@ -2335,11 +2419,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 thrown_from_hand && 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.
@@ -2912,6 +3003,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])
}
@@ -2931,6 +3032,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,
@@ -2944,6 +3050,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,
@@ -2979,6 +3096,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 {
@@ -2988,7 +3107,7 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
1029 | 1030 | 1042 | 1043 | 1044 => {
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
1019 | 1021 => {
@@ -2998,7 +3117,7 @@ impl Game<'_> {
}
_ => 1.0,
};
if matches!(id, 1019 | 1021 | 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);
@@ -3152,6 +3271,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::<f64>().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 { .. } => {}
@@ -3527,7 +3671,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,
@@ -4086,6 +4230,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 {
@@ -4107,6 +4252,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);
@@ -4125,6 +4271,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;
@@ -4168,6 +4325,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.
@@ -4198,26 +4360,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
@@ -4319,6 +4470,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
@@ -4422,7 +4579,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 {
@@ -4633,9 +4794,32 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// 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(),
@@ -4746,6 +4930,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
@@ -4948,6 +5133,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())
@@ -4969,10 +5157,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.
@@ -5431,6 +5622,9 @@ 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);
@@ -5443,6 +5637,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);
@@ -5817,12 +6012,14 @@ 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;
}
@@ -5943,6 +6140,12 @@ impl Game<'_> {
/// Several clicks on one menu instance (a quick craft drag is three).
fn menu_ops(&mut self, ops: &[(Option<usize>, 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);
@@ -7268,7 +7471,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;
@@ -7667,7 +7875,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);
@@ -7695,8 +7903,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 {
@@ -7883,7 +8092,7 @@ 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;
@@ -7946,6 +8155,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 {
@@ -10798,6 +11019,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]))
@@ -11189,6 +11421,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(),
@@ -11257,8 +11493,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 => {
@@ -11355,11 +11593,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.
@@ -11380,7 +11635,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()
@@ -11477,6 +11732,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;
@@ -11530,12 +11786,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 = (
@@ -11858,6 +12112,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);
@@ -11868,6 +12123,9 @@ 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);
}
@@ -12059,6 +12317,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()
}
@@ -12647,6 +12914,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,
@@ -12759,6 +13027,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(),
@@ -12903,6 +13173,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(),
@@ -12911,6 +13182,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,
@@ -12937,6 +13209,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,
+6
View File
@@ -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",
@@ -707,9 +710,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::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+1 -1
View File
@@ -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();
+6 -3
View File
@@ -1009,9 +1009,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);
};
+103
View File
@@ -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<usize>,
}
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);
}
}
}
@@ -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<ParticleOptions> {
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]);
}
+32
View File
@@ -10,6 +10,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<Text>,
}
#[derive(Default)]
pub(super) struct CombatTracker {
@@ -19,6 +22,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.
@@ -66,6 +71,14 @@ 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]) {
@@ -94,6 +107,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 {
@@ -111,10 +133,12 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
}
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.
@@ -224,6 +248,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))
{
@@ -248,15 +273,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])
}
}
+1 -2
View File
@@ -541,8 +541,7 @@ impl Game<'_> {
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
@@ -91,7 +91,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 +159,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,
@@ -640,6 +640,72 @@ 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(i) => self
.creatures
.projectiles
.get(i)
.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()) => {
@@ -841,10 +907,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);
@@ -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<String, serde_json::Value>| {
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<String, serde_json::Value>, 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<String, serde_json::Value>,
) -> R<Text> {
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<String, serde_json::Value>) -> Text {
use horizon_world::nbt::Tag;
@@ -136,28 +189,40 @@ impl Game<'_> {
}
out
}
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
// 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<Vec<Target>> {
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<i32> {
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<i32> {
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);
}
}
_ => {}
}
}
}
}
@@ -141,27 +141,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,
@@ -302,9 +312,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 {
@@ -328,8 +347,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(
@@ -606,8 +643,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();
+10
View File
@@ -19,7 +19,17 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `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,
@@ -878,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",
+4 -3
View File
@@ -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(
[
+222 -35
View File
@@ -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},
@@ -532,6 +533,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
@@ -604,6 +607,11 @@ impl Game<'_> {
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
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
@@ -662,6 +670,12 @@ 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();
@@ -688,6 +702,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);
@@ -950,6 +980,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.
@@ -1084,6 +1129,11 @@ 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]);
// 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.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
@@ -1646,11 +1696,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<u32>,
) {
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<Option<u32>>,
) -> bool {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -1663,23 +1736,26 @@ 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) {
@@ -1707,7 +1783,10 @@ 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)
});
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()
@@ -2045,6 +2124,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};
@@ -2073,6 +2179,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);
@@ -2222,11 +2330,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,
@@ -2247,17 +2355,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);
@@ -2268,7 +2374,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
}
@@ -2280,6 +2386,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;
@@ -2501,14 +2641,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),
_ => {}
}
@@ -3265,7 +3421,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
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.
@@ -3274,9 +3430,16 @@ impl Game<'_> {
}
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;
}
@@ -3417,6 +3580,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,
+12 -15
View File
@@ -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;
}
+130 -1
View File
@@ -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();
}
}
}
+1 -1
View File
@@ -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);
+4 -1
View File
@@ -2157,11 +2157,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);
}
+1 -1
View File
@@ -19,7 +19,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;
@@ -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<'_> {
+14 -9
View File
@@ -572,16 +572,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).
@@ -598,6 +598,11 @@ 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]);
+12 -1
View File
@@ -103,12 +103,23 @@ 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);
+3 -2
View File
@@ -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);
@@ -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,6 +122,96 @@ 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<Option<Vec3>> {
@@ -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<Publish>,
}
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,
+114 -14
View File
@@ -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,6 +21,7 @@ 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,
@@ -191,6 +192,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).
@@ -236,6 +248,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 };
@@ -350,6 +383,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 {
@@ -675,6 +716,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(
@@ -845,6 +887,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(),
},
})
}
@@ -962,6 +1014,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();
@@ -1207,8 +1269,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
@@ -1243,8 +1306,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<u32>,
@@ -1252,21 +1316,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
@@ -1339,13 +1432,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);
}
}
@@ -1428,6 +1521,13 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
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.
+69 -14
View File
@@ -165,6 +165,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// 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.
@@ -389,6 +391,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(
@@ -449,6 +452,7 @@ impl Game<'_> {
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -508,6 +512,7 @@ impl Game<'_> {
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -529,6 +534,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(
@@ -1216,6 +1232,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]
@@ -1910,21 +1932,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 +1970,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];
@@ -2319,7 +2346,35 @@ impl Game<'_> {
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),
// 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);
}
}
_ => {}
}
}
@@ -357,6 +357,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)));
@@ -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::{
@@ -81,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
@@ -89,13 +150,19 @@ impl Game<'_> {
&self,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
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<BlockStateId>,
) -> Option<Particle> {
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 {
@@ -254,8 +321,14 @@ impl Game<'_> {
/// `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(
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
let emitter = if small {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
+12 -5
View File
@@ -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
@@ -109,7 +111,7 @@ impl Game<'_> {
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
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 (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();
@@ -125,6 +127,7 @@ impl Game<'_> {
amount,
scale,
crit,
magic,
from,
sweep,
knockback: (strength, xd, zd),
@@ -257,7 +260,7 @@ impl Game<'_> {
} else {
Default::default()
};
if hurt && s.crit {
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 +270,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.
+5
View File
@@ -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() {
@@ -3,6 +3,7 @@
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
@@ -12,6 +13,12 @@ impl Game<'_> {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// 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], self.settings.particles);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
@@ -134,6 +141,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 {
+1 -1
View File
@@ -75,7 +75,7 @@ pub(super) struct Parked {
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
+37 -3
View File
@@ -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<horizon_client::save::RolledLoot>,
/// `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 {
@@ -536,6 +539,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);
@@ -1340,9 +1344,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 +1512,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 +1565,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 +1590,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)));
}
}
+73 -1
View File
@@ -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,
@@ -218,6 +219,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 +246,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<Vertex>,
) {
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).
@@ -31,6 +31,9 @@ impl Game<'_> {
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
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.
@@ -190,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<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
+13
View File
@@ -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
+295 -91
View File
@@ -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<Arrival> {
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);
}
}
+4
View File
@@ -34,6 +34,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(
+1 -2
View File
@@ -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;
}
+58 -52
View File
@@ -12,6 +12,7 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -26,6 +27,9 @@ 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<Text>,
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
@@ -47,7 +51,13 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
impl Game<'_> {
/// 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<Text>,
) {
self.potions.thrown.push(ThrownPotion {
id: 0,
stack,
@@ -55,6 +65,7 @@ impl Game<'_> {
position,
velocity,
age: 0,
owner,
});
}
}
@@ -135,6 +146,7 @@ impl PotionState {
position: at,
velocity,
age: 0,
owner: None,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
@@ -237,6 +249,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();
@@ -300,6 +313,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);
@@ -548,15 +562,15 @@ impl Game<'_> {
}
}
if let Some(p) = hit {
landed.push((t.stack.clone(), p));
landed.push((t.stack.clone(), p, 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, owner) in landed {
self.splash(&stack, at, owner);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -664,6 +678,7 @@ impl Game<'_> {
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
let center = Vec3::new(
@@ -679,6 +694,18 @@ 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) {
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
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);
}
} else {
@@ -694,68 +721,47 @@ impl Game<'_> {
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
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);
}
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 scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = stack.potion().and_then(brewing::potion) else {
let Some(def) = potion else {
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 or cloud, owner), which the
// death message names by the owner, else by the potion or cloud.
let source = owner.unwrap_or_else(|| {
let entity = if lingering {
"entity.minecraft.area_effect_cloud"
} else {
"entity.minecraft.splash_potion"
};
Text::translate(entity, &[])
});
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
@@ -609,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;
}
@@ -694,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);
@@ -714,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()
+241 -94
View File
@@ -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<Arc<str>>,
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<ChunkFormat>,
/// 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,
@@ -189,6 +193,10 @@ 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<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
@@ -212,6 +220,9 @@ pub(super) struct Remote {
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -394,6 +405,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 +442,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"),
@@ -473,6 +488,7 @@ impl Game<'_> {
destroying: None,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
death_message: None,
@@ -486,6 +502,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 +879,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) => {
@@ -1560,7 +1582,7 @@ impl Game<'_> {
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
@@ -1621,7 +1643,11 @@ 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;
@@ -1639,8 +1665,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() {
@@ -1741,7 +1769,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
@@ -1975,9 +2013,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
@@ -1988,6 +2026,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;
}
}
}
@@ -2136,9 +2175,38 @@ impl Game<'_> {
{
m.health = 0.0;
}
// 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);
}
// 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
@@ -2301,10 +2369,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) => {
@@ -3136,12 +3205,8 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
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);
@@ -3175,6 +3240,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();
@@ -3296,6 +3369,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.
@@ -3332,6 +3410,13 @@ impl Game<'_> {
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);
}
_ => {}
}
}
@@ -3508,6 +3593,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);
}
@@ -3518,6 +3613,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;
}
@@ -3872,6 +3971,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,
@@ -3884,20 +3986,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);
@@ -4296,13 +4384,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;
}
@@ -4374,14 +4476,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;
@@ -4406,20 +4519,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<()> {
@@ -4484,6 +4628,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;
}
@@ -4595,22 +4764,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)
}
@@ -4618,12 +4773,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 => {
@@ -4670,6 +4822,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.
@@ -4703,7 +4870,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()),
@@ -4793,26 +4960,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 {
@@ -5,7 +5,8 @@
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::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
@@ -34,6 +35,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 +64,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 +73,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;
@@ -100,6 +115,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<ParticleOptions> {
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) {
@@ -162,6 +162,10 @@ 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;
@@ -7,6 +7,7 @@ use horizon_client::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
chunk::{HEIGHT, MIN_Y},
containers::BlockEntity,
particles::{ParticleLevel, ParticleOptions, ParticleType},
world::World,
};
@@ -344,6 +345,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 {
+10 -2
View File
@@ -132,8 +132,16 @@ 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::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles are addressed by index and have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
}
}
fn holder_for(&mut self, t: Target) -> Option<Holder> {
+74 -17
View File
@@ -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<Text> = 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()?,
_ => {}
}
@@ -357,7 +393,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 +426,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);
}
}
}
}
@@ -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<String>,
}
/// `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<ServerLink>) -> Vec<ServerLink> {
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.<name>` 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<Text> = 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::<f32>() + 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}");
}
}
}
+220 -55
View File
@@ -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.
@@ -1690,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) {
@@ -1719,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()
@@ -1743,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));
@@ -1765,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())
@@ -1812,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).
@@ -1830,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!(
@@ -1853,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,
@@ -1901,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:"),
(
@@ -2105,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,
@@ -2120,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 {
@@ -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;
}
+22
View File
@@ -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<HeldName> {
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));
+1 -1
View File
@@ -252,7 +252,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.
+1 -1
View File
@@ -495,7 +495,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.
+1 -2
View File
@@ -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;
+24 -23
View File
@@ -511,10 +511,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<hdr::Hdr>,
/// Whether the window's surface can present scRGB.
hdr_capable: bool,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1071,7 +1071,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 +1099,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 +1113,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()
@@ -1953,22 +1953,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,
},
}),
@@ -2456,7 +2457,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(),
@@ -2990,24 +2991,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
+86 -13
View File
@@ -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<Output> {
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
}
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
[(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<Targets>,
@@ -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(&params));
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);
}
}
+11 -7
View File
@@ -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,
+22 -3
View File
@@ -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<f32>;
@group(0) @binding(1) var world: texture_2d<f32>;
@@ -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<uniform> params: Params;
@@ -40,6 +42,19 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
const BT709_TO_BT2020 = mat3x3<f32>(
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<f32>) -> vec3<f32> {
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<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
@@ -65,5 +80,9 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> 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);
}
+47
View File
@@ -191,6 +191,53 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
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<GuiDraw> {
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)]
+51 -1
View File
@@ -52,6 +52,8 @@ pub struct StackData {
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
/// `minecraft:bundle_contents`.
pub bundle_contents: Vec<ItemStack>,
/// `BundleContents.selectedItem`: runtime only, never saved or sent.
pub bundle_selected: BundleSelected,
/// `minecraft:suspicious_stew_effects`: (effect id, duration).
pub stew_effects: Vec<(Arc<str>, 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",
@@ -305,7 +321,9 @@ fn read_text(t: &Tag) -> Option<String> {
// 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,
}
}
@@ -791,6 +809,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
Arc::from(t.get("pattern").and_then(string)?.as_str()),
))
}),
bundle_selected: BundleSelected::default(),
other: Vec::new(),
};
let mut data = data;
@@ -899,6 +918,37 @@ mod tests {
);
}
#[test]
fn written_book_pages_may_be_component_lists() {
// A page as Hypixel sends it through ViaVersion: a list, not a compound.
let page = Tag::List(vec![
Tag::compound([("text", Tag::String("".into()))]),
Tag::compound([("text", Tag::String("Please read ".into()))]),
Tag::compound([("text", Tag::String("the rules".into()))]),
]);
let book = Tag::compound([
(
"title",
Tag::compound([("raw", Tag::String("Rules".into()))]),
),
("author", Tag::String("Server".into())),
(
"pages",
Tag::List(vec![
page,
Tag::compound([("raw", Tag::String("Two".into()))]),
]),
),
]);
let components = Tag::compound([("minecraft:written_book_content", book)]);
let (_, extra) = decode_components(Some(&components));
let pages = extra.and_then(|d| d.written.clone()).map(|w| w.pages);
assert_eq!(
pages,
Some(vec!["Please read the rules".to_owned(), "Two".to_owned()])
);
}
#[test]
fn banner_layers_and_shield_base_round_trip() {
let mut stack = ItemStack::new("minecraft:shield", 1);
+1
View File
@@ -653,6 +653,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();
+266 -45
View File
@@ -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<String>,
) -> Option<String> {
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<String>,
) -> Option<String> {
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<String>,
) -> Option<String> {
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::<u32>().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 <author>" and the generation.
pub fn written_book_lines(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
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<String>,
) -> Option<String> {
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<String>,
) -> 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<String>,
) -> Option<String> {
(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<String>,
) -> 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<String> = 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<String> = 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)
+6
View File
@@ -131,6 +131,8 @@ pub struct ItemInfo {
pub block_transformer: Option<String>,
/// 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<String>,
}
/// `Item.Properties.craftRemainder` (not a data component, so not in the
/// default-component report): what an item leaves in the grid when crafted with.
@@ -650,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)
+10
View File
@@ -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 {
+66 -18
View File
@@ -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<u32>,
) {
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(
+10 -4
View File
@@ -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<i32>,
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
/// 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,
});
}
+167 -16
View File
@@ -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},
};
@@ -87,6 +87,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.
@@ -168,6 +191,8 @@ pub struct Hub {
level_sync: std::collections::HashMap<String, LevelSync>,
/// Operators and their permission levels (dedicated servers).
pub ops: std::collections::HashMap<u128, u8>,
/// `entity-broadcast-range-percentage` (dedicated servers).
pub entity_range_percent: i32,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -335,6 +360,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,
@@ -1527,6 +1553,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<Item = (&'a str, bool)>,
) -> 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,
@@ -1534,21 +1603,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<horizon_protocol::item::Stack>)>,
attributes: Vec<play::AttributeSnapshot>,
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
@@ -1574,6 +1651,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,
@@ -1586,7 +1671,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,
@@ -1634,13 +1723,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<usize> = (0..self.guests.len())
@@ -1666,7 +1757,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,
@@ -1676,6 +1768,7 @@ impl Hub {
.as_ref()
.map(|m| super::attributes::syncable(&m.attributes))
.unwrap_or_default(),
&g.effects.list,
)
})
.collect();
@@ -1705,6 +1798,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)
@@ -1825,7 +1919,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 {
@@ -1974,6 +2068,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]),
@@ -2040,6 +2135,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 =
@@ -2070,6 +2174,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))),
}
}
@@ -2161,11 +2279,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,
@@ -2605,16 +2728,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
+4 -1
View File
@@ -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;
}
}
+5
View File
@@ -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<crate::server::properties::ResourcePackInfo>,
/// MinecraftServer.serverLinks (a dedicated server's `bug-report-link`):
/// sent in configuration when non-empty
/// (ServerConfigurationPacketListenerImpl.startConfiguration).
pub server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
impl NetConfig {
/// MinecraftServer.enforceSecureProfile.
@@ -168,6 +172,7 @@ impl NetConfig {
online: None,
log: None,
resource_pack: None,
server_links: Vec::new(),
}
}
}
+5 -2
View File
@@ -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<bool> {
Ok(false)
Ok(self.player.client_side)
}
fn random_float(&mut self) -> Result<f32> {
Ok(self.scene.random.next_float())
+323 -87
View File
@@ -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<L: AsRef<[u8]>>(
/// Resident chunks the incremental relighter reads and writes.
pub trait LightWorld {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32>;
/// Packed light (sky << 4 | block), None outside loaded chunks.
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8>;
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<Section>>;
/// Replace the light of a section `section` returned.
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: Light);
}
impl<S: std::hash::BuildHasher> LightWorld for HashMap<(i32, i32), Arc<Chunk>, S> {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<Section>> {
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<Slot>,
/// Section key (cx, s, cz) to slot, `NONE` where the chunk isn't lit.
index: FxHashMap<Pos, u32>,
last: (Pos, u32),
}
struct Slot {
key: Pos,
/// The world's section; its light is the value before this relight.
section: Arc<Section>,
light: Option<Box<[u8; 4096]>>,
}
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<usize> {
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<u8> {
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<u32> {
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<u8> {
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<u8> {
fn level(world: &mut Region, sky: bool, p: Pos) -> Option<u8> {
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<Pos>,
@@ -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<Chunk>>, 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<Chunk>>, 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<Chunk>>, 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<Pos> = (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(&region, &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<Pos> = (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()
);
}
}
+60
View File
@@ -3211,4 +3211,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");
});
}
}
+5 -2
View File
@@ -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;
}
}
+29 -4
View File
@@ -50,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;
@@ -645,6 +646,8 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// 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,
@@ -726,6 +729,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<crate::particles::ParticleOptions>,
pub effect_ambient: bool,
/// An invisible illusioner's mirror images.
pub illusions: Option<illusioner::Illusions>,
/// A villager's claimed bed head and meeting bell.
@@ -737,6 +746,8 @@ pub struct Extra {
/// the spin the rope's torque gives it.
pub leash: Option<leash::Holder>,
pub leash_pending: Option<leash::Pending>,
/// 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],
@@ -770,8 +781,9 @@ pub struct Extra {
pub drop_chances: Option<Box<[f32; 8]>>,
/// `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<u8>,
/// `Zombie.canBreakDoors`.
pub can_break_doors: bool,
/// A goat's `IsScreamingGoat` and the horns it lost (left, right).
@@ -972,6 +984,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,
@@ -1959,8 +1977,15 @@ impl Mob {
}
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 the old one
// with its own duration, an equal one keeps the longer duration,
// and a weaker one never changes the active effect.
if amplifier > e.2 {
e.1 = ticks;
e.2 = amplifier;
} else if amplifier == e.2 {
e.1 = e.1.max(ticks);
}
} else {
self.extra.effects.push((effect, ticks, amplifier));
}
+62 -1
View File
@@ -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,
}
}
@@ -227,6 +229,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
@@ -416,6 +419,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);
@@ -674,7 +678,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())
@@ -701,6 +705,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 {
@@ -1025,6 +1032,7 @@ fn species_ai_step(
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),
@@ -1517,6 +1525,56 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
}
}
/// `Entity.applyEffectsFromBlocks` for the flowers that give an effect:
/// `WitherRoseBlock.entityInside` (wither for 40 ticks on a living mob that
/// can take it, not in peaceful) and `EyeblossomBlock.entityInside` (poison
/// for 25 ticks on a bee, when the open flower attracts bees). The blocks
/// are those the mob's box touches where it started the tick and where it
/// ended.
fn flower_contact_effects(m: &mut Mob, w: &MobWorld) {
if !m.alive() || w.difficulty.effective == 0.0 {
return;
}
let half = f64::from(m.width()) / 2.0 - 1.0e-5;
let height = f64::from(m.height()) - 1.0e-5;
let mut visited: Vec<[i32; 3]> = 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);
@@ -1541,6 +1599,9 @@ fn tick_effects(m: &mut Mob) {
*ticks -= 1;
}
effects.retain(|e| e.1 > 0);
m.extra
.hidden_effects
.retain(|h| effects.iter().any(|e| e.0 == *h));
m.extra.effects = effects;
}
+2 -1
View File
@@ -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
},
+1 -1
View File
@@ -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);
+21 -1
View File
@@ -6,7 +6,7 @@ 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 {
@@ -135,6 +138,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 +351,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;
+25 -1
View File
@@ -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<MobEvent>) {
@@ -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.
+12 -1
View File
@@ -1508,7 +1508,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> 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.
@@ -2014,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;
+39 -2
View File
@@ -1017,6 +1017,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 +1033,7 @@ impl MeleeAttack {
speed,
follow_unseen,
zombie: false,
polar_bear: false,
path_target: [0.0; 3],
recalc: 0,
until_attack: 0,
@@ -1043,6 +1046,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 +1105,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 +1141,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);
@@ -1422,6 +1442,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,
}
@@ -1432,6 +1455,7 @@ impl HurtByTarget {
alert_except: vec![],
ignore: vec![],
bee: false,
polar_bear: false,
timestamp: 0,
st: TargetState {
unseen_memory: 300,
@@ -1451,6 +1475,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 {
@@ -1462,6 +1490,8 @@ impl Goal for HurtByTarget {
fn name(&self) -> &'static str {
if self.bee {
"BeeHurtByOtherGoal"
} else if self.polar_bear {
"PolarBearHurtByTargetGoal"
} else {
"HurtByTarget"
}
@@ -1500,7 +1530,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;
@@ -1520,6 +1552,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();
@@ -1534,6 +1568,9 @@ impl Goal for HurtByTarget {
});
}
}
if cub {
m.ai.target = None;
}
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
m.ai.target = None;
Loaded 100 of 227 files, more files were not shown because too many files have changed in this diff. Show more