Compare commits

...
Author SHA1 Message Date
Ludovic Marie 93d77962ee Merge pull request #2936 from horizon-rust/chore/horizon-rust-links
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): links after the move to horizon-rust, the mod index's new address, and the release-notes fix that makes verify pass on main again. pr_ready --quick and the full Python suite pass locally.
2026-10-27 21:43:18 +01:00
Ludovic MarieandClaude Opus 5.5 c9fa1c8115 Format the mod registry after the URL change
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:31:47 +01:00
Ludovic MarieandClaude Opus 5.5 d2b0347777 Point links at the horizon-rust organization
The repositories moved from ludovic111 to the horizon-rust organization.
GitHub redirects repository URLs but not GitHub Pages, so the mod index
moved to https://horizon-rust.github.io/horizon-mods/: the launcher and
the game read it there. The launcher still accepts icons and files from
the old addresses, which a mirror keeps serving for launchers released
before the move.

Release notes thanked ludovic111 once the repository's owner became the
organization (verify failed on main): the maintainer is now named.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:28:04 +01:00
Ludovic Marie f21e09510e Merge pull request #2910 from ludovic111/feat/triage-commands
🤖 Agent: Merged by Ludovic's AI agent (orchestrator) after review: comment commands for the trusted reviewers (/close, /reopen, /label, /unlabel), from main's code only, same list and pause as /nightly.
2026-10-27 21:11:21 +01:00
Ludovic Marie 27c3559421 Merge pull request #2879 from leifiyoo/fix/keychain-long-secrets
Keep long Microsoft sign-in secrets in several keychain items
2026-10-27 20:14:06 +01:00
Ludovic Marie a91711afaf Merge pull request #2382 from kacpherek/fix/parrot-follow-sit
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review); main is merged in after the crate split, and this head is proven in union/cg19-r5 (clippy, 1695 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 20:06:07 +01:00
Ludovic Marie 4df2461b65 Merge pull request #2408 from kacpherek/fix/bundle-dump-on-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Proven in union/cg19-r2 and union/cg19-r3, then merged with main again after the crate split and proven in union/cg19-r5 (clippy, tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 20:05:25 +01:00
Ludovic Marie 35540bda73 Merge pull request #2898 from ludovic111/feat/launcher-mojang-download
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven by the real run with Ludovic's own sign-in on the Mac mini (download from Mojang, prepare, play, sign-out refused, keychain clean; see #2892).
2026-10-27 20:01:57 +01:00
Ludovic Marie f66e6a944e Merge pull request #2896 from ludovic111/feat/launcher-owner-screen
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven by the real run with Ludovic's own sign-in on the Mac mini (download from Mojang, prepare, play, sign-out refused, keychain clean; see #2892).
2026-10-27 20:01:49 +01:00
Ludovic Marie 26547a5f29 Merge pull request #2860 from ludovic111/refactor/block-entity-data
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). The full pr_ready ran on this exact merge with main: clippy -D warnings, 1340 tests, 369 Python tests, 78 affected harnesses (77 passed, 0 failed), and lan_e2e --check chest passes in-frame and on the server thread. Verify is green here. Crate split, priority 1.
2026-10-27 19:58:54 +01:00
Ludovic Marie 41c2c2205c Merge pull request #2892 from ludovic111/feat/launcher-owner-gate
🤖 Agent: owner gate (Ludovic's decision 2026-10-27). Unit and CLI/MCP tests pass, and the real run with Ludovic's sign-in passed (Play as lu4ovic; after sign-out Play refused; keychain clean). Results posted on the PR. #2896 and #2898 are stacked on it.
2026-10-27 19:46:54 +01:00
Ludovic Marie 43134f4e41 Merge branch 'feat/launcher-owner-screen' into feat/launcher-mojang-download 2026-10-27 19:32:19 +01:00
Ludovic Marie 85c8276e06 Merge branch 'feat/launcher-owner-gate' into feat/launcher-owner-screen 2026-10-27 19:32:19 +01:00
Ludovic Marie 95bf2811fc Merge remote-tracking branch 'origin/main' into feat/launcher-owner-gate 2026-10-27 19:32:19 +01:00
Ludovic MarieandClaude Sonnet 5.5 d87b081c34 Trusted reviewers triage on GitHub: /close, /reopen, /label, /unlabel
One list (.github/trusted-reviewers.txt) now governs /nightly and the triage commands.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-27 19:21:08 +01:00
Ludovic Marie cf62fe270b Merge pull request #2679 from kacpherek/feat/spectator-menu
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:14:58 +01:00
Ludovic MarieandClaude Opus 5.5 b784b15b8a Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 38b508e5a7 Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 19:11:21 +01:00
Ludovic Marie de24a16ef4 Merge pull request #2349 from kacpherek/fix/campfire-douse-client
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:11:16 +01:00
Ludovic Marie 9b53749263 Merge pull request #2688 from kacpherek/fix/mob-looping-sounds
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:25 +01:00
Ludovic Marie cdccd03c7d Merge pull request #2685 from kacpherek/fix/mob-state-sync-3
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:21 +01:00
Ludovic Marie 27943eb6c7 Merge pull request #2683 from kacpherek/fix/magma-lava-jump-cobweb
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:17 +01:00
Ludovic Marie 28fbc7ff47 Merge pull request #2666 from kacpherek/fix/fishing-hook-players
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:13 +01:00
Ludovic Marie 33ced209a0 Merge pull request #2663 from kacpherek/fix/remote-swing-hand-duration
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:09:08 +01:00
Ludovic Marie 8155baa6f3 Merge pull request #2403 from kacpherek/fix/cat-morning-gift
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review), and this head (main merged in) is proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 19:06:53 +01:00
Ludovic Marie 423a87cf68 Merge pull request #2397 from kacpherek/fix/parrot-cookie
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said not to wait for his re-review), and this head (main merged in) is proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses, mob_ai). Thanks @kacpherek!
2026-10-27 19:06:18 +01:00
Ludovic Marie ff8a13c99a Merge remote-tracking branch 'origin/main' into refactor/block-entity-data 2026-10-27 19:03:59 +01:00
Ludovic Marie 0e1fcaa3cc Merge pull request #2310 from kacpherek/fix/2289-shield-pose
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:03:28 +01:00
Ludovic Marie 1b45824beb Merge pull request #2731 from kacpherek/feat/spear-lunge
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 and union/cg19-r3. After this batch it was merged with main again and proven in union/cg19-r4 (clippy, 1726 tests, 121/121 harnesses). Thanks @kacpherek.
2026-10-27 19:01:42 +01:00
Ludovic Marie a5053a898c Merge pull request #2215 from ludovic111/refactor/horizon-items
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). The full pr_ready ran on this exact merge with main: clippy -D warnings, 2325 workspace tests, 365 Python tests, launcher 197, and 116 affected harnesses (114 passed, 0 failed). Verify is green here. rdbtCVS approved. Crate split, priority 1.
2026-10-27 19:01:21 +01:00
Ludovic Marie 980807b0f5 Merge pull request #2895 from ludovic111/feat/nightly-prune-builds
🤖 Agent: Merged by Ludovic's AI agent (orchestrator) after review: scheduled cleanup of nightly/build-* branches older than 7 days (never the newest or a published nightly's), as Ludovic decided.
2026-10-27 18:39:20 +01:00
Ludovic MarieandClaude Opus 5.5 610c2f3c10 launcher: Download and prepare, from Mojang, for an account that owns the game
With the owner gate in place, a signed-in owner no longer needs another
launcher first (brief mc-download-1, Ludovic 2026-10-27).

- The Play page's Resources row offers an owner "Download and prepare"
  and "From my install"; without an owner it asks to sign in first.
- `horizon-launcher prepare --download` does the same.
- Every file from Mojang must have the size and sha1 Mojang lists.
- The download shows its progress and has Cancel; files already whole
  are kept, so the next try goes on where it stopped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:33:53 +01:00
Ludovic MarieandClaude Opus 5.5 7afb41a128 launcher: the owner gate's screen, once for offline players and on a refused Play
A player without an account that owns Minecraft: Java Edition sees one
screen when the launcher opens (once) and whenever Play is refused: why
Horizon needs the account, like other launchers, that worlds, offline
names and settings stay where they are, and Sign in, which runs the
device-code sign-in on the same screen. Not now closes it; Home keeps
its Sign in row, which now opens this screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:25:30 +01:00
Ludovic MarieandClaude Opus 5.5 f6e96e484c Nightly: the scheduled run deletes nightly/build-* branches older than 7 days
Ludovic's decision (2026-10-27). `nightly.py prune-builds` runs in the
scheduled `pick` only, with main's code, and lists each deletion in the run
summary. It deletes only branches named nightly/build-<date>-<n> older than
7 days, never the newest build, and never one a published `nightly` or
nightly-<date> release points to. nightly-state and every other branch are
left alone. tests/test_nightly.py pins it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:23:53 +01:00
Ludovic MarieandClaude Opus 5.5 ce64c617e9 launcher: Play needs a Microsoft account that owns Minecraft: Java Edition
Like other launchers, the launcher now starts the game only while an
account in it owns Java Edition (Ludovic's decision, 2026-10-27).

- account::launch is the owner gate: it asks the Minecraft services
  again (entitlements and profile, after the token renewal), the
  selected account first, until one owns the game. Each answer is kept
  in the keychain item beside the tokens. No answer (no network) keeps
  the last one; "doesn't own" blocks at once.
- Every Play goes through it: the window (Home, instances, packs),
  `horizon-launcher play` and `modpack play`, the MCP's play.
- Offline names are added and played only beside an owner; signing the
  last owner out keeps them, unplayable.
- Home shows "Sign in with a Microsoft account that owns Minecraft:
  Java Edition to play" with Sign in; the "coming soon" sign-in strings
  are gone.
- The game binary, harnesses, CI and the servers are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:16:55 +01:00
Ludovic MarieandClaude Opus 5.5 1cfb4def89 Merge main into fix/mob-state-sync-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:12:13 +01:00
Ludovic MarieandClaude Opus 5.5 b2fd94f49f Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:55 +01:00
Ludovic MarieandClaude Opus 5.5 b8eac21b6f Merge main into fix/mob-looping-sounds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:22 +01:00
Ludovic MarieandClaude Opus 5.5 fad711f55f Merge main into feat/spear-lunge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:22 +01:00
Ludovic MarieandClaude Opus 5.5 4bc514829c Merge main into fix/magma-lava-jump-cobweb
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:21 +01:00
Ludovic MarieandClaude Opus 5.5 b8ae7233e0 Merge main into feat/spectator-menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:20 +01:00
Ludovic MarieandClaude Opus 5.5 5001e3f57d Merge main into fix/fishing-hook-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:20 +01:00
Ludovic MarieandClaude Opus 5.5 f731ca1737 Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:19 +01:00
Ludovic MarieandClaude Opus 5.5 b124beafeb Merge main into fix/remote-swing-hand-duration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:19 +01:00
Ludovic MarieandClaude Opus 5.5 c7f448c523 Merge main into fix/campfire-douse-client
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:18 +01:00
Ludovic MarieandClaude Opus 5.5 c7937c5c20 Merge main into fix/2289-shield-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:18 +01:00
Ludovic MarieandClaude Opus 5.5 ed652da572 Merge main into fix/cat-morning-gift
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:17 +01:00
Ludovic MarieandClaude Opus 5.5 b28797c534 Merge main into fix/parrot-cookie
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 18:11:17 +01:00
Ludovic Marie 42ecf0f673 Merge pull request #2889 from ludovic111/chore/nightly-reviewer-online5277
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): reviewer list fix (Online5277 instead of levzzz5154), as Ludovic asked.
2026-10-27 18:07:58 +01:00
Ludovic Marie cf2e9d1bc0 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 18:03:10 +01:00
Ludovic Marie f8062e8fee Merge pull request #2583 from levzzz5154/fix/pot-back-face-culling
🤖 Agent: Merged by Ludovic's AI agent after the contribution gate (wave 5, cg20 union C, Mac mini), after #2296; main merged in since. Thanks @levzzz5154!
2026-10-27 18:02:10 +01:00
Ludovic Marie 7f760b8eb3 Merge pull request #2730 from kacpherek/fix/2714-windburst
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:46 +01:00
Ludovic Marie 8baf3cad9e Merge pull request #2727 from kacpherek/fix/2710-backwards-body-yaw
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:42 +01:00
Ludovic Marie 05f36b054c Merge pull request #2726 from kacpherek/fix/2709-dirtpath
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:38 +01:00
Ludovic Marie d89a8025f9 Merge pull request #2724 from kacpherek/fix/2717-flying-fall-distance
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:58:34 +01:00
Ludovic MarieandClaude Opus 5.5 674596103f nightly: Online5277 is the third trusted reviewer, not levzzz5154
Ludovic asked for his top three contributors by merged pull requests: kacpherek, rdbtCVS and Online5277.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:56:36 +01:00
Ludovic Marie 43d8bd01cd Merge pull request #2725 from kacpherek/fix/2702-toggle-sprint-ctrl-drop
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:55:00 +01:00
Ludovic Marie 18156172a3 Merge pull request #2728 from kacpherek/fix/2715-riptide
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:53:16 +01:00
Ludovic Marie 4c49c45a04 Merge pull request #2373 from kacpherek/fix/raid-recruit-needs-goal
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:15 +01:00
Ludovic Marie 7bd8747909 Merge pull request #2369 from kacpherek/fix/air-bubble-pop-sound
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:11 +01:00
Ludovic Marie 40e66ea972 Merge pull request #2365 from kacpherek/fix/give-overflow-drop
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:07 +01:00
Ludovic Marie 0acee0a978 Merge pull request #2686 from kacpherek/fix/wither-peaceful-raider-cap-trap-difficulty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:51:02 +01:00
Ludovic Marie 324239ea84 Merge pull request #2662 from kacpherek/fix/village-siege
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:50:58 +01:00
Ludovic Marie 93c4acf169 Merge pull request #2661 from kacpherek/fix/chat-click-hover-events
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:44 +01:00
Ludovic Marie 2f8c781f04 Merge pull request #2658 from kacpherek/fix/death-message-styled
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:40 +01:00
Ludovic Marie 3cdd63e01d Merge pull request #2657 from kacpherek/fix/attack-speed-effects
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:35 +01:00
Ludovic Marie aace479a0c Merge pull request #2656 from kacpherek/fix/other-player-poses
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:31 +01:00
Ludovic Marie d54cab9565 Merge pull request #2653 from kacpherek/fix/2628-enderman-mouth
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:48:27 +01:00
Ludovic Marie a3c05538ca Merge pull request #2121 from kacpherek/fix/1938-ghast-dismount
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate: proven in cg21's union2 (union/cg21-kacpherek-2 077c10835: 80/82 harnesses on playhorizon, lan_e2e 9/9 in-frame and server-thread). CI parity failed only on remote_fireball/remote_fireworks, which fail on bare main on playhorizon's runner too and pass on the Mac mini.
2026-10-27 17:45:41 +01:00
Ludovic Marie fddd6b3864 Merge pull request #2649 from kacpherek/fix/sleep-status-overlay
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:09 +01:00
Ludovic Marie ee217acc19 Merge pull request #2407 from kacpherek/fix/spawn-protection
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:05 +01:00
Ludovic Marie 18510648ea Merge pull request #2406 from kacpherek/fix/flying-climbable
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:44:01 +01:00
Ludovic Marie f65a4e45cf Merge pull request #2389 from kacpherek/fix/weather-height-adjusted-temperature
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:43:57 +01:00
Ludovic Marie 28c105fa9b Merge pull request #2388 from kacpherek/fix/death-screen-quit-confirm
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:43:52 +01:00
Ludovic Marie 2cd77f6d49 Merge pull request #2684 from kacpherek/fix/turtle-goals
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:22 +01:00
Ludovic Marie a045782e04 Merge pull request #2677 from kacpherek/fix/mob-event-particles-to-clients
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:18 +01:00
Ludovic Marie c5f0414a0a Merge pull request #2676 from kacpherek/fix/golem-shears-uncertainty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:14 +01:00
Ludovic Marie 2c82bbee6a Merge pull request #2673 from kacpherek/fix/server-thread-no-local-player
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:41:10 +01:00
Ludovic Marie 0529ae8250 Merge pull request #2412 from kacpherek/fix/ambient-bush-eyeblossom-moss-effects
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:40 +01:00
Ludovic Marie 1c876168bd Merge pull request #2402 from kacpherek/fix/parrot-imitate-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:23 +01:00
Ludovic Marie 48a3d8d175 Merge pull request #2394 from kacpherek/fix/parrot-shoulder
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:17 +01:00
Ludovic Marie f8040cb977 Merge pull request #2393 from kacpherek/fix/wolf-armor
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:13 +01:00
Ludovic Marie 1af3b2ebd9 Merge pull request #2375 from kacpherek/fix/warden-sonic-boom-knockback
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:09 +01:00
Ludovic Marie 1e8396db70 Merge pull request #2363 from kacpherek/fix/tp-facing-feet
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:38:05 +01:00
Ludovic Marie 6a031aa651 Merge pull request #2682 from kacpherek/feat/spear-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:36:52 +01:00
Ludovic Marie 98d53642fa Merge pull request #2387 from kacpherek/fix/recovery-compass-death-spot
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:56 +01:00
Ludovic Marie 60bcab5d61 Merge pull request #2380 from kacpherek/fix/comparator-reads-item-frame
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:50 +01:00
Ludovic Marie c083b9ed02 Merge pull request #2376 from kacpherek/fix/trial-mob-equipment
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:46 +01:00
Ludovic Marie 1ada2e33c8 Merge pull request #2348 from kacpherek/fix/silverfish-merge-and-wake-friends
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:42 +01:00
Ludovic Marie 2a6bf0fae5 Merge pull request #2342 from kacpherek/fix/skeleton-arrow-difficulty
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:35:37 +01:00
Ludovic Marie 5f14c125ed Merge pull request #2659 from kacpherek/fix/sweep-hits-players
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:33:29 +01:00
Ludovic Marie 152afb5529 Merge pull request #2357 from kacpherek/fix/shulker-bullet-levitation
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:34 +01:00
Ludovic Marie 3972efa333 Merge pull request #2338 from kacpherek/fix/enderman-angry-speed
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:30 +01:00
Ludovic Marie 4c348837e4 Merge pull request #2335 from kacpherek/fix/frost-walker
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:25 +01:00
Ludovic Marie 521a761745 Merge pull request #2328 from kacpherek/fix/arrows-bells-buttons
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:21 +01:00
Ludovic Marie bf11ca8ac9 Merge pull request #2324 from kacpherek/fix/plates-mobs-items
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:32:17 +01:00
Ludovic Marie df08ffda39 Merge pull request #2882 from ludovic111/fix/parity-host-excludes-locally
🤖 Agent: playhorizon's CI runner now skips the harnesses its host entry excludes (remote_fireball, remote_fireworks fail there on bare main from CPU starvation); pr_ready clean, unit test added.
2026-10-27 17:27:52 +01:00
Ludovic Marie 7d3aeb0315 Merge pull request #2399 from kacpherek/fix/wolf-spares-tamed-wolves
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:27:35 +01:00
Ludovic Marie d4b7ef198c Merge pull request #2359 from kacpherek/fix/piglins-hunt-adult-hoglins
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:26:04 +01:00
Ludovic Marie 6b011e436d Merge pull request #2356 from kacpherek/fix/trident-client-flight
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:26:00 +01:00
Ludovic Marie d98450a575 Merge pull request #2355 from kacpherek/fix/respawn-radius
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:56 +01:00
Ludovic Marie d860b9bfc0 Merge pull request #2343 from kacpherek/fix/endermite-lifetime
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:51 +01:00
Ludovic Marie 7f4ae40cc5 Merge pull request #2326 from kacpherek/fix/mob-cactus-berry-damage
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). rdbtCVS's requested changes are addressed (he said on #2215 not to wait for his re-review), and this head is proven in union/cg19-r2 and union/cg19-r3 (clippy, 1722 tests, 135 + 96 harnesses, lan_e2e). Thanks @kacpherek!
2026-10-27 17:25:47 +01:00
Ludovic Marie 7a53c8833b Merge pull request #2346 from kacpherek/fix/brush-suspicious-blocks
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19) after the contribution gate. Checked against vanilla 26.3 and proven in union/cg19-r2 (all round-2 PRs together: clippy, 1722 tests, 135 harnesses, lan_e2e in-frame and on the server thread) and union/cg19-r3 (the same PRs merged with today's main: 96/96 harnesses). Thanks @kacpherek.
2026-10-27 17:25:17 +01:00
Ludovic Marie f8e7517393 Merge pull request #2254 from kacpherek/fix/construct-beacon-trigger
🤖 Agent: Merged by Ludovic's AI agent (orchestrator): main merged in with the advancements resolution union2 proved; verify green. Thanks @kacpherek!
2026-10-27 17:24:47 +01:00
Ludovic Marie 42cbbb565e Merge pull request #2099 from kacpherek/fix/2087-pot-top
🤖 Agent: Read against the vanilla 26.3 sources (Decorated pot: draw one face of its top and bottom planes) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 17:17:48 +01:00
Ludovic Marie 4c7ada0d60 Merge pull request #2506 from masa-masa-0314/fix/animated-inventory-blocks
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Proven in union C (122/122 delta harnesses); its CI parity failed only on remote_fireball, a known load flake on playhorizon's runner that fails on bare main there too. Thanks @masa-masa-0314!
2026-10-27 17:11:38 +01:00
Ludovic MarieandClaude Opus 5.5 1788bf8134 Merge origin/main into fix/construct-beacon-trigger
advancements.rs keeps main's enchanted-item trigger (#2233) and the PR's
construct-beacon trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:11:08 +01:00
leifiyooandClaude Haiku 5.5 8d2b298253 Keep long Microsoft sign-in secrets in several keychain items
Credential Manager takes at most 2560 bytes per item. The secrets of a
Microsoft sign-in (the refresh token and the Minecraft token) are longer,
so saving them failed on Windows with "password encoded as UTF-16 is
longer than platform limit of 2560 chars" and the sign-in did not finish.

On macOS and Windows a secret is now split over the items `<uuid>`,
`<uuid>.1`, `<uuid>.2`... of at most 1000 UTF-8 bytes each (under 2560
bytes of UTF-16). Reads join the pieces, a write removes the leftovers
of a longer secret, and sign-out removes all of them. Existing single
items keep working.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-27 16:53:00 +01:00
Ludovic MarieandClaude Opus 5.5 9914a8af41 Name the wither's client smoke tick client_smoke
#2685 adds wither::client_tick for the side heads' turning on a client;
a distinct name lets both land without a clash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:47:06 +01:00
Ludovic Marie 9972feb710 Merge pull request #2235 from kacpherek/fix/instant-effects-rounding-splash-distance
🤖 Agent: Read against the vanilla 26.3 sources (Round instant potion effects and measure splash range from the body) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 16:44:03 +01:00
Ludovic MarieandClaude Opus 5.5 e776eb1716 Merge main into fix/magma-lava-jump-cobweb
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 78e635fe3e Merge main into fix/remote-swing-hand-duration
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 5e582420ed Merge main into fix/2714-windburst
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 3ff797d73d Merge main into fix/2710-backwards-body-yaw
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 1d52fe8101 Merge main into fix/2709-dirtpath
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 2487d29318 Merge main into fix/2717-flying-fall-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 3b8a8eeb91 Merge main into fix/2702-toggle-sprint-ctrl-drop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 063b6ee2b7 Merge main into fix/2715-riptide
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:33 +01:00
Ludovic MarieandClaude Opus 5.5 4414c3c1af Merge main into fix/bundle-dump-on-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 6cf5b046fa Merge main into fix/2289-shield-pose
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 d6ecd23a6e Merge main into fix/sleep-status-overlay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:42:32 +01:00
Ludovic Marie 12a8782268 Merge pull request #2864 from ludovic111/feat/nightly-launcher-text
🤖 Agent: verified (fmt, launcher lib tests 197 passed, fr/ru strings); #2859 merged, so the text is true now. Queued at 99ce9afd6.
2026-10-27 16:40:36 +01:00
Ludovic Marie 028f608816 Merge pull request #2859 from ludovic111/feat/nightly-reviewers-wire
🤖 Agent: Merged by Ludovic's orchestrator after review (nightly-2 wiring: /nightly for the trusted reviewers; nightly builds get their own cache key, never save it, and stay off the release runner).
2026-10-27 16:38:12 +01:00
Ludovic MarieandClaude Opus 5.5 0f8f8fce11 Merge main into fix/mob-state-sync-3
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:38:11 +01:00
Ludovic MarieandClaude Opus 5.5 18b317186a Merge main into fix/turtle-goals
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:54 +01:00
Ludovic MarieandClaude Opus 5.5 708db4627e Merge main into fix/fishing-hook-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:33 +01:00
Ludovic MarieandClaude Opus 5.5 e93299fb80 Merge main into fix/death-message-styled
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:25 +01:00
Ludovic MarieandClaude Opus 5.5 e12d4c9d07 Merge main into fix/ambient-bush-eyeblossom-moss-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:19 +01:00
Ludovic MarieandClaude Opus 5.5 505d17a425 Merge main into fix/parrot-shoulder
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:37:07 +01:00
Ludovic MarieandClaude Opus 5.5 2d89e9ae5f Merge main into fix/recovery-compass-death-spot
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:59 +01:00
Ludovic MarieandClaude Opus 5.5 14470343d1 Merge main into fix/air-bubble-pop-sound
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:44 +01:00
Ludovic MarieandClaude Opus 5.5 9d9389310d Merge main into feat/spectator-menu
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:43 +01:00
Ludovic MarieandClaude Opus 5.5 8649edaf16 Merge main into fix/village-siege
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 3a8706d0d7 Merge main into fix/chat-click-hover-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:42 +01:00
Ludovic MarieandClaude Opus 5.5 0762f49f20 Merge main into fix/cat-morning-gift
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 d980703786 Merge main into fix/other-player-poses
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 efd7b94c15 Merge main into fix/2628-enderman-mouth
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 9f2e4485ad Merge main into fix/sweep-hits-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:41 +01:00
Ludovic MarieandClaude Opus 5.5 bbd5c1c833 Merge main into fix/parrot-follow-sit
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 639ac0e4c6 Merge main into fix/parrot-imitate-dance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 76e3a0f7b7 Merge main into fix/wolf-armor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:40 +01:00
Ludovic MarieandClaude Opus 5.5 47e6541731 Merge main into fix/warden-sonic-boom-knockback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 361020fc23 Merge main into fix/tp-facing-feet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 417ee432c5 Merge main into fix/silverfish-merge-and-wake-friends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:39 +01:00
Ludovic MarieandClaude Opus 5.5 accd2718ac Merge main into fix/skeleton-arrow-difficulty
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:36:38 +01:00
Ludovic MarieandClaude Opus 5.5 854bcb158f random_tick probe: a fresh suspicious block's reset check changes nothing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:08 +01:00
Ludovic MarieandClaude Opus 5.5 1bd74d1b2f Skip the world spawn trip's teleport when the respawn already put the player there
Vanilla teleports a respawning player once; the second, identical move
showed up in the dedicated_server, pearl_death and cursor_death harnesses.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 967a9363ce entity_death_loot: give wolves their gear in the body slot
Horizon now keeps a wolf's body armour in its head slot, as for the other
BODY types.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:35:07 +01:00
Ludovic Marie ed10d376fa Merge pull request #2858 from ludovic111/feat/nightly-set
🤖 Agent: Merged by Ludovic's orchestrator after review (nightly-2: the /nightly set library; tools/ci/ is hand-merged).
2026-10-27 16:27:51 +01:00
Ludovic MarieandClaude Opus 5.5 8d5424408c Nightly: nothing an unreviewed build leaves reaches a release
Orchestrator's review of #2859: a nightly can hold pull requests nobody
reviewed yet, and their build scripts run in `build`. game-build.yml gets a
`trusted` input, true only from release.yml:

- the Rust cache (GitHub-hosted Linux and Windows): its own key for an
  untrusted build (`nightly-<name>`), never saved; a release restores only
  what a release saved;
- macOS: an untrusted build's exporters and build jobs run on the runner
  that already runs pull requests' parity jobs (macOS + horizon-game), never
  the release runner (MAC_RUNNER) nor parity-main's;
- a trusted build doesn't read the machine's shared sccache, which pull
  requests' jobs write (runner-env.sh: HORIZON_NO_SCCACHE).

tests/test_nightly.py pins all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:26:51 +01:00
Ludovic MarieandClaude Opus 5.5 99ce9afd63 Launcher: the Nightly setting says reviewers pick its pull requests
nightly-2: a Nightly can include pull requests not reviewed yet, picked by
trusted reviewers (docs/release-flow.md "Nightly channel"). The Settings row
says so, in English, French and Russian.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:20:32 +01:00
Ludovic MarieandClaude Opus 5.5 21ac444f28 refactor: block entity data moves down beside the chunk
Step A of the horizon-client crate split. `Chunk.block_entities` held
`containers::BlockEntity`, whose payloads were defined across containers,
brewing, signs, sculk, mobs::spawner, mobs::trial_spawner, vault, beehive
and command_block, so `chunk` reached most of the gameplay code.

The enum and every payload type now live in `chunk/block_entity.rs` as
plain data (definitions and derives). Every impl block stays in its
feature module, and each module re-exports its types under the old path,
so callers don't change. Also:

- `mobs::Who` moves to `horizon_client_core::who` (the vibration `Info`
  names it); mobs re-exports it.
- `skins::NameOrId` moves beside `SkullSkin`, which holds it; skins
  re-exports it.
- `Spawner.next`, the `Next` enum and `Selector.current` become
  `pub(crate)`, since their struct now lives in another module.
- The vault, spawner and trial_spawner harnesses record
  `chunk/block_entity.rs`.

No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:16:27 +01:00
Ludovic MarieandClaude Opus 5.5 7efd8ef33a Nightly tests: the scheduled refresh, the notes and the repo's reviewers list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:15:14 +01:00
Ludovic Marie c6633ea1bb Merge branch 'feat/nightly-set' into feat/nightly-reviewers-wire 2026-10-27 16:14:58 +01:00
Ludovic MarieandClaude Opus 5.5 6c0e262aa0 nightly_set tests: the repo's list file is checked with it (next PR)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:14:39 +01:00
Ludovic Marie 1e7e2ffb27 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 16:14:13 +01:00
Ludovic MarieandClaude Opus 5.5 c9990e43e9 nightly_set tests: the scheduled refresh and the notes move with nightly.py (next PR)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:14:01 +01:00
Ludovic MarieandClaude Opus 5.5 79eddec09c Nightly: trusted reviewers pick the pull requests with /nightly
Ludovic's decision (2026-10-27, from kacpherek's proposal under #2779): the
Nightly is where contributors test pull requests before the gate.
kacpherek, rdbtCVS and levzzz5154 (.github/nightly-reviewers.txt) and
Ludovic pick them with `/nightly 2640 2642`, `add`, `remove`, `clear`,
`status` or `dry-run` in any issue or pull request.

- nightly-command.yml (issue_comment, main's copy): checks who asked, saves
  the set on nightly-state, starts `nightly build`, answers "🤖 Nightly: …".
  It never checks out or merges a pull request.
- nightly-build.yml: `pick` builds main + the set (newest heads and merged or
  closed ones dropped at the scheduled run), pushes it as
  nightly/build-<date>-<n>, records it in builds.jsonl; build, sign and
  publish are unchanged; `answer` tells the thread how it ended.
- Notes say a Nightly can include pull requests not reviewed yet, picked by
  trusted reviewers, and list each with its author and who picked it.
- docs/release-flow.md: who can pick, how, how Ludovic removes or pauses them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:11:14 +01:00
Ludovic MarieandClaude Opus 5.5 0ff1df58a2 nightly_set.py: trusted reviewers' picks for the Nightly (library)
The pieces of nightly-2 (`/nightly 2640 2642`): parsing a comment, the
reviewers list (.github/nightly-reviewers.txt, with a `paused` line), the
refusals (paths CODEOWNERS gives to Ludovic, closed or merged PRs, more than
30, more than 6 builds a day), the set and its commands (Ludovic's remove
keeps reviewers from adding a PR back), the nightly-state branch (pushed with
retries when another run saved first), and the assembly: each PR head merged
onto main in order with `git merge-tree --write-tree`, objects only, so no
file, hook or filter of a pull request reaches the runner. Equal inputs give
the same commit. A conflict names the two pull requests (or main) and the
paths.

Nothing calls it yet: the next PR wires it into nightly-build.yml and a
`nightly command` workflow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 16:11:13 +01:00
Ludovic Marie 58beb98327 Merge pull request #2233 from kacpherek/fix/enchanted-item-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire the enchanted_item advancement trigger at the enchanting table) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 16:08:02 +01:00
Ludovic Marie 96f84abfeb Merge pull request #2256 from kacpherek/fix/axe-frees-copper-golem-statue
🤖 Agent: Read against the vanilla 26.3 sources (An axe turns an unoxidized copper golem statue back into a golem) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 15:50:37 +01:00
Ludovic MarieandClaude Opus 5.5 0879f3dbd2 parity: a host's exclude list also holds on its own CI runner
playhorizon's runner fails remote_fireball and remote_fireworks even alone and
at bare main (3b19c386c: 2 of 2 runs and the retry), while the Mac mini passes
them; every fork PR whose parity lands there stopped on them. The host's
exclude list only kept harnesses off workers sent there, not off the runner's
own jobs: platform_skips now adds the local host's exclude, skipped with its
reason unless named. The two harnesses join playhorizon's list; the Mac
mini's runner and the nightly audit (macOS) keep running them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:37:04 +01:00
Ludovic Marie 27af1879d7 Merge pull request #2124 from kacpherek/fix/block-animate-gaps
🤖 Agent: Read against the vanilla 26.3 sources (Block ambient effects: levers, repeaters, dripstone, wither roses, End gateways, lightning rods) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 15:28:43 +01:00
Ludovic Marie 096814ade9 Merge pull request #2675 from kacpherek/fix/dolphin-goals
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:24:54 +01:00
Ludovic Marie d177ad48e5 Merge pull request #2360 from kacpherek/fix/dragon-egg-attack
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:21:13 +01:00
Ludovic Marie 21123aaa3c Merge pull request #2670 from kacpherek/fix/chat-options-page
🤖 Agent: Merged main in (conflicts resolved, see the merge commit); proven in union R1b (branch union/cg19-r1b): clippy -D warnings, tests, the harnesses it can affect, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:32 +01:00
Ludovic Marie 65096c5b2e Merge pull request #2374 from kacpherek/fix/effect-icons-over-screens
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:28 +01:00
Ludovic Marie 9948e9d3ee Merge pull request #2372 from kacpherek/fix/nether-map-arrow-spin
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:17:24 +01:00
Ludovic Marie 3c3884ff38 Merge pull request #2245 from kacpherek/fix/crossbow-advancement-triggers
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate: proven in cg21's union2 (union/cg21-kacpherek-2 077c10835: 80/82 harnesses on playhorizon, lan_e2e 9/9 in-frame and server-thread). CI parity failed only on remote_fireball/remote_fireworks, which fail on bare main on playhorizon's runner too and pass on the Mac mini.
2026-10-27 15:14:14 +01:00
Ludovic Marie 30c58b6591 Merge pull request #2371 from kacpherek/fix/wolf-meat-heal
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:12 +01:00
Ludovic Marie fd2265dc19 Merge pull request #2370 from kacpherek/fix/johnny-vindicator-targets-raiders
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:08 +01:00
Ludovic Marie 84dcd156f8 Merge pull request #2368 from kacpherek/fix/raid-no-action-time
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:12:04 +01:00
Ludovic Marie 5ca6bd7de6 Merge pull request #2367 from kacpherek/fix/xp-clear-spawnpoint-targets
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:11:59 +01:00
Ludovic Marie 2a6b4c0ae6 Merge pull request #2366 from kacpherek/fix/clone-move-overlap
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:11:55 +01:00
Ludovic MarieandClaude Opus 5.5 d5aa3f6e42 Name the cat relax goal's owner check is_owner_of
The tame mob goals (#2382, #2399) add an is_owner(m, w) to this module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:11:34 +01:00
Ludovic MarieandClaude Opus 5.5 fbc7c628f6 Merge main into fix/give-overflow-drop
/give's overflow throw takes main's third argument (GiveCommand:
player.drop(copy, false)).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:10:11 +01:00
Ludovic Marie c20565ee49 Merge pull request #2364 from kacpherek/fix/dragon-strafe-line-of-sight
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:51 +01:00
Ludovic Marie 4e9c68e3da Merge pull request #2358 from kacpherek/fix/shulker-closed-armor
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:46 +01:00
Ludovic Marie 712144297a Merge pull request #2325 from kacpherek/fix/phantom-difficulty
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:41 +01:00
Ludovic Marie 651ac2cfd5 Merge pull request #2674 from kacpherek/fix/air-bubbles-eye-height
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:08:38 +01:00
Ludovic Marie 2853015f0e Merge pull request #2231 from kacpherek/fix/step-sound-above
🤖 Agent: Read against the vanilla 26.3 sources (Play the step sound of the block above the floor for the player) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 15:08:34 +01:00
Ludovic MarieandClaude Opus 5.5 0914229800 A parrot saves its owner in main's Variant arm
A second parrot arm was unreachable, so the variant was never saved.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:07:40 +01:00
Ludovic MarieandClaude Opus 5.5 e350b87b3c End portal exits pass no spawn finder
main's item and mob End portal exit calls end_arrival, which now takes the
respawn search's finder; in the End it only builds the platform.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 15:07:40 +01:00
Ludovic Marie 6567f95aa3 Merge pull request #2672 from kacpherek/fix/smithing-template-tooltip
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:51 +01:00
Ludovic Marie 1361ae8338 Merge pull request #2671 from kacpherek/fix/chat-minor
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:47 +01:00
Ludovic Marie d4412d160f Merge pull request #2655 from kacpherek/fix/book-sign-author
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:44 +01:00
Ludovic Marie 38ddc48cab Merge pull request #2654 from kacpherek/fix/host-sees-invisible-guests
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:40 +01:00
Ludovic Marie ef0d62e47e Merge pull request #2652 from kacpherek/fix/leave-bed-screen
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:05:35 +01:00
Ludovic Marie 6f4b1bf4c2 Merge pull request #2651 from kacpherek/fix/hotbar-pop-bundle-bar
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:59 +01:00
Ludovic Marie 691341b2b0 Merge pull request #2650 from kacpherek/fix/pvp-axe-disables-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:55 +01:00
Ludovic Marie 83979b38d6 Merge pull request #2648 from kacpherek/fix/local-server-checks
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:51 +01:00
Ludovic Marie b189f1b44c Merge pull request #2647 from kacpherek/fix/creative-hotbar-save-load
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:48 +01:00
Ludovic Marie 958db5f40f Merge pull request #2646 from kacpherek/fix/2630-husk-hunger-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:02:44 +01:00
Ludovic Marie 6b9d3309cc Merge pull request #2644 from kacpherek/fix/remote-break-item-change
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 15:00:02 +01:00
Ludovic Marie c5d737b536 Merge pull request #2643 from kacpherek/fix/2633-dragon-breath-colour
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:58 +01:00
Ludovic Marie 57fe750035 Merge pull request #2642 from kacpherek/fix/pick-item-server
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:54 +01:00
Ludovic Marie 3849e37393 Merge pull request #2640 from kacpherek/fix/hopper-entity-containers
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:48 +01:00
Ludovic Marie 9b091ed657 Merge pull request #2418 from kacpherek/fix/announce-player-achievements
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:59:43 +01:00
Ludovic Marie 777e0e2eb5 Merge pull request #2416 from kacpherek/fix/riptide-lift-collision
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:37 +01:00
Ludovic Marie 04ecdee7a2 Merge pull request #2415 from kacpherek/fix/op-permission-level
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:33 +01:00
Ludovic Marie 6ddc3226d4 Merge pull request #2410 from kacpherek/fix/enforce-whitelist
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:29 +01:00
Ludovic Marie 1903865cd5 Merge pull request #2409 from kacpherek/fix/flying-edge-backoff
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:25 +01:00
Ludovic Marie d5d54a8775 Merge pull request #2405 from kacpherek/fix/lan-join-leave-host-chat
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:56:21 +01:00
Ludovic Marie c27e2d2c2d Merge pull request #2404 from kacpherek/fix/sulfur-cube-archetype-physics
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:58 +01:00
Ludovic Marie d463d793a9 Merge pull request #2398 from kacpherek/fix/lodestone-compass-use
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:54 +01:00
Ludovic Marie f10425675c Merge pull request #2395 from kacpherek/fix/horse-armor-protects
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:50 +01:00
Ludovic Marie aa8ab4253d Merge pull request #2392 from kacpherek/fix/loot-set-name
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:46 +01:00
Ludovic Marie fa56070097 Merge pull request #2390 from kacpherek/fix/block-loot-copy-components
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:52:41 +01:00
Ludovic Marie dd5863edc2 Merge pull request #2385 from kacpherek/fix/lightning-sky-flash
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:48:05 +01:00
Ludovic Marie 37cabdcb02 Merge pull request #2381 from kacpherek/fix/hud-text-above-bars
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:48:00 +01:00
Ludovic Marie a40ef4eee0 Merge pull request #2379 from kacpherek/fix/sculk-catalyst-nonplayer-kills
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:56 +01:00
Ludovic Marie 914a6956e9 Merge pull request #2378 from kacpherek/fix/sky-body-rain-brightness
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:51 +01:00
Ludovic Marie 698b49e3a5 Merge pull request #2362 from kacpherek/fix/shulker-peaceful
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:47:46 +01:00
Ludovic Marie ec067f1e39 Merge pull request #2353 from kacpherek/fix/piglin-hurt-drops-admired-item
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:42:07 +01:00
Ludovic Marie 6081a66629 Merge pull request #2352 from kacpherek/fix/portal-pigmen-cooldown
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:42:03 +01:00
Ludovic Marie ee69413a68 Merge pull request #2351 from kacpherek/fix/ice-melts-when-mined
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:41:59 +01:00
Ludovic Marie fc4e7af854 Merge pull request #2344 from kacpherek/fix/creeper-fall-swell
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:41:54 +01:00
Ludovic Marie 0e069c31d7 Merge pull request #2243 from kacpherek/fix/bee-flower-effects
🤖 Agent: Read against the vanilla 26.3 sources (Bees get an effect from a wither rose or open eyeblossom instead of breeding) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread; main merged in since, clippy clean. Thanks @kacpherek!
2026-10-27 14:41:50 +01:00
Ludovic Marie fbbff39e07 Merge pull request #2341 from kacpherek/fix/fishing-rain-biome
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:42 +01:00
Ludovic Marie 49c2000de3 Merge pull request #2339 from kacpherek/fix/fishing-pickup-delay
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:37 +01:00
Ludovic Marie f561d4845b Merge pull request #2336 from kacpherek/fix/zombie-drowned-conversion-stops
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:31 +01:00
Ludovic Marie d6c4e5a027 Merge pull request #2333 from kacpherek/fix/farmer-requested-items
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:36:26 +01:00
Ludovic Marie ad271604af Merge pull request #2262 from kacpherek/fix/2209-flat-world-horizon
🤖 Agent: Read against the vanilla 26.3 sources (Flat worlds: no black horizon, the dark disc and void fog use the flat horizon) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 14:36:22 +01:00
Ludovic MarieandClaude Opus 5.5 c4a39757a1 Merge main into feat/spear-lunge
enchant_effects.rs: Lunge's post_piercing_attack effects beside main's
ApplyMobEffect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:21:38 +01:00
Ludovic MarieandClaude Opus 5.5 7690ad27c2 Merge main into fix/mob-looping-sounds
main's Drown event (entity event 67) stays beside this branch's entity
event doc.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:19:47 +01:00
Ludovic MarieandClaude Opus 5.5 74da2fe6b7 Merge main into fix/mob-state-sync-3
- lan.rs shared flags: on fire, a drowned's swimming, invisible and main's
  glowing in one byte.
- The piglin's zombification immunity (16) goes into its existing arm (the
  shared arm could no longer reach it); the villager's unhappy counter
  beside main's mob_freeze_meta.
- remote_mob_data.rs: imports unioned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:17:14 +01:00
Ludovic MarieandClaude Opus 5.5 966c38092d Merge main into fix/turtle-goals
- turtle.rs: these goals and navigation beside main's TurtleMoveControl
  and its tests; the doc says what is still not ported.
- ai.rs: the turtle keeps its own register arm, so main's call from the
  generic arm goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:14:49 +01:00
Ludovic MarieandClaude Opus 5.5 6d4b5c345f Merge main into feat/spear-use
lan.rs: the spear's kinetic hits beside main's cushion break particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:07:35 +01:00
Ludovic MarieandClaude Opus 5.5 8dcb4289a9 Merge main into fix/golem-shears-uncertainty
creatures.rs: shearing a snow golem or a bogged beside main's armadillo
brushing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:06:35 +01:00
Ludovic MarieandClaude Opus 5.5 6df1dfd4da Merge main into fix/fishing-hook-players
Hook targets and pulls beside main's guest explosions and particles; imports
unioned.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:05:55 +01:00
Ludovic MarieandClaude Opus 5.5 98ec18fcfe Merge main into fix/chat-click-hover-events
chat.rs: the run-command click beside main's page links and link prompt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:05:24 +01:00
Ludovic MarieandClaude Opus 5.5 683ff6f73a Merge main into fix/sweep-hits-players
The guests' sweeps (damage to the other players) beside main's guest
explosions and particles; both flush at the end of a guest's turn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:04:31 +01:00
Ludovic MarieandClaude Opus 5.5 a791368257 Merge main into fix/2628-enderman-mouth
- lan.rs: the enderman's DATA_CREEPY after main's warden, sniffer and
  shulker data.
- remote_mob_data.rs: the creepy flag beside main's arms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:07 +01:00
Ludovic MarieandClaude Opus 5.5 af548f5ae8 Merge main into fix/cat-morning-gift
- PlayerView: sleeping and the sleep timer beside main's passenger flag.
- creatures.rs: DropGift's item motion uses nextDouble, as the ItemEntity
  constructor does; SetBlock takes main's setBlockAndUpdate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:59:04 +01:00
Ludovic MarieandClaude Opus 5.5 1e741d1528 Merge main into fix/parrot-imitate-dance
game.rs: jukebox songs for the guests beside main's cushion breaks and
mining cracks (level events 2019, 2020).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:57:19 +01:00
Ludovic MarieandClaude Opus 5.5 8fad68ab3e Merge main into fix/parrot-cookie
creatures.rs: the player's eye position beside main's acting UUID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:56:34 +01:00
Ludovic MarieandClaude Opus 5.5 32c8def114 Merge main into fix/parrot-shoulder
- creatures.rs: shoulder times beside main's guest particles, the parrot
  shoulder event beside main's Infested, parrot food beside main's acting UUID.
- entity_nbt.rs: a parrot saves its Owner and Sitting in main's Variant arm
  (a second arm was unreachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:55:55 +01:00
Ludovic MarieandClaude Opus 5.5 7c1055df97 Merge main into fix/wolf-armor
- Taming takes main's acting_player_uuid (the host gets a UUID made for
  it); acting_owner goes.
- Wolf armour is the body slot (6) on main (head_is_body): lan.rs sends
  its cracks and components there, and remote.rs reads them from slot 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:54:47 +01:00
Ludovic MarieandClaude Opus 5.5 d25648f729 Merge main into fix/comparator-reads-item-frame
game.rs GameLevel: the item frames beside main's cart signals and arrow
boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:52:12 +01:00
Ludovic MarieandClaude Opus 5.5 70dd250ae6 Merge main into fix/warden-sonic-boom-knockback
- creatures.rs: mob_hits_mob takes main's hit handling (infested
  silverfish, other events left in the outbox), so a sonic boom and a melee
  hit share it.
- mobs.rs: SonicBoom beside main's Shift.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:51:40 +01:00
Ludovic MarieandClaude Opus 5.5 c6276dbbde Merge main into fix/trident-client-flight
- trident.rs: main's thrower guest (player_hurt_entity) beside the owner id
  a server sends.
- remote.rs: the trident and main's firework rocket both take a server
  move at once; both take its velocity.
- remote_projectiles.rs: main's smoke trails stay; the trident flies by its
  own client tick instead of main's interpolation step.
- tests/test_remote_motion.py: both fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:50:45 +01:00
Ludovic MarieandClaude Opus 5.5 a0b4721c5b Merge main into fix/campfire-douse-client
remote.rs: main's command block editor opens on a reacting block; the
campfire douse prediction stays its own branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:49:34 +01:00
Ludovic Marie d136f966f8 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 13:49:06 +01:00
Ludovic MarieandClaude Opus 5.5 1f457ff81e Merge main into fix/silverfish-merge-and-wake-friends
spawner.rs: main's mob_poof (entity event 20 to the clients) and
poof_particles split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:49:02 +01:00
Ludovic MarieandClaude Opus 5.5 9663d6f53e Merge main into fix/mob-cactus-berry-damage
mobs.rs: main's DryOut and piston deltas beside this branch's sweet berry
bush and stuck multiplier.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:48:12 +01:00
Ludovic MarieandClaude Opus 5.5 1bddc392b2 Merge main into fix/plates-mobs-items
- game.rs: the plate and tripwire mobs and other entities beside main's
  detector rail signal and arrow-pressed buttons.
- projectiles.rs: main's trident is 0.5 x 0.5 (EntityTypes.TRIDENT).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:47:37 +01:00
Ludovic Marie 4a8cb8c4f9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/raider.rs
2026-10-27 13:45:00 +01:00
Ludovic Marie 6ef20dc9af Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/natural_spawning.rs
2026-10-27 13:44:58 +01:00
Ludovic Marie e219a40ebc Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:55 +01:00
Ludovic Marie 64ca4d485f Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 13:44:53 +01:00
Ludovic Marie 66b9b3323c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:49 +01:00
Ludovic Marie 24d06f5ce2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-10-27 13:44:48 +01:00
Ludovic Marie 19de683b66 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:47 +01:00
Ludovic Marie ed8418a2ac Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/animate_tick.rs
2026-10-27 13:44:44 +01:00
Ludovic Marie c33a2a5d19 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
2026-10-27 13:44:43 +01:00
Ludovic Marie 9b49a0b727 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/motion.rs
2026-10-27 13:44:42 +01:00
Ludovic Marie 1b42652e96 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 13:44:39 +01:00
Ludovic Marie 1400da9637 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/biome.rs
2026-10-27 13:44:35 +01:00
Ludovic Marie 62794a3fde Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/screens.rs
2026-10-27 13:44:34 +01:00
Ludovic Marie c17da16907 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/save.rs
2026-10-27 13:44:34 +01:00
Ludovic Marie 62c5a6d27b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 13:44:32 +01:00
Ludovic Marie 9a9de6a936 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/finalize.rs
2026-10-27 13:44:31 +01:00
Ludovic Marie fe9b20733a Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:29 +01:00
Ludovic Marie 50f9255472 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/commands/execute.rs
2026-10-27 13:44:29 +01:00
Ludovic Marie 70181252d8 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 13:44:28 +01:00
Ludovic Marie dc3762b533 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/initial_spawn.rs
2026-10-27 13:44:26 +01:00
Ludovic Marie 4de85f000d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote.rs
2026-10-27 13:44:24 +01:00
Ludovic Marie 4de42d0dc4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 13:44:22 +01:00
Ludovic Marie 747c2031f1 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 13:44:21 +01:00
Ludovic Marie 14353e67d4 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/enchant_effects.rs
2026-10-27 13:44:20 +01:00
Ludovic Marie 8027bda6bf Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-world/src/level/redstone.rs
2026-10-27 13:44:19 +01:00
Ludovic MarieandClaude Opus 5.5 0c5e30ae5e Merge main into fix/chat-options-page
main moved the Opt enum to settings.rs (#2781): the three chat options are
added there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:42:12 +01:00
Ludovic MarieandClaude Opus 5.5 35689a58ba Merge main into fix/dragon-egg-attack
interact.rs attack_block: the dragon egg teleports first, then main's note
block plays its note.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:35:42 +01:00
Ludovic MarieandClaude Opus 5.5 79efeab0ce Merge main into fix/dolphin-goals
- goals.rs: main's player_in_range takes the visibility scaling; the
  through-walls check (forNonCombat) keeps it on.
- dolphin.rs: AroundSelf now names its mob and entity event (38).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:34:51 +01:00
Ludovic MarieandClaude Opus 5.5 be5d7ef703 Merge main into fix/animated-inventory-blocks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:16:10 +01:00
Ludovic MarieandClaude Opus 5.5 ee49ec5071 Merge main into fix/pot-back-face-culling (item_render: ItemQuad keeps cull_back and main's translucent)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:15:03 +01:00
Ludovic MarieandClaude Opus 5.5 e6b92995f3 Merge origin/main into fix/instant-effects-rounding-splash-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:10:01 +01:00
Ludovic MarieandClaude Opus 5.5 3094896ad1 mob_ai probe: count hits that bring an effect as player hurts
The new AttackPlayerWithEffect hit (husk, bee, cave spider...) still hurts
the oracle's player, so the probe prints its hurt sound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:02:29 +01:00
Ludovic Marie 9f0c201851 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-27 12:51:52 +01:00
Ludovic MarieandClaude Opus 5.5 6d55c17322 Merge origin/main into fix/crossbow-advancement-triggers
advancements.rs keeps main's levitation and using-item triggers and the PR's
killed-by-arrow trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:51:00 +01:00
Ludovic MarieandClaude Opus 5.5 9305cc3439 Merge origin/main into fix/2087-pot-top
entity.wgsl: main's glint_uv takes location 11, so the PR's cull flag moves
to location 12.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:49:15 +01:00
Ludovic MarieandClaude Opus 5.5 8187f1ef66 Merge origin/main into fix/construct-beacon-trigger
advancements.rs keeps main's new triggers and the PR's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:33:29 +01:00
Ludovic MarieandClaude Opus 5.5 05f3355c57 Merge origin/main into fix/enchanted-item-trigger
advancements.rs keeps main's new triggers and the PR's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:33:27 +01:00
Ludovic MarieandClaude Opus 5.5 f90cdb04a5 Merge origin/main into fix/1938-ghast-dismount
Keeps main's mob_meta call (with the game time) and the PR's precise flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:24:08 +01:00
Ludovic MarieandClaude Opus 5.5 78d4d7ede4 Merge origin/main into fix/crossbow-advancement-triggers
Resolved as in contrib-gate-21's union: both triggers and the damage
predicate kept; the arrow-kill trigger fires after PLAYER_HURT_ENTITY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:23:08 +01:00
Ludovic MarieandClaude Opus 5.5 fff7116b51 Format the chat options or-pattern
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:12:33 +01:00
Ludovic MarieandClaude Opus 5.5 ca351542ef Merge origin/main into fix/block-animate-gaps
animate_tick.rs keeps main's poplar leaves ambient sound and the PR's
dripstone drip, both whole.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:11:25 +01:00
Ludovic MarieandClaude Opus 5.5 d811847b2d Merge main into fix/chat-options-page
main added Chat Links Prompt as a setting of its own (settings.chat_links_prompt,
read by the server links screen); the option keeps that one instead of a
second, file-backed copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:03:25 +01:00
Ludovic MarieandClaude Opus 5.5 0c58226e9c Merge main into fix/2630-husk-hunger-shield
The bee's poison keeps main's difficulty rule (none on easy) and comes
only with a sting that hurt.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:59:21 +01:00
Ludovic MarieandClaude Opus 5.5 745a3ef110 Merge main into fix/dolphin-goals
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:40 +01:00
Ludovic MarieandClaude Opus 5.5 eace14a013 rustfmt the step-sound match arms
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:19 +01:00
Ludovic MarieandClaude Opus 5.5 5adf5c43a2 Merge main into fix/hotbar-pop-bundle-bar
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:12 +01:00
Ludovic MarieandClaude Opus 5.5 0075879d79 Merge origin/main into fix/axe-frees-copper-golem-statue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 1727f95575 Merge origin/main into fix/construct-beacon-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 73a9345c43 Merge origin/main into fix/bee-flower-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 0cecbef5c3 Merge origin/main into fix/instant-effects-rounding-splash-distance
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 4dec228d0c Merge origin/main into fix/enchanted-item-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 9b4b2e2db3 Merge origin/main into fix/axe-frees-copper-golem-statue
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 d511139408 Merge origin/main into fix/bee-flower-effects
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:49 +01:00
Ludovic MarieandClaude Opus 5.5 3ec521749b Merge origin/main into fix/instant-effects-rounding-splash-distance
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:46 +01:00
Ludovic MarieandClaude Opus 5.5 27671eac9b Merge origin/main into fix/enchanted-item-trigger
advancements.rs keeps main's trade trigger and the PR's own trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:34 +01:00
Ludovic MarieandClaude Opus 5.5 f9e11e7302 Merge origin/main into fix/construct-beacon-trigger
advancements.rs keeps main's trade trigger and the PR's own trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:32 +01:00
Ludovic MarieandClaude Opus 5.5 3093f8c594 Merge origin/main into fix/2209-flat-world-horizon (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:28 +01:00
Ludovic MarieandClaude Opus 5.5 831efabfbd Merge origin/main into fix/step-sound-above (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:27 +01:00
Ludovic MarieandClaude Opus 5.5 cae7cc02d3 Merge origin/main into fix/block-animate-gaps (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 7d54dc4eaa Merge origin/main into fix/1938-ghast-dismount (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:25 +01:00
Ludovic MarieandClaude Opus 5.5 9b98998991 Merge origin/main into fix/2087-pot-top (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:24 +01:00
Ludovic MarieandClaude Opus 5.5 d4137afb8c Merge origin/main into fix/step-sound-above
Step sounds: main's emission arms (no crouch guard) read the PR's muffled
flag; MotionResources keeps both the step-sound tags and main's crystal tag.
The amethyst chime (Entity.walkingStepSound) now also follows an inside or
combination step, as vanilla plays it after playStepSound whatever block
that sounded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:29:32 +01:00
Ludovic MarieandClaude Opus 5.5 905fd0d193 Merge origin/main into fix/2209-flat-world-horizon
Remote keeps main's use_item field and the PR's public dimension field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:26:21 +01:00
Ludovic MarieandClaude Opus 5.5 ef54b481a7 Merge origin/main into fix/2087-pot-top
Item quads keep both flags: the PR's cull_back (entitySolid back-face culling)
and main's translucent (the translucent item sheet).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:23:36 +01:00
Ludovic MarieandClaude Opus 5.5 3a5dd71152 Merge origin/main into fix/1938-ghast-dismount
Keeps both the happy ghast's precise-position flag and main's freeze metadata.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:19:03 +01:00
Ludovic MarieandClaude Opus 5.5 2c783d099a Merge origin/main into fix/block-animate-gaps
Keeps both the PR's lever, repeater, dripstone, wither rose, end gateway and
lightning rod animations and main's wet sponge and suspicious sand/gravel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:18:07 +01:00
Kacpherek e314c2fa81 Implement the spear's left-click jab and the Lunge enchantment 2026-10-27 11:13:14 +01:00
Kacpherek 3c85d2558b Tell a server player's client about its velocity after a mace smash 2026-10-27 11:11:36 +01:00
Kacpherek a05dc35c39 Launch a Riptide trident on a server's client when the use is released 2026-10-27 11:08:49 +01:00
Kacpherek 5d68512994 Keep the body facing forward when a player walks backwards 2026-10-27 11:08:07 +01:00
Kacpherek 377fecbf79 Draw a quad over fully opaque texels without the alpha cutout
A dirt path's side faces skip the clear top row of their texture, but the
cutout still ran on them: mip filtering blended that row into the top edge
and dropped it. Vanilla bakes each quad's layer from the texels under its
UVs, so these quads are solid.
2026-10-27 11:07:53 +01:00
Kacpherek a23a7ec35a Drop the whole stack on Ctrl+Q by the physical Control key only 2026-10-27 11:06:35 +01:00
Kacpherek 50cd5576f9 Clear a flying player's fall distance on the server tick 2026-10-27 11:06:03 +01:00
Ludovic MarieandClaude Opus 5.5 8648470c47 rustfmt the merged item_data.rs line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:06:54 +01:00
Ludovic MarieandClaude Opus 5.5 eb664d50c1 pr_ready: blocks.rs and items.rs live in horizon-items now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:04:38 +01:00
Ludovic Marie e42ec2360c Merge remote-tracking branch 'origin/main' into refactor/horizon-items
# Conflicts:
#	crates/horizon-items/src/item_data.rs
2026-10-27 09:04:26 +01:00
Ludovic MarieandClaude Opus 5.5 716cc7f102 Merge main into fix/crossbow-advancement-triggers
Keeps main's piercing count with this branch's fired_from_weapon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:55:21 +01:00
Ludovic MarieandClaude Opus 5.5 c35436a7da Merge main into fix/instant-effects-rounding-splash-distance
Splashes carry both the potion's age (for the distance) and main's
thrower (for the indirectMagic source).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:15 +01:00
Kacpherek fc06585938 Send mob particles to clients the way vanilla does 2026-10-27 06:45:32 +01:00
Ludovic Marie f8582fb678 Merge #2296 (enchantment glint)
# Conflicts:
#	crates/horizon-client/src/entity.wgsl
2026-10-27 06:40:21 +01:00
Ludovic Marie 9777b525b7 Merge #2296 (enchantment glint)
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-27 06:40:20 +01:00
Kacpherek 97d119b4e8 Loop the bee buzz and the guardian attack hum like vanilla 2026-10-27 06:39:31 +01:00
Kacpherek bca4513187 Keep withers out of Peaceful, raiders out of the spawn count, and use local difficulty for skeleton traps 2026-10-27 06:37:08 +01:00
Kacpherek 28b72d46c5 Give turtles vanilla's goals: panic, tempt, go to water, travel and stroll 2026-10-27 06:36:15 +01:00
Kacpherek cece5aca30 Send more mob state to the single player window
A client of a server only draws what the server sends. Send and read the
synched data for sleeping villagers (pose and bed), horses grazing and
rearing, invisible mobs, a carried enderman block, swimming drowned, zombie
villager cures, the villager head shake, zombie drowning conversion,
hoglin and piglin conversion, charged blazes, turtle eggs, baby zoglins and
the wither's side head targets.

A wither's side heads also need a line of sight to shoot, as in vanilla.
2026-10-27 06:36:13 +01:00
Kacpherek 713de31f64 Slow mobs in cobwebs and make magma cubes jump out of lava 2026-10-27 06:36:04 +01:00
Kacpherek e2a48d1c36 Implement the spear's use action: charge, stab, knockback, dismount 2026-10-27 06:36:00 +01:00
Kacpherek 9142f57bc8 Add the spectator menu with Teleport to Player and Teleport to Team Member 2026-10-27 06:33:52 +01:00
Kacpherek 306c6625e9 Shear snow golems and bogged by hand and use vanilla's snowball spread 2026-10-27 06:33:07 +01:00
Kacpherek 46f674f842 Give dolphins swim-with-player, jump and treasure goals and fish feeding 2026-10-27 06:32:31 +01:00
Kacpherek dca3bb89fe Show the air bubbles only when the eyes are below the water surface 2026-10-27 06:32:01 +01:00
Kacpherek e74a1fe461 Use every player in the level for patrols, sniffers, allays, trap horses and other world checks on the server thread 2026-10-27 06:31:46 +01:00
Kacpherek 581828bf94 Show the smithing template lines in item tooltips 2026-10-27 06:30:47 +01:00
Kacpherek 2c0d3c1a86 Chat wheel counts right Shift, durability line is translated 2026-10-27 06:30:46 +01:00
Kacpherek 62556429dc Add the four missing options to the Chat page 2026-10-27 06:30:09 +01:00
Kacpherek eeceb9c2de Let a fishing hook catch and pull other players 2026-10-27 06:27:40 +01:00
Kacpherek 2a1132387f Swing the right arm and for the right time on other players 2026-10-27 06:26:53 +01:00
Kacpherek ca83704cbb Add zombie village sieges 2026-10-27 06:26:12 +01:00
Kacpherek 180e2f612a Chat lines: server message bar and tooltip, item hover, run_command clicks, Shift insertion 2026-10-27 06:26:12 +01:00
Kacpherek 7b84a7ae91 Let a sweep attack hurt and push back the other players next to the target 2026-10-27 06:25:43 +01:00
Kacpherek ffad9e1f0a Keep the styling of the death message on the death screen 2026-10-27 06:25:21 +01:00
Kacpherek 1131a2910d Apply Haste and Mining Fatigue to the attack speed 2026-10-27 06:24:43 +01:00
Kacpherek 18217532af Show other players gliding, swimming, spinning and holding a loaded crossbow 2026-10-27 06:24:15 +01:00
Kacpherek 6d69bba15e Remote mining test fixture: the held-item key and the attack strength reset 2026-10-27 06:24:05 +01:00
Kacpherek 71b2f110eb Show the account name on the book signing screen 2026-10-27 06:23:59 +01:00
Kacpherek b6c77e12f3 Hide invisible guests in the host window 2026-10-27 06:23:43 +01:00
Kacpherek 5bbb61aec2 Angry endermen open their mouth on a server thread 2026-10-27 06:23:23 +01:00
Kacpherek 1f98f3ca89 Open the Leave Bed chat screen while the player sleeps 2026-10-27 06:23:22 +01:00
Kacpherek 0cd3410a2d Squeeze hotbar slots that gain items and draw the bundle fullness bar 2026-10-27 06:23:01 +01:00
Kacpherek 4afb5748f2 Let a player's axe hit disable the victim's shield 2026-10-27 06:22:13 +01:00
Kacpherek 6b1d376e6f Show the sleeping players count on the action bar of a LAN host or server 2026-10-27 06:22:11 +01:00
Kacpherek 008c4f91cd Treat single player as a local server for the tab list and F3 charts 2026-10-27 06:21:37 +01:00
Kacpherek 85f34393fe Save and load creative toolbars with the toolbar keys 2026-10-27 06:21:01 +01:00
Kacpherek 59bdb00599 Husk, cave spider, wither skeleton, bee and pufferfish effects only come when the hit lands 2026-10-27 06:19:41 +01:00
Kacpherek 29ab66935e Restart a break when the held item changes and reset the attack strength when it stops 2026-10-27 06:17:48 +01:00
Kacpherek 80fd68e1c2 Send the dragon's breath cloud particle to the client 2026-10-27 06:16:45 +01:00
Kacpherek f92cf816b1 Pick blocks and entities on the server like vanilla 2026-10-27 06:16:32 +01:00
Kacpherek 0c9bddecda Let block hoppers move items into and out of minecart containers 2026-10-27 06:11:01 +01:00
levzzz 0ee20da35c Merge remote-tracking branch 'upstream/main' into fix/pot-back-face-culling 2026-10-27 09:49:34 +05:00
levzzzandminimax-m3.1-flash b7749dd0d2 Fix a needless borrow in the decorated pot's test
Clippy's needless_borrow: the registry is already a reference, so the
extra & is dereferenced right away.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:38:03 +05:00
masa-masa-0314andZCode 18b544770f Inventory: frame strips by the mcmeta's frame size
`decode_one` cropped an animated block or item strip as `h > w -> w x w`,
ignoring the mcmeta's `animation.width`/`height`: a pack texture that
names its frame dimensions, or lays its frames along a row, packed the
whole image as "frame 0" and was then drawn as a square frame over a
rect sized for the image. The first frame now comes from
`AnimationMetadataSection.frameSize`, resampled to its longer side the
way the world's sprite arrays keep a frame square, and the sprite's GUI
size is that square too.

The atlas also gains the regression test the review asked for: a
synthetic two-frame block strip whose mcmeta starts on the second frame
registers in `sprite_animations`, and the atlas holds that frame at the
sprite's rect; a row strip with explicit frame dimensions packs only its
left frame.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 12:17:54 +08:00
Ludovic MarieandClaude Opus 5.5 03cc30ae45 Merge origin/main into refactor/horizon-items
entity_nbt keeps the name_text re-export (main's body is the same as
horizon-client-core's) and takes main's new spawn_bonus. Main's edits to
blocks, bundle, enchant, item_data and items land in horizon-items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:28:06 +01:00
Ludovic MarieandClaude Opus 5.5 80e28dfe63 creative_mining harness: items.rs now lives in horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:49:17 +01:00
Ludovic MarieandClaude Opus 5.5 27f73e1db3 Merge origin/main into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:49:17 +01:00
Ludovic MarieandClaude Opus 5.5 75bcbce441 Merge origin/main into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:27:09 +01:00
Ludovic MarieandClaude Opus 5.5 2f489f54c5 Merge origin/main (with #2160) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:38:11 +01:00
levzzzandminimax-m3.1-flash 7250944a7c Test the decorated pot item's back-face culling too
The pot in the hand and dropped on the ground is drawn by the same
`DecoratedPotRenderer` model, through the item path, so its quads carry
the cull as well. The test fails without it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:06:20 +05:00
levzzzandminimax-m3.1-flash ac33fb74ec Add the decorated pot's back-face culling regression test
`DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
whose pipeline culls back faces. The pot's `top` and `bottom` are flat
boxes, so without that cull both sides of each plane are drawn and they
z-fight: the pot renders wrong (kacpherek's #2099 draws the cull).

The test renders a plain pot and checks that all of vanilla's 28 submitted
quads are kept and that every one of them is marked for the cull, so the
behaviour cannot silently regress. It fails without the cull.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:57:27 +05:00
levzzz 5ac1c82eb9 Merge kacpherek's PR #2099: the decorated pot culls its back faces
The decorated pot's model parts are submitted with RenderTypes.entitySolid,
whose pipeline culls back faces; the pot's top and bottom planes are flat
boxes, so without the cull both sides of each plane are drawn and they
z-fight. This keeps all of vanilla's 28 submitted quads and culls the back
faces, like entitySolid does.
2026-10-27 04:36:29 +05:00
Ludovic MarieandClaude Opus 5.5 5f73d0d02d Merge refactor/horizon-command (with main) into refactor/horizon-items
Main's items.rs changes (equip_damage_on_hurt, resists_cactus) land in
crates/horizon-items/src/items.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:20:35 +01:00
masa-masa-0314andZCode 024415e09d Inventory: animate every animated block and item sprite
The shared atlas packs animated block and item textures as their first
frame, and only fire re-drew itself: an animated block held or previewed
in the GUI (sculk, magma) sat still, while the same block animated in
the world. The atlas now keeps every animated block or item sprite's
frame list and re-uploads the current frame into its rect as the
animation ticks, with the mcmeta's interpolation mixing the frame into
the next one the way the world's sprite arrays already do, so GUI items
animate like vanilla's stitched atlas.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:11:35 +08:00
Kacpherek e9797d69d0 Use the berry damage type for sweet berry bushes and check the whole move for contact 2026-10-26 21:51:25 +01:00
Kacpherek 4b4361fbc0 Clear a piglin's hoglin target once it dies or disappears 2026-10-26 21:48:20 +01:00
Kacpherek b0ce58adea Check the cat's owner and sync lying and relaxing to clients 2026-10-26 21:48:18 +01:00
Kacpherek 6145077d7e Make a tame parrot answer only to its owner 2026-10-26 21:44:11 +01:00
Kacpherek 512e003cbd Wolf armor: check the acting player's UUID and keep the armor's components 2026-10-26 21:43:50 +01:00
Kacpherek 2a297a43eb Credit the feeder with a parrot's cookie kill 2026-10-26 21:42:51 +01:00
Kacpherek 7eaa5f592b Send jukebox play and stop events to guests so their parrots dance 2026-10-26 21:38:21 +01:00
Kacpherek be714b38bb Only let a tame parrot land on its saved owner 2026-10-26 21:38:16 +01:00
Kacpherek 1a5e5eb358 Owner combat goals only follow the wolf's own owner 2026-10-26 21:37:58 +01:00
Kacpherek fba004eb3d Skip leaves in the no-leaves heightmap for firefly bush sounds 2026-10-26 21:22:41 +01:00
Kacpherek 2d70e55fbb Load the respawn search's candidate chunks instead of skipping unloaded ones 2026-10-26 21:21:42 +01:00
Kacpherek d0df238972 Include equipment modifiers in a mob's knockback resistance for the sonic boom push 2026-10-26 21:19:49 +01:00
Kacpherek d97b58fd4a Send the thrower's entity id with a tracked trident 2026-10-26 21:15:49 +01:00
Kacpherek e872d3ae54 Send isFlat in every dimension and use the dimension's min y for the horizon 2026-10-26 20:26:12 +01:00
Kacpherek fbdd04d817 Use armor stand, boat, cart and item eye heights for facing entity eyes 2026-10-26 19:58:03 +01:00
Kacpherek aed4e26f59 Apply the legacy announce-player-achievements key
The key was parsed and dropped. It now sets the show_advancement_messages game rule when the dedicated server starts, like DedicatedServer.initServer.
2026-10-26 19:54:23 +01:00
Kacpherek b5adad15eb Stop the riptide ground lift at ceilings 2026-10-26 19:53:42 +01:00
Kacpherek d17df3ca05 Give /op the level from op-permission-level
The console's /op always granted level 4. It now grants the permission level set in server.properties, like PlayerList.op.
2026-10-26 19:53:41 +01:00
Kacpherek 2d72e987c5 Play ambient sounds and spawn fireflies around firefly bushes, eyeblossoms, pale moss and dry plants 2026-10-26 19:53:15 +01:00
Kacpherek 9ae3882c1a Kick unlisted players when enforce-whitelist is on
Whitelist remove, reload and on, and deop, now disconnect online players who are not on the whitelist, like MinecraftServer.kickUnlistedPlayers.
2026-10-26 19:52:46 +01:00
Kacpherek 89c195fa6e Don't back off from edges while flying 2026-10-26 19:51:46 +01:00
Kacpherek de808e3612 Empty a bundle by holding right click 2026-10-26 19:51:31 +01:00
Kacpherek 4575f4f1df Honour spawn-protection on a dedicated server
Players who are not operators can no longer start digging or use an item on a block within the radius of the world spawn. They get the spawn protection message and the client gets the block back, like DedicatedServer.isUnderSpawnProtection.
2026-10-26 19:51:18 +01:00
Kacpherek 54b1c4f941 Make climbing follow vanilla: no climbing while flying, open trapdoors over ladders climb 2026-10-26 19:51:03 +01:00
Kacpherek 8a309c393d Show join and leave messages in a LAN host's own chat 2026-10-26 19:50:50 +01:00
Kacpherek 58a99a2ce9 Sulfur cubes bounce, slide and keep speed by their block's archetype 2026-10-26 19:49:41 +01:00
Kacpherek 23019a7bf9 Cats lie beside a sleeping owner and may leave a morning gift 2026-10-26 19:48:21 +01:00
Kacpherek 78a5ee0e55 Let parrots imitate nearby mobs and dance to a jukebox
Parrots only ever made their own sound. Follow Parrot.aiStep: one tick in
400 a parrot imitates a random mob within 20 blocks (half the time), and
its ambient sound is sometimes a mob's voice. A parrot within 3.46 blocks
of a playing jukebox now uses the party pose (level events 1010 and 1011).
2026-10-26 19:43:53 +01:00
Kacpherek c30d51d65e Wolves fight other owners' tamed wolves 2026-10-26 19:41:45 +01:00
Kacpherek c439b806b8 Let a compass lock onto a lodestone
Using a compass on a lodestone now sets its lodestone target (in place for a single compass in survival, otherwise one is split off), and the target clears when the lodestone is gone. The target also travels in item packets on the server thread.
2026-10-26 19:40:59 +01:00
Kacpherek 50fdcad10f A cookie poisons and kills a parrot 2026-10-26 19:40:42 +01:00
Kacpherek 2886b8f9ba Horse armor reduces the damage a horse takes 2026-10-26 19:39:31 +01:00
Kacpherek 9ca7162169 Let a tame parrot sit on its owner's shoulder
A tame parrot never landed on its owner. Add LandOnOwnersShoulderGoal:
when the parrot touches its owner, it is stored in the player's
ShoulderEntityLeft or ShoulderEntityRight and removed from the world.
It comes back, still tame, when the player falls, swims, flies, sleeps,
is hurt, dies, turns spectator or spins with a riptide.

The shoulder data is the player's own save data, so it also works for
players on the integrated server and for LAN guests.
2026-10-26 19:39:14 +01:00
Kacpherek de2df51193 Wolf armor: put it on, repair it, shear it off, and let it take the damage 2026-10-26 19:38:43 +01:00
Kacpherek dc36089156 Apply the set_name loot function to generated items 2026-10-26 19:37:51 +01:00
Kacpherek 08c959807d Broken heads keep their skin and silk-touched hives keep their bees and honey 2026-10-26 19:37:01 +01:00
Kacpherek 796dc320d9 Use the height adjusted biome temperature for the rain and snow line on the client 2026-10-26 19:36:45 +01:00
Kacpherek f1949c3bda Ask before the death screen's Title Screen button leaves the world 2026-10-26 19:36:39 +01:00
Kacpherek 4d9ead721e Point the recovery compass at the player's last death
The local player's last death location is now recorded, saved as LastDeathLocation, taken from the Login and Respawn packets on the server thread, and given to the item owner.
2026-10-26 19:36:35 +01:00
Kacpherek b8d0590d6d Lightning flash: mix the sky colour toward the flash tint instead of replacing it 2026-10-26 19:34:47 +01:00
Kacpherek bc2dd35cc3 Raise the held item name and action bar above extra heart, armor and mount rows 2026-10-26 19:33:57 +01:00
Kacpherek b5a735c8d1 Let tame parrots follow their owner and sit when told to
A tamed parrot ignored its owner: it never followed, and right-clicking
it did nothing. Give the parrot the goals of Parrot.registerGoals
(SitWhenOrderedToGoal, FollowOwnerGoal 1.0, 5.0, 1.0), let the owner
toggle sitting with a click while it stands on the ground, and keep the
owner and sitting state in the save file and in the data a guest gets.
2026-10-26 19:33:51 +01:00
Kacpherek 8c0ed0e591 Let a comparator read an item frame through a block
A comparator behind a solid block now reads the item frame on its far side (rotation + 1, or 0 when empty), and updates when the frame's item or rotation changes.
2026-10-26 19:33:24 +01:00
Kacpherek 849b5b663b Let a sculk catalyst take the experience of mobs killed without a player 2026-10-26 19:33:09 +01:00
Kacpherek b65b0a6a7d Dim the sun and moon in rain and turn the moon texture the right way up 2026-10-26 19:32:59 +01:00
Kacpherek f1181d4a25 Trial chamber mobs spawn with the gear their spawner's equipment table gives 2026-10-26 19:32:16 +01:00
Kacpherek c5a37c02de Push sonic boom targets only when hit, by their knockback resistance 2026-10-26 19:32:01 +01:00
Kacpherek 74dc389321 Keep the effect icons on screen over the pause menu, chat and other screens 2026-10-26 19:31:55 +01:00
Kacpherek 7d7a85d184 Recruit nearby raiders only while the raid pathfinding goal runs
Raiders recruited other raiders every 20 ticks whether or not they were
heading for the raid. In vanilla the recruiting is part of
PathfindToRaidGoal.tick, so a raider that has a target or carries a
rider does not recruit. Do the recruiting from the goal's tick.
2026-10-26 19:31:28 +01:00
Kacpherek de1e5f7292 Spin the player arrow on a Nether map
Vanilla's MapItemSavedData.calculateRotation gives the arrow a changing rotation in the Nether. The player marker used the yaw there too, so it never spun.
2026-10-26 19:31:10 +01:00
Kacpherek 9bc05faf53 Tamed wolves heal twice the food's nutrition 2026-10-26 19:30:54 +01:00
Kacpherek 575cef1d26 Let a vindicator named Johnny attack other raiders too
Vanilla's Johnny goal targets any living entity that can be attacked.
Horizon skipped pillagers, vindicators, witches, evokers, illusioners
and ravagers, so Johnny left them alone. Remove that filter.
2026-10-26 19:30:38 +01:00
Kacpherek 0cc3f567fc Play the bubble pop sound when an air bubble bursts 2026-10-26 19:30:29 +01:00
Kacpherek cb97c5496e Raiders in a raid no longer count idle time twice as fast
A raider's idle counter grew by 4 per tick while in a raid, so it
dropped out of the raid after about 700 ticks instead of 1400 and could
no longer be recruited sooner than in vanilla. Remove the extra tick
added by the raid bookkeeping; the counter now grows by 3 like vanilla.
2026-10-26 19:30:07 +01:00
Kacpherek 8ca15ae2ca Apply /xp, /clear and /spawnpoint to each target, not only the host 2026-10-26 19:23:47 +01:00
Kacpherek 10872bdd64 Add the tridents field to the remote motion test fixture 2026-10-26 19:22:53 +01:00
Ludovic MarieandClaude Opus 5.5 fc8e505c95 Merge refactor/horizon-command (with main and #2316) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:22:52 +01:00
Kacpherek 3b24aceb04 Let /clone move work on overlapping regions 2026-10-26 19:21:53 +01:00
Kacpherek a4ccf205f7 Throw what /give cannot fit from the player's eyes, for that player only 2026-10-26 19:21:09 +01:00
Kacpherek 452fca8bdd Merge origin/main into fix/1938-ghast-dismount 2026-10-26 19:21:07 +01:00
Kacpherek 9dbd0a4baf Make the strafing dragon charge its fireball only with a clear line of sight 2026-10-26 19:19:36 +01:00
Kacpherek 9aabeb39ee Make /tp facing look from the feet, or the eyes under anchored eyes 2026-10-26 19:18:50 +01:00
Kacpherek 605e8a1488 Stop shulkers from targeting and shooting in Peaceful 2026-10-26 19:18:08 +01:00
Kacpherek dfd0601163 Teleport a dragon egg when it is left-clicked 2026-10-26 19:16:52 +01:00
Kacpherek 034af45777 Make adult piglins hunt adult hoglins 2026-10-26 19:15:33 +01:00
Kacpherek 0b3e40f9d4 Give a closed shulker its 20 covered armor 2026-10-26 19:15:28 +01:00
Kacpherek a44581eaf3 Let a shulker bullet's levitation run out on mobs 2026-10-26 19:14:23 +01:00
Kacpherek 886a84dea0 Fly thrown tridents on by themselves between server updates 2026-10-26 19:14:04 +01:00
Kacpherek 79c3896449 Respawn on a free column within the respawn radius around the world spawn 2026-10-26 19:13:58 +01:00
Kacpherek 6823e34451 Let a piglin let go of the gold it admires when a player hits it 2026-10-26 19:12:31 +01:00
Kacpherek f25fbb6f2e Give zombified piglins that spawn in a nether portal a portal cooldown 2026-10-26 19:11:24 +01:00
Kacpherek 872409ea57 Mined ice melts into water unless the tool has silk touch 2026-10-26 19:08:19 +01:00
Kacpherek 81b4c09ee6 Count the endermite lifetime with NoAI set 2026-10-26 19:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 a07c6d57dc horizon-items: item_name reads name_text from horizon-client-core
Main's item_name (d983ef89b) called crate::entity_nbt::name_text, which
the client re-exports from horizon_client_core::component.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:04:00 +01:00
Ludovic MarieandClaude Opus 5.5 3c94b5441e Merge refactor/horizon-command (with main) into refactor/horizon-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:59:46 +01:00
Kacpherek 5b89508fe9 Predict putting out a lit campfire with a shovel on the client
In single player and LAN the client never recognized the shovel on a lit
campfire, so there was no smoke and a shield in the other hand was raised
too. The client now shows the smoke, puts the fire out locally and ends
the click after the hand that doused.
2026-10-26 18:58:36 +01:00
Kacpherek b92dd80566 Let silverfish hide in stone and wake their friends when hurt 2026-10-26 18:55:02 +01:00
Kacpherek 4084bcdf75 Let a brush dust suspicious sand and gravel 2026-10-26 18:54:11 +01:00
Kacpherek 5657080884 Swell a creeper's fuse when it falls 2026-10-26 18:51:45 +01:00
Kacpherek 708702917c Remove an endermite after 2400 ticks 2026-10-26 18:51:16 +01:00
Kacpherek 4c8ff02969 Make skeleton arrow spread and damage follow the difficulty 2026-10-26 18:51:03 +01:00
Kacpherek fe167781c4 Give the fishing rain bonus only where rain falls 2026-10-26 18:50:36 +01:00
Kacpherek 0d02a4c722 Let a caught fish be picked up right away 2026-10-26 18:50:04 +01:00
Kacpherek 9d4c984e03 Speed up an enderman while it has a target 2026-10-26 18:49:31 +01:00
Kacpherek e4cd56ed7b Make Frost Walker freeze water and protect from magma 2026-10-26 18:49:17 +01:00
Kacpherek f2424043f3 Stop zombie drowning conversion when it leaves the water 2026-10-26 18:49:12 +01:00
Kacpherek 45996d8d78 Match the farmer's requested items to vanilla
The farmer's list had food, torchflower seeds and pitcher pods and lacked bone meal. Vanilla requests wheat, wheat seeds, beetroot seeds and bone meal. Food is already picked up and traded through its villager food value.
2026-10-26 18:48:10 +01:00
Kacpherek 472da6509d Projectiles ring bells they hit 2026-10-26 18:45:57 +01:00
Kacpherek b283f2ad31 Hurt mobs that touch a cactus or a sweet berry bush
Mobs ignored both blocks. A mob inside a cactus now takes 1 damage a tick,
and a mob inside a sweet berry bush is slowed (0.8, 0.75, 0.8) and, in a
grown bush, takes 1 damage when it moved at least 0.003 on X or Z. Foxes
and bees are left alone.
2026-10-26 18:45:44 +01:00
Kacpherek 6dcf4b3e8b Use the real local difficulty for the phantom spawn check
The phantom spawn gate assumed a fixed effective difficulty of 2. Use the local difficulty at the player instead, so easy, hard, a long-played chunk and the moon phase change how often phantoms spawn.
2026-10-26 18:45:43 +01:00
Kacpherek bf508c2842 Let mobs, dropped items and projectiles trigger pressure plates and tripwires 2026-10-26 18:45:39 +01:00
Kacpherek d18197a176 Shield: hold it up in first person and show the block pose on other players 2026-10-26 18:14:24 +01:00
Kacpherek c987778e97 Decorated pot: keep every quad and cull back faces like entitySolid 2026-10-26 16:34:24 +01:00
Kacpherek 9841ce7824 Revert "Decorated pot: draw one face of its top and bottom planes"
This reverts commit 408a75aeb3.
2026-10-26 16:27:13 +01:00
Kacpherek 83a6f5c7b9 Revert "Decorated pot: say why only the outer faces stay"
This reverts commit d338bcd070.
2026-10-26 16:27:13 +01:00
Ludovic MarieandClaude Opus 5.5 775e5b19cb horizon-items: tests load prepared items without the client's Assets
#2038's bundle tooltip tests and #2037's loot tests share one helper that
reads the block registry and facts directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:59:37 +01:00
Ludovic Marie f56bf1ba38 Merge remote-tracking branch 'origin/main' into refactor/horizon-items 2026-10-26 15:58:59 +01:00
Ludovic MarieandClaude Opus 5.5 ca119bb069 Merge origin/main into refactor/horizon-items
#2037's loot-enchantment tests stay in horizon-items: they load the
prepared items through the block registry and facts directly instead of
the client's Assets::load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:57:41 +01:00
Kacpherek 3c013dac75 An axe turns an unoxidized copper golem statue back into a golem
The golem stands where the statue was, facing the same way, the axe
loses durability and the statue block is removed.
2026-10-26 15:18:03 +01:00
Kacpherek e87e839c97 Fire the construct_beacon trigger when a beacon activates 2026-10-26 15:17:44 +01:00
Kacpherek 94592a35ac Use the flat world's horizon height for the dark disc and void fog 2026-10-26 15:16:33 +01:00
Kacpherek 581dc08cf5 Fire shot_crossbow and killed_by_arrow advancement triggers 2026-10-26 15:15:43 +01:00
Kacpherek 1b4fe0f067 Wither rose and open eyeblossom give bees an effect instead of breeding them 2026-10-26 15:15:28 +01:00
Kacpherek d2105fe66c Round instant potion effects and measure splash range from the body
Instant health and harm round to the nearest point instead of truncating. A splash potion measures the distance to the entity's box, not its centre, so a potion landing next to a player hits harder and reaches farther.
2026-10-26 15:14:30 +01:00
Kacpherek 20630b1230 Fire the enchanted_item trigger when an enchanting table enchants 2026-10-26 15:14:25 +01:00
Kacpherek 66637220ef Play the step sound of the block above the floor for the player
Powder snow, lily pads, carpets, snow layers and similar blocks now sound like vanilla when walked on: inside blocks replace the floor's step, combination blocks play their own step with the floor's step muffled.
2026-10-26 15:13:58 +01:00
Ludovic MarieandClaude Opus 5.5 27d750f1c9 Split horizon-items out of horizon-client
Second slice of the crate split, a pure move like the first: blocks (+ blocks/), items, item_data, enchant,
cooldowns, bundle and random_sequences move to horizon-items. They are the first layer mobs/ needs, after
horizon-client-core.

Items moved out of lib modules that sit in the big cycle, unchanged:
- firework shapes, explosions and dye colours (particles.rs) -> firework.rs;
- BannerLayer and its NBT form (banner.rs) -> banner_layer.rs;
- the ItemStack NBT codec (save.rs) -> item_nbt.rs;
- consumable_animation (first_person.rs) -> use_animation.rs;
- the content namespaces (mod_content.rs) -> content_namespaces.rs;
- save.rs's NBT value helpers -> horizon-client-core's nbt_value.rs, and name_text (entity_nbt.rs) -> core's
  component.rs.

horizon-client re-exports every module and item under its old path. Test helpers other client tests use
(Items::for_tests, enchant::tests) are behind horizon-items' `test-support` feature, enabled by
horizon-client's dev-dependency. The items test that loads the client's assets moved to the client:
always_edible_and_consume_effects_loaded, to assets.rs. particles.rs' test module moved to the end of the file
(clippy's items_after_test_module had been quiet only thanks to the derives that moved out).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:46:48 +01:00
Ludovic MarieandClaude Opus 5.5 d338bcd070 Decorated pot: say why only the outer faces stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:31 +01:00
Kacpherek 5d72fdd8c2 Add ambient effects for levers, repeaters, dripstone, wither roses, End gateways and lightning rods
These blocks have an animateTick in vanilla but nothing happened around them in Horizon. Each gets the same particles and odds as vanilla.
2026-10-26 13:02:23 +01:00
Kacpherek 946f47b3ec Keep a happy ghast solid and still under a rider who gets off it
On the server thread the window never learned the ghast was on its still
timeout, and its copy trailed the server's, so a rider dropped through the
ghast and the ghast flew off. Sync STAYS_STILL, assume the timeout on
dismount, and put the window's ghast under the landing spot.
2026-10-26 12:48:36 +01:00
Kacpherek 408a75aeb3 Decorated pot: draw one face of its top and bottom planes 2026-10-26 11:45:03 +01:00
326 changed files with 14962 additions and 2479 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
# Trusted reviewers: one list for two things (docs/release-flow.md "Trusted reviewers").
# - `/nightly 2640 2642` picks the pull requests the Nightly holds (tools/ci/nightly_set.py);
# - `/close`, `/reopen`, `/label`, `/unlabel` triage issues and pull requests (tools/ci/triage_command.py).
# Ludovic is always allowed and isn't listed. One GitHub login per line, with who added it and when. Removing a
# line revokes that login at once. A line with only `paused` stops both for every reviewer (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
+21 -4
View File
@@ -19,6 +19,15 @@ on:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
trusted:
# Only release.yml says true: it builds main. A nightly can hold pull requests nobody reviewed yet, whose
# build scripts and tests run here and could rewrite target/, the cargo home or a compiler cache. So an
# untrusted build never runs on the release runner, never saves the Rust cache (its key is its own anyway),
# and a trusted one never reads the machine's shared sccache, which pull requests' jobs write
# (tests/test_nightly.py pins all of it).
description: The source is main (a release); false for anything else (a nightly)
type: boolean
default: false
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
@@ -27,8 +36,10 @@ permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it. An untrusted build
# goes to the Mac mini runner that already runs pull requests' parity jobs (macOS + horizon-game:
# ludovics-mac-mini), never the release runner (MAC_RUNNER: ludovics-mac-mini-release) nor parity-main's.
runs-on: ${{ inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]') }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
@@ -39,6 +50,8 @@ jobs:
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
@@ -90,7 +103,7 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-game"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
@@ -117,7 +130,9 @@ jobs:
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
# A release restores only what a release saved: an untrusted build has its own key and saves nothing.
shared-key: ${{ inputs.trusted && 'release' || 'nightly' }}-${{ matrix.name }}
save-if: ${{ inputs.trusted }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
@@ -173,6 +188,8 @@ jobs:
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
env:
HORIZON_NO_SCCACHE: ${{ inputs.trusted && '1' || '' }}
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
+77 -6
View File
@@ -1,13 +1,23 @@
name: nightly build
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
run-name: nightly build${{ inputs.request && format(' (request {0})', inputs.request) || '' }}
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night, and when a trusted
# reviewer asks (`/nightly 2640 2642`, nightly-command.yml), it builds main plus the pull requests the reviewers
# picked, merged in order (tools/ci/nightly_set.py). With none picked, it builds the newest union branch on origin
# (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or main when no union is
# newer. It publishes the result as the `nightly` pre-release. Launchers install it only for players who turn Nightly
# on. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the union's code:
# Why the signing key never meets the pull requests' code:
# - `pick` merges the picked heads onto main with `git merge-tree --write-tree`: git objects only, no working tree,
# so none of their files reaches the disk and no hook, filter or script of theirs runs. It refuses any pull
# request that changes a path CODEOWNERS gives to Ludovic (this folder, tools/ci/, the signing and update code),
# pushes the merge as nightly/build-<date>-<n> and saves the set on the nightly-state branch.
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
# - `build` (game-build.yml, `trusted: false`) never reaches a release's tools: its macOS jobs run on the runner
# that already runs pull requests' parity jobs, never the release runner; its Rust cache has its own key and is
# never saved; and releases don't read the machine's shared sccache.
# - `build` (game-build.yml) checks out and compiles the merge (or the union) with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
@@ -31,18 +41,37 @@ on:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
issue:
description: The issue or pull request whose `/nightly` comment asked for this build (answered there)
type: string
default: ''
request:
description: The command's id, in the run's name so it can link the run
type: string
default: ''
prs:
description: A dry run's own set, `2640@<head> 2642@<head>` (the saved set stays)
type: string
default: ''
permissions:
contents: read
concurrency:
# One build at a time. A newer request replaces one still waiting: it builds the set as it is by then, so no pick
# is lost.
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # nightly/build-<date>-<n> and the nightly-state branch (main is protected)
issues: write # says in the thread when the picked pull requests conflict
pull-requests: write
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
short: ${{ steps.pick.outputs.short }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
@@ -60,9 +89,20 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
DRY_RUN: ${{ steps.mode.outputs.dry_run }}
NIGHTLY_ISSUE: ${{ inputs.issue }}
NIGHTLY_PRS: ${{ inputs.prs }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- name: Old nightly/build-* branches deleted (scheduled runs only)
# Ludovic, 2026-10-27: older than 7 days, but never the newest build or one a published nightly points to;
# nothing but nightly/build-* (nightly.py stale_builds, tests/test_nightly.py).
if: github.event_name == 'schedule'
env:
GH_TOKEN: ${{ github.token }}
run: |
{ echo "### nightly/build-* branches"; python3 tools/ci/nightly.py prune-builds --days 7; } | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
@@ -78,6 +118,7 @@ jobs:
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
trusted: false # it can hold unreviewed pull requests: their own cache key, never saved, not the release runner
sign:
needs: [pick, build]
@@ -164,3 +205,33 @@ jobs:
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
answer:
# Tells the thread that asked for this build how it ended. A conflict `pick` already explained there.
needs: [pick, build, sign, publish]
if: always() && inputs.issue != '' && needs.pick.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ inputs.issue }}
BUILD: ${{ needs.pick.outputs.build }}
PUBLISHED: ${{ needs.publish.result }}
DRY_RUN: ${{ needs.pick.outputs.dry_run }}
NAME: Nightly ${{ needs.pick.outputs.date }} · ${{ needs.pick.outputs.short }}
RUN: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [[ "$BUILD" != true ]]; then
text="nothing new: the published Nightly is already this build."
elif [[ "$PUBLISHED" == success && "$DRY_RUN" == true ]]; then
text="the dry run built and signed $NAME; nothing was published. $RUN"
elif [[ "$PUBLISHED" == success ]]; then
text="published $NAME: https://github.com/$GITHUB_REPOSITORY/releases/tag/nightly. Launchers with Nightly on install it at their next start."
else
text="the build failed, nothing was published: $RUN"
fi
gh api "repos/$GITHUB_REPOSITORY/issues/$ISSUE/comments" -f body="🤖 Nightly: $text"
+46
View File
@@ -0,0 +1,46 @@
name: nightly command
# `/nightly …` comments: the trusted reviewers in .github/trusted-reviewers.txt (and Ludovic) pick the pull requests
# the Nightly holds (docs/release-flow.md "Nightly channel", tools/ci/nightly_set.py). This job only checks who asked,
# saves the set on the nightly-state branch, starts `nightly build` and answers in the thread.
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever; a pull request's own copy of it
# is never read.
# - It checks out main and runs main's nightly_set.py. The comment's text and author reach it as env vars, never in
# a shell line.
# - It never checks out, fetches or merges a pull request: it reads their file lists through the API. The merge
# happens in nightly-build.yml's `pick`, with git objects only; their code runs in its `build`, without secrets.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: startsWith(github.event.comment.body, '/nightly') && github.event.comment.user.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write # the nightly-state branch (main is protected)
actions: write # starts `nightly build`
issues: write # answers in the thread
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/CODEOWNERS
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/nightly_set.py command
+1
View File
@@ -90,6 +90,7 @@ jobs:
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
trusted: true # main: the release runner, the release cache, no shared sccache
sign:
needs: [decide, build]
+48
View File
@@ -0,0 +1,48 @@
name: triage command
# `/close`, `/reopen`, `/label`, `/unlabel` comments: the trusted reviewers in .github/trusted-reviewers.txt (and
# Ludovic) triage issues and pull requests without any repository role (docs/release-flow.md "Trusted reviewers",
# tools/ci/triage_command.py).
#
# Why no pull request's code runs here, though the token can write:
# - `issue_comment` always runs this file from main, whoever comments and wherever.
# - It checks out main's script and list only, never a pull request; it reads threads through the API.
# - The comment's text and author reach the script as env vars, never in a shell line. The token stays in the step.
on:
issue_comment:
types: [created]
permissions:
contents: read
jobs:
command:
# Every other comment skips here, before a runner starts.
if: >-
github.event.comment.user.type != 'Bot' && (
startsWith(github.event.comment.body, '/close') ||
startsWith(github.event.comment.body, '/reopen') ||
startsWith(github.event.comment.body, '/label') ||
startsWith(github.event.comment.body, '/unlabel'))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: main
persist-credentials: false
sparse-checkout: |
.github/trusted-reviewers.txt
tools/ci/nightly_set.py
tools/ci/triage_command.py
sparse-checkout-cone-mode: false
- name: Run the command
env:
GH_TOKEN: ${{ github.token }}
BODY: ${{ github.event.comment.body }}
AUTHOR: ${{ github.event.comment.user.login }}
ISSUE: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
COMMENT_URL: ${{ github.event.comment.html_url }}
run: python3 tools/ci/triage_command.py command
+4 -3
View File
@@ -35,7 +35,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~170 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
@@ -57,7 +58,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private horizon-rust/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
@@ -120,7 +121,7 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`; crate `horizon-items`: `items.rs`, `item_data.rs`, `enchant.rs`, `blocks.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
+1 -1
View File
@@ -98,5 +98,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/ludovic111/horizon/issues)
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. If you want to take something on, say so in its issue so nobody else starts the same work.
Generated
+12
View File
@@ -1385,6 +1385,7 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1469,6 +1470,17 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-items"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-core",
"horizon-world",
"serde",
"serde_json",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
+1
View File
@@ -9,6 +9,7 @@ members = [
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
+7 -7
View File
@@ -3,7 +3,7 @@
</p>
<p align="center">
<a href="https://github.com/ludovic111/horizon/actions/workflows/ci.yml"><img src="https://github.com/ludovic111/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<a href="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml"><img src="https://github.com/horizon-rust/horizon/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>
<img src="https://img.shields.io/badge/Minecraft-Java%2026.3-5D9C3F" alt="Minecraft Java 26.3">
<img src="https://img.shields.io/badge/Rust-stable-B7410E?logo=rust&logoColor=white" alt="Rust">
<img src="https://img.shields.io/badge/vanilla%20parity-100%25-F0795A" alt="Vanilla parity 100%">
@@ -15,7 +15,7 @@
<a href="https://playhorizon.dev"><b>Website</b></a> ·
<a href="#download"><b>Download</b></a> ·
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
<a href="https://github.com/horizon-rust/horizon/releases"><b>Release notes</b></a> ·
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
<a href="#project-status"><b>Project status</b></a> ·
<a href="#contributing"><b>Contributing</b></a>
@@ -29,9 +29,9 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/ludovic111/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs and updates the game, prepares its files from your own copy of Minecraft, and updates itself.
**You need** Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)); Minecraft: Java Edition 26.3, played once (the official launcher, Prism, MultiMC and other launchers all work); a Java 25 runtime, for the one-time preparation; and a GPU with Vulkan on Linux and Windows, or any Apple Silicon Mac. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
@@ -47,7 +47,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
@@ -99,7 +99,7 @@ Horizon contains no game files and never downloads them. It builds its resources
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
```sh
git clone https://github.com/ludovic111/horizon.git
git clone https://github.com/horizon-rust/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
@@ -158,7 +158,7 @@ them too (Rust uses Java's own float maths on each platform).
### Known issues
Horizon can't sign in to a Microsoft account yet, so it can't join online-mode servers (it joins offline-mode ones); skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
## Licence
+66 -66
View File
@@ -32,7 +32,7 @@ Making Horizon faster than vanilla on every computer, with smooth camera movemen
<details><summary>Technical</summary>
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
Frame pacing at render distance 32 ([#678](https://github.com/horizon-rust/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/horizon-rust/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/horizon-rust/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/horizon-rust/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/horizon-rust/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/horizon-rust/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -42,7 +42,7 @@ Speeding up the code that reads and writes the blocks of the world, which almost
<details><summary>Technical</summary>
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/horizon-rust/horizon/issues/1790), [#1794](https://github.com/horizon-rust/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/horizon-rust/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
</details>
@@ -54,7 +54,7 @@ Making the world-generation mods of popular modpacks (new structures, YUNG's dun
<details><summary>Technical</summary>
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/horizon-rust/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/horizon-rust/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/horizon-rust/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/horizon-rust/horizon/issues/1753), [#1757](https://github.com/horizon-rust/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -66,7 +66,7 @@ When you play on a server, other players, mobs, items and menus look and behave
<details><summary>Technical</summary>
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/horizon-rust/horizon/issues/1802), [#1798](https://github.com/horizon-rust/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/horizon-rust/horizon/issues/1795)), skins ([#1652](https://github.com/horizon-rust/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/horizon-rust/horizon/issues/1650)), firework boosts ([#1671](https://github.com/horizon-rust/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/horizon-rust/horizon/issues/1731)), styled item names ([#1702](https://github.com/horizon-rust/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/horizon-rust/horizon/issues/1710)).
</details>
@@ -76,7 +76,7 @@ Servers that show dialogs, such as the register and login screens of AuthMe, wil
<details><summary>Technical</summary>
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
26.3's dialog packets and model ([#1720](https://github.com/horizon-rust/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/horizon-rust/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/horizon-rust/horizon/issues/1735)).
</details>
@@ -86,7 +86,7 @@ A /pregen command that builds an area of the world ahead of time, so players don
<details><summary>Technical</summary>
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/ludovic111/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
Generates, lights and saves an area before anyone walks there ([#1805](https://github.com/horizon-rust/horizon/issues/1805), a contributor's pull request, in review). Useful on servers, and it moves the generation cost away from the first visit; the generation speed-ups are under 'Steady frames at long render distances'.
</details>
@@ -98,7 +98,7 @@ One place for mods, modpacks and resource packs made for Horizon: browse them on
<details><summary>Technical</summary>
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/horizon-rust/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/horizon-rust/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/horizon-rust/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/horizon-rust/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/horizon-rust/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/horizon-rust/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/horizon-rust/horizon/issues/1637)), the Mods page ([#1638](https://github.com/horizon-rust/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/horizon-rust/horizon/issues/1640)).
More: [docs/mods.md](docs/mods.md)
@@ -110,7 +110,7 @@ Mods that run on the server side of a world, so a dedicated server or a LAN worl
<details><summary>Technical</summary>
API 0.3, experimental first ([#1833](https://github.com/ludovic111/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
API 0.3, experimental first ([#1833](https://github.com/horizon-rust/horizon/issues/1833), in review): the same sandboxed component runs on the server thread and the dedicated server, with world events (block broken or placed, entity spawned or killed, player joined or left), game rules and server commands with typed arguments. The world, item and entity interfaces come after (see 'Mods that read and change the world'). [docs/api/mods.md](docs/api/mods.md), [docs/mods.md](docs/mods.md).
</details>
@@ -120,7 +120,7 @@ Mods will be able to open their own screens and settings pages and react to keys
<details><summary>Technical</summary>
Capability `ui` is in review ([#1864](https://github.com/ludovic111/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
Capability `ui` is in review ([#1864](https://github.com/horizon-rust/horizon/issues/1864)): mod-owned screens and a settings page under Options > Mods, built from vanilla's widgets. Still to design: `input` (an item's use, key binds, the crosshair target), `render` (a budgeted draw list on the native shader API) and `world` (changing blocks). Orbital Laser is the acceptance test: it needs all four today through its author's fork (see 'Orbital Laser on the normal game').
More: [docs/mods.md](docs/mods.md)
@@ -132,7 +132,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
<details><summary>Technical</summary>
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/horizon-rust/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/horizon-rust/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/horizon-rust/horizon/issues/1859), [#1810](https://github.com/horizon-rust/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
@@ -146,7 +146,7 @@ Single player now runs a small server in the background, as vanilla does. We are
<details><summary>Technical</summary>
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
Since [#1514](https://github.com/horizon-rust/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/horizon-rust/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/horizon-rust/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/horizon-rust/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
</details>
@@ -156,7 +156,7 @@ Item names, container titles and server messages follow the language you pick, a
<details><summary>Technical</summary>
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/horizon-rust/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/horizon-rust/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/horizon-rust/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/horizon-rust/horizon/issues/1774)).
</details>
@@ -166,9 +166,9 @@ After you throw an ender pearl, its slot should show the cooldown sweeping away,
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/horizon-rust/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
More: [#1933](https://github.com/horizon-rust/horizon/issues/1933)
</details>
@@ -178,9 +178,9 @@ The Hidden and Commands Only chat settings should hide chat, and team prefixes s
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/horizon-rust/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/horizon-rust/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
More: [#1932](https://github.com/horizon-rust/horizon/issues/1932) · [#1841](https://github.com/horizon-rust/horizon/issues/1841)
</details>
@@ -190,9 +190,9 @@ The mouse pointer should turn into a pointing hand over menu buttons, as in vani
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/horizon-rust/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
More: [#1897](https://github.com/horizon-rust/horizon/issues/1897)
</details>
@@ -202,9 +202,9 @@ Ctrl+Backspace should delete a whole word in the creative search, and every list
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/horizon-rust/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/horizon-rust/horizon/issues/1782), after [#978](https://github.com/horizon-rust/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
More: [#1884](https://github.com/horizon-rust/horizon/issues/1884) · [#1931](https://github.com/horizon-rust/horizon/issues/1931)
</details>
@@ -214,9 +214,9 @@ Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/horizon-rust/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
More: [#1892](https://github.com/horizon-rust/horizon/issues/1892)
</details>
@@ -228,9 +228,9 @@ Shader packs were removed and are being rebuilt from scratch. They come back, of
<details><summary>Technical</summary>
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/horizon-rust/horizon/issues/1584), [#1585](https://github.com/horizon-rust/horizon/issues/1585), [#1586](https://github.com/horizon-rust/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/horizon-rust/horizon/issues/1698) to [#1801](https://github.com/horizon-rust/horizon/issues/1801)).
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
More: [#1561](https://github.com/horizon-rust/horizon/issues/1561) · [#1771](https://github.com/horizon-rust/horizon/issues/1771) · [#1786](https://github.com/horizon-rust/horizon/issues/1786)
</details>
@@ -240,7 +240,7 @@ Horizon's own way to make shader packs, with tools to write and test them. Every
<details><summary>Technical</summary>
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/ludovic111/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/ludovic111/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/ludovic111/horizon/issues/1861)), the sky stage ([#1863](https://github.com/ludovic111/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/ludovic111/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
`horizon-shader-api` (API 0.1, experimental): WGSL stages with typed inputs inside the renderer's passes, options as WGSL override constants, and the `horizon-shader new|check|pack` tool with templates and the example pack Golden Hour. Merged: the check every pack passes ([#1820](https://github.com/horizon-rust/horizon/issues/1820)) and the tool, templates and guide ([#1822](https://github.com/horizon-rust/horizon/issues/1822)). In review: the post stage running in the game with hot reload ([#1861](https://github.com/horizon-rust/horizon/issues/1861)), the sky stage ([#1863](https://github.com/horizon-rust/horizon/issues/1863)) and registry checks that use the game's own check ([#1855](https://github.com/horizon-rust/horizon/issues/1855)). Left: the material, lighting and shadow stages, a last-good fallback on reload, a GPU-time overlay per stage and budgets ([docs/shaders.md](docs/shaders.md), [docs/api/shaders.md](docs/api/shaders.md)). The Iris/OptiFine loader is a separate item, 'Shader packs, written again from scratch'.
</details>
@@ -252,7 +252,7 @@ Your Discord profile can show that you are playing Horizon, with a switch to tur
<details><summary>Technical</summary>
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
A Discord IPC client on its own thread ([#1787](https://github.com/horizon-rust/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/horizon-rust/horizon/issues/1788)). Merged: it ships with the next release.
</details>
@@ -264,7 +264,7 @@ Cleaning up Horizon's code: removing what nothing uses and making it quicker to
<details><summary>Technical</summary>
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/horizon-rust/horizon/issues/1432), [#1658](https://github.com/horizon-rust/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/horizon-rust/horizon/issues/1453), [#1454](https://github.com/horizon-rust/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/horizon-rust/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/horizon-rust/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
</details>
@@ -278,7 +278,7 @@ New public numbers comparing Horizon with vanilla on many kinds of computers, me
<details><summary>Technical</summary>
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
Redo the comparison after the current performance work ([#1073](https://github.com/horizon-rust/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/horizon-rust/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/horizon-rust/horizon/issues/1749)). Next: a performance history across releases (an automatic comparison of each release with the last, on a timeline), more report fields (server TPS and MSPT, power, battery drain, temperature, throttling) and more hardware (a MacBook on battery, low-end laptops, community reports). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
</details>
@@ -288,7 +288,7 @@ Creating a world and joining it should be quick even on a four-thread laptop, wh
<details><summary>Technical</summary>
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/ludovic111/horizon/issues/1760) and [#1778](https://github.com/ludovic111/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
Cut the per-column generation cost (noise sampling, surface rules, aquifers) and the lock convoy on structure starts, and leave one core free for the rest of the game as vanilla does ([#1760](https://github.com/horizon-rust/horizon/issues/1760) and [#1778](https://github.com/horizon-rust/horizon/issues/1778), in review, are listed under 'Steady frames at long render distances'). Still to profile on small CPUs: the first seconds of a fresh world deliver no chunk, and fluids remesh for about a minute after loading. Measured with `tools/perf_bench.py` ([docs/benchmark.md](docs/benchmark.md), [docs/profiling.md](docs/profiling.md)).
</details>
@@ -320,7 +320,7 @@ Draw the game at a lower resolution and sharpen it to your screen's size, in the
<details><summary>Technical</summary>
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/ludovic111/horizon/issues/1403).
A render-scale setting below the window size with an upscaler of our own that runs on every wgpu backend. Proof: frame time against native resolution on integrated GPUs, and screenshots for image quality. Issue [#1403](https://github.com/horizon-rust/horizon/issues/1403).
</details>
@@ -330,7 +330,7 @@ Several smaller speed-ups, each measured: idle block entities that sleep, cheape
<details><summary>Technical</summary>
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/ludovic111/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
Block entities and hoppers that skip work when idle (the hopper item scan is already skipped when no item is near); a particle frustum test first (also a parity gap), then particle quads built on the GPU; jigsaw shortcuts that skip blocked connectors and pool elements known not to fit, with layouts unchanged (trial chambers and ancient cities first); batched raw mouse input on Windows for 8000 Hz mice; a better atlas packer for huge resource packs (a 64x pack builds a 704 MiB atlas for 452 MiB of textures); sign-heavy scenes ([#1461](https://github.com/horizon-rust/horizon/issues/1461)). Profile first, keep what pays, show before and after numbers.
More: [docs/profiling.md](docs/profiling.md)
@@ -364,7 +364,7 @@ Making mods that add machines, magic and mobs work in Horizon, in steps, startin
<details><summary>Technical</summary>
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/ludovic111/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/ludovic111/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
333 of the 496 mods in a big pack (ATM10) contain code. Issue [#1854](https://github.com/horizon-rust/horizon/issues/1854) ranks the API gaps (ticking block entities, networking, rendering, menus, inventories) that unlock them in tiers, with machines first as the recommended path and a furnace ported tick for tick as proof. The first API step is a draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189), see 'Machines and blocks with inventories'). The path isn't started yet ([docs/modpacks.md](docs/modpacks.md)).
</details>
@@ -386,7 +386,7 @@ Set up Horizon, a server first, without Java on your computer: published tables
<details><summary>Technical</summary>
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/ludovic111/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
Today `horizon-prepare` runs Mojang's exporters with Java. Issue [#1750](https://github.com/horizon-rust/horizon/issues/1750) proposes publishing the computed version tables once from CI and assembling the rest locally from the player's own jar and assets, provided the tables hold no Mojang content; a contributor offered a pull request, and it would help phones too. A server-only prepare from `server.jar` alone is the first step ([docs/server.md](docs/server.md)).
</details>
@@ -406,7 +406,7 @@ Smoother hosting: a real Windows service, a backup before every update, backups
<details><summary>Technical</summary>
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/ludovic111/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
A Windows service wrapper instead of the logon scheduled task; `backups.before_update`; hot reload of config for view distance and the message of the day (needs engine support); backups that keep world and player state ([#1808](https://github.com/horizon-rust/horizon/issues/1808), a contributor's draft, in review). [docs/hosting.md](docs/hosting.md), docs/server.md.
</details>
@@ -464,7 +464,7 @@ Mods that add machines: blocks that tick, hold items and have their own menus. T
<details><summary>Technical</summary>
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/ludovic111/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
Block entities and menus: ticking machines with slots, server menu logic and the menu protocol to clients, energy, item and fluid transfer, data attachments, accessory slots and custom recipe types, with a typed client and server channel and typed config beside them. A draft ([#1189](https://github.com/horizon-rust/horizon/issues/1189)) proves a furnace ported tick for tick and holds the ATM10 code-mods plan; the design is redone through the API policy before it merges ([docs/modpacks.md](docs/modpacks.md), docs/api/stability.md).
</details>
@@ -486,7 +486,7 @@ Pistons and slime blocks will push players, mobs and items as in vanilla. Today
<details><summary>Technical</summary>
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/horizon-rust/horizon/issues/1496), [#1609](https://github.com/horizon-rust/horizon/issues/1609), [#1608](https://github.com/horizon-rust/horizon/issues/1608), [#1704](https://github.com/horizon-rust/horizon/issues/1704), [#1758](https://github.com/horizon-rust/horizon/issues/1758).
</details>
@@ -506,7 +506,7 @@ Villages and fights behaving as in vanilla: iron golems defending villages, vill
<details><summary>Technical</summary>
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/ludovic111/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
Villages: iron golem village goals, villager memories across saves, villager age, job-site claiming on dedicated servers, flower offers ([#1639](https://github.com/horizon-rust/horizon/issues/1639) is one report). Combat: the mobs' `attack_knockback` attribute, mace smash logic, the hit knockback source on the host, the fire burnout biome tag. Each is checked against vanilla by a harness ([docs/rust-parity.md](docs/rust-parity.md)).
</details>
@@ -516,7 +516,7 @@ Pick the speakers or headphones Horizon plays through, in Music & Sounds.
<details><summary>Technical</summary>
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/ludovic111/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
An output device list in Music & Sounds, falling back to the system default. [#1035](https://github.com/horizon-rust/horizon/issues/1035) started it: a player on an older Windows computer had no sound at all, and a device choice is how such cases are found and fixed.
</details>
@@ -544,7 +544,7 @@ Coming soon: sign in with your Microsoft account to play on online-mode servers.
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
More: [#1270](https://github.com/horizon-rust/horizon/issues/1270)
</details>
@@ -566,7 +566,7 @@ Builds of the game and the launcher for Macs with an Intel processor.
<details><summary>Technical</summary>
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/horizon-rust/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/horizon-rust/horizon/issues/1161), [#1792](https://github.com/horizon-rust/horizon/issues/1792).
</details>
@@ -576,7 +576,7 @@ Fixes for Linux desktops that use Wayland and Hyprland: fullscreen, the clipboar
<details><summary>Technical</summary>
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/ludovic111/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/ludovic111/horizon/issues/1007), closed). Checked on a real Wayland session.
Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayland keeps it ([#1866](https://github.com/horizon-rust/horizon/issues/1866)). Earlier reports covered fullscreen dropping on menu clicks, the clipboard and a white overlay ([#1007](https://github.com/horizon-rust/horizon/issues/1007), closed). Checked on a real Wayland session.
</details>
@@ -626,7 +626,7 @@ Safer releases: changes to the release machinery need the owner's review, and AI
<details><summary>Technical</summary>
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/ludovic111/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
`.github/CODEOWNERS` lists the workflows, the release and signing tools, the update-checking code and the installers ([#1015](https://github.com/horizon-rust/horizon/issues/1015)); the branch protection that makes it count waits for the `horizon-agent` GitHub App, which reads and writes code, pull requests and issues but can't change settings, workflows, secrets or members, and whose tokens last an hour. It needs the owner to create the App and switch the protection on ([docs/security.md](docs/security.md)).
</details>
@@ -636,7 +636,7 @@ Every download Horizon makes is checked against the project's signature, includi
<details><summary>Technical</summary>
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/ludovic111/horizon/issues/1011), [#1014](https://github.com/ludovic111/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
Releases carry signed checksums, and the launcher and `horizon-server update` refuse anything unsigned ([#1011](https://github.com/horizon-rust/horizon/issues/1011), [#1014](https://github.com/horizon-rust/horizon/issues/1014)). Left: the first install through `install.sh` and `install.ps1` still trusts only the checksum GitHub publishes, because verifying Ed25519 signatures needs OpenSSL 3, which macOS doesn't ship ([docs/security.md](docs/security.md)).
</details>
@@ -650,7 +650,7 @@ Far terrain drawn from the world you really built and explored, caves and builds
<details><summary>Technical</summary>
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/ludovic111/horizon/issues/718).
Keep a 3D mip store on disk built from real chunks, drawn by the GPU beside the seed preview for areas nobody has visited. Written from scratch and measured before it is kept. Builds on Far Detail and the far-terrain levels of detail already shipped; the open work on them is [#718](https://github.com/horizon-rust/horizon/issues/718).
</details>
@@ -692,7 +692,7 @@ Run Java mods that nobody has rewritten for Horizon yet, through a bridge or a c
<details><summary>Technical</summary>
Earlier research ([#527](https://github.com/ludovic111/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
Earlier research ([#527](https://github.com/horizon-rust/horizon/issues/527), closed) found that Java mods can't simply run, because they call Minecraft's own classes. Options to look at again: a jar-to-WebAssembly backend, or a bridge for simple Fabric mods. Whatever is chosen has to run inside the same sandbox and capability checks as native mods.
</details>
@@ -704,7 +704,7 @@ Sign in with other account providers, such as Ely.by or any Yggdrasil-compatible
<details><summary>Technical</summary>
Issue [#1270](https://github.com/ludovic111/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
Issue [#1270](https://github.com/horizon-rust/horizon/issues/1270) asks for Ely.by and Yggdrasil-compatible servers with an account selector in the launcher. It builds on the Microsoft sign-in ([docs/microsoft-account.md](docs/microsoft-account.md)).
</details>
@@ -748,7 +748,7 @@ Contraptions and physics like the Create and Aeronautics mods, as a mod for Hori
<details><summary>Technical</summary>
Issue [#805](https://github.com/ludovic111/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
Issue [#805](https://github.com/horizon-rust/horizon/issues/805) (porting QOL mods) closed with this answer: it would come as a native Horizon mod on the mod API. It needs server mods, entities and rendering first.
</details>
@@ -790,7 +790,7 @@ Vanilla's complete F3 screen: debug options, a profiler chart, ten-second captur
<details><summary>Technical</summary>
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/ludovic111/horizon/issues/562) asked for the F3 editor).
Today F3 shows position, chunk, light, biome and FPS ([docs/native-client.md](docs/native-client.md)). Vanilla also has F3+F6 debug options with profiles, F3+1 the profiler pie, F3+L a ten-second capture and F3+S the atlas dump ([#562](https://github.com/horizon-rust/horizon/issues/562) asked for the F3 editor).
</details>
@@ -814,7 +814,7 @@ An optional ray-tracing mode for graphics cards that support it (RTX and recent
<details><summary>Technical</summary>
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/ludovic111/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
A spike with ray-traced sun shadows, off by default, is a draft ([#1180](https://github.com/horizon-rust/horizon/issues/1180)). Reflections, global illumination, Apple M3 and newer if it is cheap, and a performance report per card come after.
</details>
@@ -824,7 +824,7 @@ Output for HDR monitors: brighter highlights and richer colour where your screen
<details><summary>Technical</summary>
Issue [#1441](https://github.com/ludovic111/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
Issue [#1441](https://github.com/horizon-rust/horizon/issues/1441). Needs a surface format and a tone-mapping path per backend, and tests on real HDR screens on Windows, macOS and Linux.
</details>
@@ -858,7 +858,7 @@ Game and launcher builds for 64-bit ARM Linux computers.
<details><summary>Technical</summary>
Issue [#1709](https://github.com/ludovic111/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
Issue [#1709](https://github.com/horizon-rust/horizon/issues/1709): aarch64 builds of the game and the launcher in the release workflow, tested on a real ARM machine with its Vulkan driver. The server build already drops audio and controllers (`--no-default-features`).
</details>
@@ -868,7 +868,7 @@ Versions of Horizon for Android, iOS and web browsers. Nothing official yet; con
<details><summary>Technical</summary>
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/ludovic111/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/ludovic111/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/ludovic111/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
No official build. Android was first closed as not planned, with touch controls, a mobile interface, packaging and loading the player's own assets all missing; the roadmap now includes it, iOS and a WebAssembly build for the browser as long-term goals. First pieces from contributors: `horizon-dualsense` compiling on iOS ([#1813](https://github.com/horizon-rust/horizon/issues/1813)) and helper-thread limits on Android ([#1751](https://github.com/horizon-rust/horizon/issues/1751)). Preparing without Java ([#1750](https://github.com/horizon-rust/horizon/issues/1750)) matters here too. Horizon ships none of Mojang's files on any platform.
</details>
@@ -912,7 +912,7 @@ An optional AI teammate you can talk to by voice. It plays beside you, helps or
<details><summary>Technical</summary>
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
Draft pull requests: the companion ([#1068](https://github.com/horizon-rust/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/horizon-rust/horizon/issues/1069)) and a route that finishes the game ([#1070](https://github.com/horizon-rust/horizon/issues/1070)). In the drafts: a companion that follows, fights, mines, crafts, sleeps and respawns; chat and push-to-talk voice, with speech recognition and a choice of voices; presets (very strong, helper, beat the game); a guard model with Clean, Kid and Salty modes; perception levels from Fair to All-knowing; hardware detection with a simple mode and an Advanced tab; local models first, cloud keys optional. The No AI switch is already in the game. Left: the speedrun route doesn't finish the game reliably yet, voices for more languages, and the safety evaluation, which must pass before it ships.
</details>
@@ -938,7 +938,7 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/horizon-rust/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
@@ -952,7 +952,7 @@ Banners in End cities show as plain magenta instead of with their pattern.
<details><summary>Technical</summary>
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/horizon-rust/horizon/issues/1438); possibly related: [#526](https://github.com/horizon-rust/horizon/issues/526).
</details>
@@ -962,19 +962,19 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
<details><summary>Technical</summary>
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/horizon-rust/horizon/issues/1437), [#1660](https://github.com/horizon-rust/horizon/issues/1660).
</details>
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/horizon-rust/horizon/issues/1917) · [#1920](https://github.com/horizon-rust/horizon/issues/1920) · [#1936](https://github.com/horizon-rust/horizon/issues/1936)
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/horizon-rust/horizon/issues/1562) · [#1569](https://github.com/horizon-rust/horizon/issues/1569) · [#1570](https://github.com/horizon-rust/horizon/issues/1570)
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/horizon-rust/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/horizon-rust/horizon/issues/1434)
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/horizon-rust/horizon/issues/1239)
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/horizon-rust/horizon/issues/1199) · [#1029](https://github.com/horizon-rust/horizon/issues/1029)
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/horizon-rust/horizon/issues/1154) · [#1242](https://github.com/horizon-rust/horizon/issues/1242) · [#1243](https://github.com/horizon-rust/horizon/issues/1243) · [#1246](https://github.com/horizon-rust/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/horizon-rust/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/horizon-rust/horizon/issues/1162)
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/horizon-rust/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
@@ -119,7 +119,7 @@ machines during the runs.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
@@ -148,7 +148,7 @@ early, wrote no report. Its lines come from its output.
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -103,7 +103,7 @@ the Nix store, so the run set `LD_LIBRARY_PATH` to them (see #898).
## Reproduce
curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/packaging/server/install.sh | sh
curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh
horizon-prepare --prepare --server-jar /path/to/server.jar # your own copy
cargo build --release -p horizon-bots # on the machine that runs the bots
# in a checkout whose .local/minecraft holds the pinned vanilla jars (tools/lab.py prepare):
+1 -1
View File
@@ -25,7 +25,7 @@ use std::{
};
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
const SDK_GIT: &str = "{ git = \"https://github.com/horizon-rust/horizon\" }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
+2 -2
View File
@@ -1,5 +1,5 @@
//! `cargo horizon-mod registry build|review|check|list`: the registry repository's CI
//! (`ludovic111/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! (`horizon-rust/horizon-mods`, docs/api/mod-index.md). Network-free: the
//! workflow downloads the files and publishes what this writes.
use horizon_mods::index::{
FILE_HOST,
@@ -8,7 +8,7 @@ use horizon_mods::index::{
use std::path::Path;
/// Where the registry's site is published (its icons).
const SITE: &str = "https://ludovic111.github.io/horizon-mods/";
const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
+28 -6
View File
@@ -1,6 +1,7 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
/// `SnbtGrammar`, enough for components: compounds, lists, quoted and
@@ -190,19 +191,31 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
_ => None,
};
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
style.insertion = o
.get("insertion")
.and_then(Value::as_str)
.map(str::to_owned);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -287,6 +300,15 @@ pub fn to_text_with(
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -14,6 +14,7 @@ pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod nbt_value;
pub mod particle_type;
pub mod pattern;
pub mod potions;
@@ -23,4 +24,5 @@ pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub mod who;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,24 @@
//! Reading and writing single NBT values the way vanilla's codecs accept them.
use horizon_world::nbt::Tag;
pub fn string(s: &str) -> Tag {
Tag::String(s.into())
}
pub fn text(tag: Option<&Tag>) -> Option<String> {
match tag {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
}
}
/// Any numeric tag as i64 (vanilla's `getIntOr` etc. accept every number type).
pub fn number(tag: Option<&Tag>) -> Option<i64> {
Some(match tag? {
Tag::Byte(v) => i64::from(*v),
Tag::Short(v) => i64::from(*v),
Tag::Int(v) => i64::from(*v),
Tag::Long(v) => *v,
Tag::Float(b) => f32::from_bits(*b) as i64,
Tag::Double(b) => f64::from_bits(*b) as i64,
_ => return None,
})
}
+24 -1
View File
@@ -47,6 +47,8 @@ pub enum ClickEvent {
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
ChangePage(i32),
SuggestCommand(String),
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
@@ -65,6 +67,10 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
@@ -94,6 +100,12 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
@@ -499,7 +511,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 9] = [
const STYLE_KEYS: [&str; 10] = [
"color",
"bold",
"italic",
@@ -508,6 +520,7 @@ const STYLE_KEYS: [&str; 9] = [
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
@@ -541,6 +554,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::CopyToClipboard(value) => {
json!({ "action": "copy_to_clipboard", "value": value })
}
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -550,6 +566,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"hover_event".to_owned(),
json!({ "action": "show_text", "value": hover.component() }),
));
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
+8
View File
@@ -0,0 +1,8 @@
//! Who an action came from or is aimed at: the player or a mob by id.
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
+2
View File
@@ -11,6 +11,7 @@ description = "Horizon native development client"
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -105,5 +106,6 @@ path = "examples/probe/main.rs"
workspace = true
[dev-dependencies]
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
@@ -2,7 +2,7 @@
//! Build with `cargo build --release -p horizon-client --example gameplay_decode_profile`.
//! Each invocation runs one phase in a separate process for CPU/RSS measurements.
#[allow(dead_code)]
#[path = "../src/blocks/decode.rs"]
#[path = "../../horizon-items/src/blocks/decode.rs"]
mod decode;
use std::{hint::black_box, path::Path, time::Instant};
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, |p, v| {
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
lines.push(line(p, v))
});
}
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
..HealthHud::default()
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -74,7 +74,14 @@ pub fn main() -> Result<()> {
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -567,7 +567,13 @@ fn print_heart(
/// The damage of a mob's direct hit on the player (others count as none).
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
_ => 0.0,
}
}
@@ -578,7 +584,13 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
target: Who::Player,
..
} => true,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
@@ -1045,6 +1057,7 @@ pub fn main() -> horizon_client::Result<()> {
alive: true,
creative: s.creative,
sneaking: false,
swimming: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
@@ -1064,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
in_water: true,
muffled: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
+36 -1
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
};
@@ -753,6 +753,41 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
+380 -2
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
world::World,
};
use horizon_core::{
@@ -75,8 +75,32 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
/// `WetSpongeBlock.animateTick`.
WetSponge,
FireflyBush,
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
OpenEyeblossom,
PaleHangingMoss,
/// `DryVegetationBlock`: a dead bush.
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -91,6 +115,7 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -172,10 +197,47 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -246,10 +308,16 @@ pub struct AnimateTables {
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
desert_dry_vegetation_sounds: Vec<bool>,
terracotta: Vec<bool>,
pale_oak_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -315,14 +383,36 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
desert_dry_vegetation_sounds: by_state(
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
),
terracotta: by_state("minecraft:terracotta"),
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
burnable,
ambient,
})
@@ -348,8 +438,22 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -398,6 +502,15 @@ impl AnimateContext<'_> {
],
))
}
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
/// sky light less the sky darkening.
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
let light = self.world.light(p[0], p[1], p[2]);
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -453,7 +566,7 @@ fn do_animate_tick(
return;
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
@@ -609,6 +722,22 @@ fn beehive_drip(
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
/// sand or terracotta two blocks deep.
fn desert_dry_vegetation_sounds(
tables: &AnimateTables,
ctx: &AnimateContext,
below: [i32; 3],
) -> bool {
let triggers = |p: [i32; 3]| {
tables
.desert_dry_vegetation_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -684,6 +813,7 @@ fn block_animate_tick(
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
player: [i32; 3],
) {
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
match tables.kind(state) {
@@ -1073,12 +1203,198 @@ fn block_animate_tick(
}
}
}
Animate::FireflyBush => {
// FireflyBushBlock.animateTick: the idle sound on a quiet night
// under open sky, and fireflies in the dark within 5 blocks.
if next_int(engine.random(), 30) == 0
&& ctx.firefly_bush_sounds
&& (ctx.height_no_leaves)(pos) <= pos[1]
{
let name = "minecraft:block.firefly_bush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
if ctx.max_local_raw_brightness(pos) <= 13 {
let r = engine.random();
if r.next_double() <= 0.7 {
let p = [
fx + r.next_double() * 10.0 - 5.0,
fy + r.next_double() * 5.0,
fz + r.next_double() * 10.0 - 5.0,
];
add(engine, T::Firefly, p, [0.0; 3]);
}
}
}
Animate::OpenEyeblossom => {
// EyeblossomBlock.animateTick: pale moss below.
if next_int(engine.random(), 700) == 0 {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
if ctx
.registry
.state(below)
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
{
let name = "minecraft:block.eyeblossom.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::PaleHangingMoss => {
// HangingMossBlock.animateTick: under pale oak logs or leaves.
if next_int(engine.random(), 500) == 0 {
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
let pale_leaves = ctx
.registry
.state(above)
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
if pale_leaves
|| tables
.pale_oak_logs
.get(above.0 as usize)
.copied()
.unwrap_or(false)
{
let name = "minecraft:block.pale_hanging_moss.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
let below = [pos[0], pos[1] - 1, pos[2]];
let state = ctx.state(below);
let red_sand = ctx
.registry
.state(state)
.is_some_and(|s| s.name == "minecraft:red_sand");
let terracotta = tables
.terracotta
.get(state.0 as usize)
.copied()
.unwrap_or(false);
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
return;
}
if desert_dry_vegetation_sounds(tables, ctx, below) {
let name = "minecraft:block.deadbush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DryGrass => {
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
// played at the player.
if next_int(engine.random(), 200) == 0
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
{
let at = [
f64::from(player[0]) + 0.5,
f64::from(player[1]),
f64::from(player[2]) + 0.5,
];
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1382,6 +1698,68 @@ fn leaves_ambient_sound(
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
+3
View File
@@ -159,6 +159,8 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -208,6 +210,7 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
+78
View File
@@ -234,3 +234,81 @@ impl RenderAssets {
})
}
}
#[cfg(test)]
mod tests {
use crate::items::ConsumeEffect;
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = crate::items::Items::load(&root, assets.registry, &facts).unwrap();
let apple = items.get("minecraft:golden_apple").expect("golden apple");
assert!(apple.can_always_eat);
assert!(apple.food.is_some());
assert!(!apple.consumable.as_ref().unwrap().effects.is_empty());
let enchanted = items
.get("minecraft:enchanted_golden_apple")
.expect("enchanted golden apple");
assert!(enchanted.can_always_eat);
assert!(enchanted.food.is_some());
assert!(!enchanted.consumable.as_ref().unwrap().effects.is_empty());
let beef = items.get("minecraft:cooked_beef").expect("cooked beef");
assert!(!beef.can_always_eat);
assert!(beef.food.is_some());
// Consumables without food, their style and their remainders.
use horizon_world::consumable::UseAnimation;
let milk = items.get("minecraft:milk_bucket").expect("milk bucket");
let drink = milk.consumable.as_ref().unwrap();
assert!(milk.food.is_none());
assert_eq!(drink.effects, [ConsumeEffect::ClearAllEffects]);
assert_eq!((drink.ticks(), drink.animation), (32, UseAnimation::Drink));
assert_eq!(drink.sound, "minecraft:entity.generic.drink");
assert!(!drink.particles);
assert_eq!(milk.use_remainder.as_deref(), Some("minecraft:bucket"));
let honey = items.get("minecraft:honey_bottle").expect("honey bottle");
let sip = honey.consumable.as_ref().unwrap();
assert_eq!(sip.ticks(), 40);
assert_eq!(sip.sound, "minecraft:item.honey_bottle.drink");
assert_eq!(
sip.effects,
[ConsumeEffect::RemoveEffects(vec![
"minecraft:poison".into()
])]
);
assert_eq!(
honey.use_remainder.as_deref(),
Some("minecraft:glass_bottle")
);
let kelp = items.get("minecraft:dried_kelp").unwrap();
let bite = kelp.consumable.as_ref().unwrap();
assert_eq!((bite.ticks(), bite.animation), (16, UseAnimation::Eat));
assert_eq!(bite.sound, "minecraft:entity.generic.eat");
assert!(bite.particles && kelp.use_remainder.is_none());
let chorus = items.get("minecraft:chorus_fruit").unwrap();
assert_eq!(
chorus.consumable.as_ref().unwrap().effects,
[ConsumeEffect::TeleportRandomly(
horizon_world::teleport_consume::TeleportConsume {
diameter: 16.0,
directional_particles: true,
}
)]
);
let chicken = items.get("minecraft:chicken").unwrap();
assert!(matches!(
chicken.consumable.as_ref().unwrap().effects[..],
[ConsumeEffect::ApplyEffects { probability, .. }] if probability == 0.3
));
}
}
+1 -53
View File
@@ -3,24 +3,10 @@
//! banners, and the loom rules of `LoomMenu` (selectable patterns, the
//! six-layer limit and the result stack).
use crate::{block_entity_render::DYES, items::ItemStack, items::Items};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
/// `BannerPatternLayers.Layer`: a pattern id and the dye name it is painted in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BannerLayer {
pub pattern: Arc<str>,
pub color: Arc<str>,
}
impl BannerLayer {
pub fn new(pattern: &str, color: &str) -> Self {
Self {
pattern: Arc::from(pattern),
color: Arc::from(color),
}
}
}
pub use horizon_items::banner_layer::{BannerLayer, layers_from_nbt, layers_to_nbt};
/// Layers a loom may stack on a banner (`LoomMenu`'s `hasMaxPatterns`).
pub const MAX_LAYERS: usize = 6;
@@ -224,44 +210,6 @@ pub fn has_max_layers(banner: &ItemStack) -> bool {
layers(banner).len() >= MAX_LAYERS
}
/// The `banner_patterns` NBT list of layers.
pub fn layers_to_nbt(layers: &[BannerLayer]) -> Tag {
Tag::List(
layers
.iter()
.map(|l| {
Tag::compound([
("pattern", Tag::String((&*l.pattern).into())),
("color", Tag::String((&*l.color).into())),
])
})
.collect(),
)
}
pub fn layers_from_nbt(tag: Option<&Tag>) -> Vec<BannerLayer> {
let Some(Tag::List(list)) = tag else {
return Vec::new();
};
list.iter()
.filter_map(|e| {
let text = |k: &str| match e.get(k) {
Some(Tag::String(s)) => Some(s.to_string_lossy()),
_ => None,
};
let pattern = text("pattern")?;
let color = text("color")?;
Some(BannerLayer::new(
&if pattern.contains(':') {
pattern
} else {
format!("minecraft:{pattern}")
},
&color,
))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
+1 -14
View File
@@ -3,6 +3,7 @@
//! the rain, nor through a blocked front, unless the hive is emptied in an
//! emergency), honey for every bee coming home with nectar. The host (the game
//! or the mob AI probe) loads and adds the bees through [`HiveHost`].
pub use crate::chunk::block_entity::{Beehive, Occupant};
use crate::mobs::{Mob, Species, bee};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::nbt::{Compound, Tag};
@@ -38,20 +39,6 @@ const IGNORED_BEE_TAGS: [&str; 25] = [
"UUID",
];
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
/// `BeehiveBlockEntity.BeeReleaseStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
+7
View File
@@ -66,6 +66,13 @@ impl BiomeInfo {
}
.height_adjusted(pos, sea_level)
}
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
/// without precipitation, else whether it falls as snow
/// (`getHeightAdjustedTemperature` below 0.15).
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.precipitation
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
+1 -1
View File
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::containers::BlockEntity;
use crate::chunk::block_entity::BlockEntity;
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
+103 -17
View File
@@ -5,7 +5,11 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{hud::Rect, mesher::Vertex, world::World};
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -938,6 +942,30 @@ impl BlockEntityRenderer {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
}
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
/// at `pos`: the faces its portal is drawn on.
pub fn gateway_faces(
&self,
world: &World,
state: BlockStateId,
pos: [i32; 3],
) -> Vec<Direction> {
Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(state, neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect()
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
@@ -1392,6 +1420,7 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1409,6 +1438,11 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1439,22 +1473,8 @@ impl BlockEntityRenderer {
out,
);
}
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
let faces: Vec<Direction> = Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(BlockStateId(*state), neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect();
// AbstractEndPortalRenderer.extractRenderState.
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
@@ -2118,4 +2138,70 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
/// z-fight: the pot renders wrong.
#[test]
fn the_decorated_pot_culls_its_back_faces() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(&root, registry).unwrap();
let state = registry.default_state("minecraft:decorated_pot").unwrap();
let sprites: HashMap<String, Rect> = [
"entity/decorated_pot/decorated_pot_base",
"entity/decorated_pot/decorated_pot_side",
]
.into_iter()
.map(|name| {
(
name.to_owned(),
Rect {
u0: 0.25,
v0: 0.125,
u1: 0.375,
v1: 0.25,
w: 16,
h: 16,
},
)
})
.collect();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.5,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
// Vanilla submits 28 quads for a plain pot, and the cull keeps them all.
assert_eq!(
verts.len() / 4,
28,
"the pot submits all of vanilla's quads"
);
assert!(
verts.iter().all(|v| v.normal == CULL_BACK),
"entitySolid culls the pot's back faces"
);
}
}
+2 -13
View File
@@ -2,12 +2,13 @@
//! in vanilla, ported as tables), data-driven `minecraft:brewing` recipes from
//! `data/minecraft/recipe/brewing`, potion colours and tooltips, and
//! `BrewingStandBlockEntity.serverTick`.
pub use crate::chunk::block_entity::BrewingStand;
use crate::{
containers::Providers,
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
@@ -190,18 +191,6 @@ impl Brewing {
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// What a brewing tick asks the world to do.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BrewTick {
+26 -11
View File
@@ -556,26 +556,34 @@ impl Chat {
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
self.style_at(font, height, x, y)?.hover.map(|h| *h)
}
/// ChatComponent.getClickedComponentStyleAt.
fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
/// The wrapped line under a GUI-space cursor while chat is focused, and
/// the cursor's x within it (the text starts at 0).
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
let scale = self.options.scale as f32;
let mut x = x / f64::from(scale) - 4.0;
let x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
let from_bottom = f64::from(bottom) - y / f64::from(scale);
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
return None;
}
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
if row >= self.lines_per_page(true) {
return None;
}
let line = self.lines.get(self.scroll + row)?;
Some((self.lines.get(self.scroll + row)?, x))
}
/// ChatComponent.getClickedComponentStyleAt.
pub fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
let (line, mut x) = self.line_at(height, x, y)?;
if x < 0.0 {
return None;
}
let content = line.content.resolve_legacy();
for (c, style) in content.chars() {
x -= f64::from(font.advance(c, style));
@@ -585,6 +593,13 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
self.messages
+3 -1
View File
@@ -6,6 +6,8 @@ use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
pub mod block_entity;
pub const MIN_Y: i32 = -64;
pub const HEIGHT: i32 = 384;
pub const SECTIONS: usize = (HEIGHT / 16) as usize;
@@ -149,7 +151,7 @@ pub struct Chunk {
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), crate::containers::BlockEntity)>,
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
pub block_names: Vec<((i32, i32, i32), String)>,
/// Generation postprocessing marks (ProtoChunk.postProcessing) as world
@@ -0,0 +1,409 @@
//! Block entity data: what a chunk stores for its block entities (`BlockEntity`
//! and its payloads). Plain data, as vanilla's block entities are world data;
//! their behaviour lives with the features that use them (`containers`,
//! `brewing`, `signs`, ...), which re-export these types under their old paths.
use horizon_client_core::{text::Text, who::Who};
use horizon_items::{banner_layer::BannerLayer, items::ItemStack};
use horizon_world::{
level::sculk::SculkSpreader,
nbt::{Compound, Tag},
};
use std::{collections::HashMap, sync::Arc};
/// An entity's UUID as its four ints (`UUIDUtil.CODEC`).
pub type Uuid = [i32; 4];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
/// by (`ResolvableProfile.nameOrId`).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum NameOrId {
Name(String),
Id(u128),
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
pub items: [Option<ItemStack>; 5],
pub brew_time: i32,
pub total_brew_time: i32,
pub fuel: i32,
pub total_fuel: i32,
pub speed: f32,
pub ingredient: Option<Arc<str>>,
pub last_bottles: Option<[bool; 3]>,
}
/// One sign side (`SignText`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignText {
pub lines: [String; 4],
/// `DyeColor` id.
pub color: u8,
pub glowing: bool,
}
/// `SignBlockEntity` / `HangingSignBlockEntity` data.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SignData {
pub hanging: bool,
pub front: SignText,
pub back: SignText,
pub waxed: bool,
}
/// `VibrationInfo`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Info {
pub event: &'static str,
pub distance: f32,
pub pos: [f64; 3],
pub source: Option<Who>,
pub owner: Option<Who>,
}
/// `VibrationSelector`: the closest, then highest-frequency candidate of a tick.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Selector {
pub(crate) current: Option<(Info, i64)>,
}
/// `VibrationSystem.Data`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Data {
pub current: Option<Info>,
pub travel: i32,
pub selector: Selector,
}
/// Which sculk block entity this is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SculkKind {
Sensor,
Calibrated,
Shrieker,
Catalyst,
}
/// `SculkSensorBlockEntity`, `CalibratedSculkSensorBlockEntity`,
/// `SculkShriekerBlockEntity` and `SculkCatalystBlockEntity`.
#[derive(Clone, Debug)]
pub struct SculkEntity {
pub kind: SculkKind,
pub vibration: Data,
/// `last_vibration_frequency` (sensors).
pub last_frequency: i32,
/// `warning_level` (shriekers).
pub warning_level: i32,
/// The catalyst's charge cursors.
pub spreader: SculkSpreader,
}
/// `SpawnData.CustomSpawnRules`: the block and sky light the spawn block
/// must have (inclusive ranges within 0..=15).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CustomSpawnRules {
pub block: (i32, i32),
pub sky: (i32, i32),
}
/// `SpawnData`: the entity to spawn, optional light rules and an equipment
/// table (kept as loaded).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SpawnData {
pub entity: Compound,
pub custom_spawn_rules: Option<CustomSpawnRules>,
pub equipment: Option<Tag>,
}
/// Which `SpawnData` is next: one of the potentials (the same object in
/// vanilla, so changing it changes the potential) or its own.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Next {
Potential(usize),
Own(SpawnData),
}
/// `BaseSpawner`.
#[derive(Clone, Debug, PartialEq)]
pub struct Spawner {
pub spawn_delay: i32,
/// `SpawnPotentials`: data and weight.
pub potentials: Vec<(SpawnData, i32)>,
pub(crate) next: Option<Next>,
pub min_spawn_delay: i32,
pub max_spawn_delay: i32,
pub spawn_count: i32,
pub max_nearby_entities: i32,
pub required_player_range: i32,
pub spawn_range: i32,
/// The client copy's delay (`spawnDelay` on the client level), which
/// speeds the spin up as the next spawn nears.
pub client_delay: i32,
pub spin: f64,
pub o_spin: f64,
}
/// `TrialSpawnerConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialConfig {
pub spawn_range: i32,
pub total_mobs: f32,
pub simultaneous_mobs: f32,
pub total_mobs_added_per_player: f32,
pub simultaneous_mobs_added_per_player: f32,
pub ticks_between_spawn: i32,
pub spawn_potentials: Vec<(SpawnData, i32)>,
pub loot_tables_to_eject: Vec<(String, i32)>,
pub items_to_drop_when_ominous: String,
}
/// A `Holder<TrialSpawnerConfig>` as saved: a registry id
/// (`minecraft:trial_chamber/breeze/normal`) or an inline config.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ConfigSlot {
/// The value as loaded, written back as is; None for the default.
pub saved: Option<Tag>,
pub config: TrialConfig,
}
/// `TrialSpawner` with its `TrialSpawnerStateData`.
#[derive(Clone, Debug, PartialEq)]
pub struct TrialSpawner {
pub normal: ConfigSlot,
pub ominous: ConfigSlot,
pub target_cooldown_length: i32,
pub required_player_range: i32,
pub detected_players: Vec<Uuid>,
pub current_mobs: Vec<Uuid>,
pub cooldown_ends_at: i64,
pub next_mob_spawns_at: i64,
pub total_mobs_spawned: i32,
pub next_spawn_data: Option<SpawnData>,
pub ejecting_loot_table: Option<String>,
/// `isOminous`, set from the block on every server tick.
pub is_ominous: bool,
/// The server's display entity exists (`getOrCreateDisplayEntity`): it is
/// made once per loaded block entity and kept.
pub has_display: bool,
pub spin: f64,
pub o_spin: f64,
/// `getDispensingItems`, kept once rolled (not saved): the ominous items
/// and their weights (the rolled counts).
pub dispensing: Option<Vec<ItemStack>>,
}
/// `VaultConfig`.
#[derive(Clone, Debug, PartialEq)]
pub struct VaultConfig {
pub loot_table: String,
pub activation_range: f64,
pub deactivation_range: f64,
/// None when the config has no key (`ItemStack.EMPTY`): the vault never opens.
pub key_item: Option<ItemStack>,
pub override_loot_table_to_display: Option<String>,
}
/// `VaultBlockEntity`: its config, server data and shared data.
#[derive(Clone, Debug, PartialEq)]
pub struct Vault {
pub config: VaultConfig,
/// `VaultServerData`, oldest rewarded player first.
pub rewarded_players: Vec<Uuid>,
pub state_updating_resumes_at: i64,
/// Popped from the end.
pub items_to_eject: Vec<ItemStack>,
pub total_ejections_needed: i32,
pub last_insert_fail_timestamp: i64,
/// `VaultSharedData`.
pub display_item: Option<ItemStack>,
pub connected_players: Vec<Uuid>,
pub connected_particles_range: f64,
/// The shared data changed since the last tick sent it.
pub shared_dirty: bool,
/// `VaultClientData`: the display item's spin and the previous tick's, in degrees.
pub spin: f32,
pub o_spin: f32,
}
/// `BeehiveBlockEntity`: the bees inside and the flower one of them knew.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Beehive {
pub bees: Vec<Occupant>,
pub flower: Option<[i32; 3]>,
}
/// `BeehiveBlockEntity.Occupant`: a bee's tag (with its `id`), its time in
/// the hive and the time it stays at least.
#[derive(Clone, Debug, PartialEq)]
pub struct Occupant {
pub entity_data: Tag,
pub ticks_in_hive: i32,
pub min_ticks_in_hive: i32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
+1 -16
View File
@@ -1,6 +1,7 @@
//! `CommandBlockEntity` with its `BaseCommandBlock`: the command, the success
//! count comparators read, the output it keeps, and the powered, automatic
//! and condition flags `CommandBlock.tick` runs on.
pub use crate::chunk::block_entity::CommandBlock;
use crate::{
save::{number, text},
text::Text,
@@ -76,22 +77,6 @@ pub fn automatic_block(name: &str) -> bool {
name == "minecraft:chain_command_block"
}
#[derive(Clone, Debug, PartialEq)]
pub struct CommandBlock {
pub command: String,
pub success_count: i32,
/// `CustomName` as saved (a text component).
pub custom_name: Option<Tag>,
pub track_output: bool,
pub last_output: Option<Text>,
pub update_last_execution: bool,
/// The game time of the last run, -1 for none.
pub last_execution: i64,
pub powered: bool,
pub auto: bool,
pub condition_met: bool,
}
impl Default for CommandBlock {
fn default() -> Self {
Self {
+13 -2
View File
@@ -63,13 +63,24 @@ impl Confirm {
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
active: bool,
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
}
+4 -170
View File
@@ -1,6 +1,10 @@
//! Container block entities: chests/barrels (27 slots) and the three furnace
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
};
use crate::{
crafting::Recipes,
items::{ItemStack, Items},
@@ -8,12 +12,6 @@ use crate::{
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FurnaceKind {
Furnace,
BlastFurnace,
Smoker,
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -34,21 +32,6 @@ impl FurnaceKind {
}
}
}
#[derive(Clone, Debug)]
pub struct Furnace {
pub kind: FurnaceKind,
/// input, fuel, result
pub items: [Option<ItemStack>; 3],
pub lit_remaining: i32,
pub lit_total: i32,
pub cooking_timer: i32,
pub cooking_total: i32,
pub speed: f32,
/// `AbstractFurnaceBlockEntity.recipesUsed`: how many of each recipe this furnace has
/// made since a player last took from its output. The experience is owed per recipe
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
@@ -63,78 +46,6 @@ pub fn recipe_used_key(id: &str) -> String {
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
#[derive(Clone, Debug)]
pub enum BlockEntity {
Chest(Vec<Option<ItemStack>>),
Furnace(Furnace),
Brewing(Box<crate::brewing::BrewingStand>),
/// Signs and hanging signs (`SignBlockEntity`).
Sign(crate::signs::SignData),
/// `JukeboxBlockEntity`: the record and its `JukeboxSongPlayer`.
Jukebox(Jukebox),
/// `BeaconBlockEntity`.
Beacon(Box<Beacon>),
/// `LecternBlockEntity`: the book and the open page.
Lectern(Lectern),
/// Sculk sensors, shriekers and catalysts (`crate::sculk`).
Sculk(Box<crate::sculk::SculkEntity>),
/// `CreakingHeartBlockEntity`: its protector and the hurt emitter.
CreakingHeart(Box<CreakingHeart>),
/// `BannerBlockEntity`: the pattern layers.
Banner(Banner),
/// `CrafterBlockEntity`.
Crafter(Crafter),
/// `SpawnerBlockEntity` (`crate::mobs::spawner`).
Spawner(Box<crate::mobs::spawner::Spawner>),
/// `TrialSpawnerBlockEntity` (`crate::mobs::trial_spawner`).
TrialSpawner(Box<crate::mobs::trial_spawner::TrialSpawner>),
/// `VaultBlockEntity` (`crate::vault`).
Vault(Box<crate::vault::Vault>),
/// `BeehiveBlockEntity` of beehives and bee nests (`crate::beehive`).
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
/// `TheEndGatewayBlockEntity`: its age and configured destination.
EndGateway(EndGateway),
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `TheEndGatewayBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EndGateway {
pub age: i64,
pub exit_portal: Option<[i32; 3]>,
pub exact_teleport: bool,
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
@@ -182,17 +93,6 @@ impl Campfire {
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
#[derive(Clone, Debug, Default)]
pub struct Skull {
pub profile: Option<horizon_world::nbt::Tag>,
pub note_block_sound: Option<String>,
pub custom_name: Option<horizon_world::nbt::Tag>,
/// What `profile` draws, read from it once rather than every frame.
pub skin: Option<SkullSkin>,
}
impl Skull {
pub fn new(
profile: Option<horizon_world::nbt::Tag>,
@@ -208,18 +108,6 @@ impl Skull {
}
}
}
/// `SkullBlockRenderer.getPlayerSkin` of a head's profile: its skin's URL
/// with the `skins/<sha1>` sprite it downloads to, and the
/// `DefaultPlayerSkin` drawn until that sprite is there (steve for a
/// profile with no id).
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
pub fn of(profile: &horizon_world::nbt::Tag) -> Self {
let download = crate::skins::profile_skin_url_nbt(profile).and_then(|url| {
@@ -248,18 +136,6 @@ impl SkullSkin {
fn is_skull(name: &str) -> bool {
name.ends_with("_skull") || (name.ends_with("_head") && name != "minecraft:piston_head")
}
/// `CrafterBlockEntity`: nine slots that can be disabled, the powered flag
/// and the ticks left in the crafting animation.
#[derive(Clone, Debug, Default)]
pub struct Crafter {
pub items: [Option<ItemStack>; 9],
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
pub fn slot_can_be_disabled(&self, slot: usize) -> bool {
@@ -299,48 +175,6 @@ impl Crafter {
})
}
}
#[derive(Clone, Debug, Default)]
pub struct Banner {
pub layers: Vec<crate::banner::BannerLayer>,
pub name: Option<String>,
/// Components not consumed by BannerBlockEntity's implicit fields.
pub components: horizon_world::nbt::Compound,
}
/// `CreakingHeartBlockEntity` (the logic is in `game/creaking.rs`).
#[derive(Clone, Debug, Default)]
pub struct CreakingHeart {
/// The protector: its entity id once resolved, and its UUID (the saved form).
pub creaking: Option<(Option<u32>, [i32; 4])>,
pub ticks_existed: i64,
pub ticker: i32,
/// Ticks left of the hurt effect (`emitter`) and where it heads.
pub emitter: i32,
pub emitter_target: Option<[f64; 3]>,
pub output_signal: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Lectern {
pub book: Option<ItemStack>,
pub page: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Beacon {
pub levels: i32,
/// Effect ids (`minecraft:speed`).
pub primary: Option<String>,
pub secondary: Option<String>,
/// Beam sections (RGB colour, height) and the ones being rebuilt.
pub sections: Vec<(u32, i32)>,
pub checking: Vec<(u32, i32)>,
pub last_check_y: i32,
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl BlockEntity {
pub fn for_block(name: &str) -> Option<Self> {
if let Some(hanging) = crate::signs::sign_kind(name) {
+4 -3
View File
@@ -129,15 +129,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
_ => None,
}
}
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
/// the amount rounds to the nearest whole point).
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
+8 -1
View File
@@ -19,6 +19,8 @@ pub struct ItemEntity {
pub id: u32,
pub stack: ItemStack,
pub thrower: Option<[i32; 4]>,
/// `ItemEntity.target`: the only player who may pick the item up.
pub target: Option<[i32; 4]>,
pub position: Vec3,
pub previous: Vec3,
pub velocity: Vec3,
@@ -355,7 +357,10 @@ pub fn merge_with_neighbours(
}
// ItemEntity.areMergable: both fit one stack of the same item and components.
let (this, other) = (&items[i].stack, &items[j].stack);
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
if items[i].target != items[j].target
|| this.count + other.count > max_stack(other)
|| !this.same_item_same_components(other)
{
continue;
}
let (to, from) = if other.count < this.count {
@@ -951,6 +956,7 @@ mod fire_environment_tests {
let make = |id: &str| ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
@@ -1047,6 +1053,7 @@ mod merge_tests {
ItemEntity {
id: 0,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count,
@@ -189,6 +189,7 @@ mod tests {
extra: None,
},
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+7 -2
View File
@@ -54,6 +54,8 @@ struct Out {
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
@location(11) glint_uv: vec2<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(12) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -102,7 +104,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
if normal.w != 0.0 {
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -234,7 +239,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 { discard; }
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
+52 -13
View File
@@ -13,7 +13,6 @@ use crate::{
doubles, float, floats, item_from_nbt, item_to_nbt, number, put, read_doubles, text,
uuid_from_tag,
},
text::Text,
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -515,6 +514,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
} else {
map.remove(&"Owner".into());
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
@@ -552,7 +557,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
)]),
)]),
);
@@ -606,6 +614,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
"minecraft:shulker" => {
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
@@ -712,6 +723,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A wolf's armor keeps the durability it lost.
if slot == "body"
&& m.species() == crate::mobs::Species::Wolf
&& let Some(id) = item
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
match item {
Some(id) => {
let keep = matches!(
@@ -851,14 +873,7 @@ fn custom_name(tag: &Tag) -> Option<String> {
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
match tag {
Tag::String(s) => Text::legacy(&s.to_string_lossy()),
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
pub use horizon_client_core::component::name_text;
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
@@ -1111,6 +1126,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.variant = gene("MainGene");
m.extra.variant2 = gene("HiddenGene");
}
if m.species() == crate::mobs::Species::Parrot {
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");
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
@@ -1118,6 +1138,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
crate::mobs::tame::set_tame(&mut m, tame);
m.health = health;
m.extra.sitting = flag(tag, "Sitting");
let armor = tag
.get("equipment")
.and_then(|e| e.get("body"))
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
}
@@ -1148,11 +1174,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
m.extra.tame = tag
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:liked_player"))
.is_some();
.and_then(|b| b.get("minecraft:liked_player"));
m.extra.tame = liked.is_some();
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
}
if crate::mobs::camel::is_camel(m.species()) {
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
@@ -1230,6 +1259,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
@@ -1320,6 +1352,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Thrower".into());
}
if let Some(target) = e.target {
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Owner".into());
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -1331,6 +1368,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
id,
stack,
thrower: uuid_from_tag(tag.get("Thrower")),
target: uuid_from_tag(tag.get("Owner")),
position,
previous: position,
velocity: motion(tag),
@@ -2240,6 +2278,7 @@ mod tests {
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
+18 -12
View File
@@ -26,6 +26,13 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
kinetic: crate::spear::Kinetic,
time_held: f32,
ticks_since_hit: f32,
},
Crossbow {
ticks_used: Option<f32>,
charge_duration: f32,
@@ -69,6 +76,16 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
time_held,
ticks_since_hit,
} = pose
{
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
@@ -426,18 +443,7 @@ impl UseResult {
matches!(self, Self::Success { swing: true }) && before == after
}
}
pub fn consumable_animation(v: &serde_json::Value) -> horizon_world::consumable::UseAnimation {
use horizon_world::consumable::UseAnimation;
match v
.get("animation")
.and_then(serde_json::Value::as_str)
.unwrap_or("eat")
{
"eat" => UseAnimation::Eat,
"drink" => UseAnimation::Drink,
_ => UseAnimation::None,
}
}
pub use horizon_items::use_animation::consumable_animation;
/// What the click handling reads and does in the world.
pub trait HandWorld {
fn hit(&mut self) -> Hit;
File diff suppressed because it is too large. Load diff
@@ -719,6 +719,19 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
@@ -863,6 +876,35 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
});
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["item"], &it, w)
&& adv::range_i(&c["levels"], i64::from(levels))
});
}
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
+38 -3
View File
@@ -8,7 +8,6 @@ use horizon_core::random::RandomSource;
impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
@@ -58,7 +57,7 @@ impl Game<'_> {
};
let Some((j, _)) = wanted else {
self.mobs[i].extra.point = None;
self.allay_deliver(i, player);
self.allay_deliver(i);
continue;
};
let e = &self.dropped[j];
@@ -90,14 +89,46 @@ impl Game<'_> {
}
}
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
/// within 64 blocks.
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
let m = &self.mobs[i];
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
.map(|p| p.position);
let found = host.or_else(|| {
self.lan_present()
.into_iter()
.find(|p| p.uuid == uuid && !p.spectator)
.map(|p| {
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
})
});
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
}
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
/// the deposit, one item a tick, each restarting the three-second pause
/// before it picks anything up again.
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
fn allay_deliver(&mut self, i: usize) {
let m = &self.mobs[i];
if m.extra.ints[1] <= 0 {
return;
}
let player = self.allay_liked_player(i);
let m = &self.mobs[i];
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
Some(h) => horizon_core::geometry::Vec3::new(
f64::from(h[0]) + 0.5,
@@ -167,11 +198,14 @@ impl Game<'_> {
self.consume_held();
return true;
}
let acting = self.acting_uuid();
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.consume_held();
@@ -186,6 +220,7 @@ impl Game<'_> {
.unwrap_or_else(|| "minecraft:air".into());
let carried = std::mem::take(&mut m.extra.ints[1]);
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
if carried > 0 {
+24
View File
@@ -164,11 +164,35 @@ impl Game<'_> {
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
}
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
let Some(bb) = self
.player
.as_ref()
.map(|pl| pl.dimensions.bounds(pl.position))
else {
return;
};
let (lo, hi) = (bb.min_position(), bb.max_position());
let [x, y, z] = p.map(f64::from);
if lo.x < x + 10.0
&& hi.x > x - 10.0
&& lo.y < y + 5.0
&& hi.y > y - 9.0
&& lo.z < z + 10.0
&& hi.z > z - 10.0
{
self.trigger_construct_beacon(levels);
}
}
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
+42 -2
View File
@@ -4,9 +4,13 @@
//! placed item gives its block entity.
use super::Game;
use horizon_client::{
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
registry::BlockStateId,
};
use horizon_world::nbt::{Compound, Tag};
/// The component patch of `stack`.
fn components(stack: &ItemStack) -> Compound {
@@ -77,6 +81,42 @@ impl Game<'_> {
Some(stack)
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
pub(super) fn apply_item_block_state(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
) -> Result<BlockStateId> {
let Some(Tag::Compound(saved)) =
stack.data().and_then(|d| d.other("minecraft:block_state"))
else {
return Ok(state);
};
let Some(current) = self.pack.assets.registry.state(state) else {
return Ok(state);
};
// Only the properties the block has.
let props: Vec<(String, String)> = saved
.iter()
.filter_map(|(k, v)| match v {
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
_ => None,
})
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
.collect();
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
match self.with_props(state, &props) {
Some(new) if !props.is_empty() && new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
/// for the block entity just placed at `pos` from `stack`; `game_master`:
/// the placer can use game master blocks (a dispenser can't).
+6 -1
View File
@@ -1144,7 +1144,12 @@ impl Game<'_> {
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
// BookSignScreen: `owner.getName()`, the account's name on a server.
let owner = self
.remote
.as_ref()
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
atlas,
+246
View File
@@ -0,0 +1,246 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use horizon_client::{
particles::{ParticleOptions, ParticleType},
save,
};
use horizon_core::random::RandomSource;
use std::collections::HashMap;
/// `BrushItem.USE_DURATION`.
const USE_DURATION: u32 = 200;
/// A brushable block's counters. Vanilla never saves them: the block's
/// `dusted` state is all that is left after a reload.
#[derive(Default)]
pub(super) struct BrushState {
count: i32,
resets_at: i64,
cooldown_ends_at: i64,
hit_face: Option<[i32; 3]>,
}
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
fn completion_state(count: i32) -> i32 {
match count {
0 => 0,
1..=2 => 1,
3..=5 => 2,
_ => 3,
}
}
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
let Some(hit) = self.pick() else {
self.using = None;
return;
};
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
}
/// One stroke on the block aimed at: dust (this window only), the brush
/// sound, and the block entity's count. The brush wears when the block
/// changed state.
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let name = self.res.facts().get(state).name.clone();
let (sound, brushable) = match &*name {
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
_ => ("minecraft:item.brush.brushing.generic", false),
};
if self.lan_acting.is_none() {
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
self.damage_held(1);
}
}
/// `BrushItem.spawnDustParticles`.
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
return;
};
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
// The arm that brushes: the main arm, or the other one for the off hand.
let flip = if off_hand == self.settings.main_hand_left {
1.0
} else {
-1.0
};
let view = view_vector(self.yaw, self.pitch);
// DustParticlesDelta.fromDirection.
let (dx, dz) = match hit.face {
[0, _, 0] => (view[2], -view[0]),
[0, 0, -1] => (1.0, -0.1),
[0, 0, 1] => (-1.0, 0.1),
[-1, 0, 0] => (-0.1, -1.0),
_ => (0.1, 1.0),
};
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
for _ in 0..count {
let random = self.particles.level_random();
let v = [
dx * flip * 3.0 * random.next_double(),
0.0,
dz * flip * 3.0 * random.next_double(),
];
self.particles.add(&options, [x, hit.point[1], z], v);
}
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
return false;
}
brush.cooldown_ends_at = time + 10;
self.unpack_brush_loot(pos);
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
return true;
}
self.schedule_brushable_tick(pos);
let after = completion_state(brush.count);
self.brushing.insert(pos, brush);
if before != after {
self.set_dusted(pos, after);
}
false
}
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
loot.as_deref(),
&mut self.world.random,
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
"minecraft:suspicious_gravel" => "minecraft:gravel",
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
item.count = item.count.min(take as u32);
let at = [
f64::from(pos[0] + face[0]) + 0.5,
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
let _ = self.level_set(pos, base);
}
}
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
state.properties.insert("dusted".into(), dusted.to_string());
if let Ok(state) = registry.resolve(&state) {
let _ = self.level_set(pos, state);
}
}
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
}
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
/// left alone loses two counts, again every four ticks.
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
let time = self.world.game_time;
let Some(mut brush) = self.brushing.remove(&pos) else {
return;
};
let before = completion_state(brush.count);
if brush.count != 0 && time >= brush.resets_at {
brush.count = (brush.count - 2).max(0);
brush.resets_at = time + 4;
}
let after = completion_state(brush.count);
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
}
if before != after {
self.set_dusted(pos, after);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
let state = horizon_world::registry::BlockStateId(data as u32);
let sound = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
_ => "",
};
if !sound.is_empty() {
let at = pos.map(|v| f64::from(v) + 0.5);
// Each client plays it from the event itself.
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
}
self.destroy_particles(pos, state);
}
}
@@ -2,8 +2,60 @@
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
use horizon_core::position::BlockPos;
use horizon_world::chunk::BlockState;
use winit::keyboard::KeyCode;
impl Game<'_> {
/// The lit campfire at `pos` that `held` puts out, when the item is in
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
})?;
self.pack
.assets
.registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
}
/// `CampfireBlock.useItemOn` as this client runs it before the server's
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
/// under `seq`. The level event's sound comes from the server. True when
/// it doused (SUCCESS), which ends the click for both hands.
pub(super) fn predict_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
seq: i32,
) -> bool {
// Player.isSecondaryUseActive: a held item means both hands count.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let Some(mut state) = self.campfire_to_douse(pos, held) else {
return false;
};
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.predict_blocks(&vec![(at, unlit)], seq);
}
true
}
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
@@ -11,24 +63,11 @@ impl Game<'_> {
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
else {
return Ok(false);
};
let registry = self.pack.assets.registry;
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
@@ -44,7 +83,6 @@ impl Game<'_> {
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
+144 -35
View File
@@ -81,6 +81,8 @@ pub struct UiState {
pub chat_screen: Option<ChatScreen>,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
pub in_bed_chat: bool,
pub registries: Registries,
pub rules: GameRules,
pub clock: WorldClock,
@@ -102,6 +104,9 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
/// 20 ticks, `setDelay`).
pub death_confirm: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
@@ -452,6 +457,12 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
self.wake_up();
return;
}
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -459,6 +470,35 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
if self.ui.in_bed_chat {
if self.sleeping.is_none() {
self.ui.in_bed_chat = false;
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
if !text.is_empty() {
self.open_chat(&text);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
/// The Leave Bed button of `InBedChatScreen`: its bounds.
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -571,7 +611,15 @@ impl Game<'_> {
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Submit(message) => {
self.close_chat();
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
if self.ui.in_bed_chat && self.sleeping.is_some() {
screen.suggest_command(&m, &c, "");
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
self.close_chat();
}
if !message.is_empty() {
self.handle_chat_input(&message);
}
@@ -673,7 +721,8 @@ impl Game<'_> {
{
return;
}
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
let shift = self.has_shift_down();
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
self.ui.chat.scroll_by(amount as i32, true);
}
@@ -687,6 +736,16 @@ impl Game<'_> {
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
let width = self.renderer.as_ref().map_or(1280.0, |r| {
HudBatch::new([r.config.width, r.config.height]).width
});
let (x, y, w, h) = Self::leave_bed_button(width, height);
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
self.wake_up();
return;
}
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
return;
@@ -694,12 +753,25 @@ impl Game<'_> {
} else {
return;
}
match self.ui.chat.click_at(
let Some(style) = self.ui.chat.style_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) {
) else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
// insertion acts, else only the click event.
if self.has_shift_down() {
if let Some(insertion) = style.insertion
&& let Some(screen) = self.ui.chat_screen.as_mut()
{
screen.type_text(&m, &c, &insertion);
}
return;
}
match style.click {
Some(ClickEvent::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
@@ -713,6 +785,7 @@ impl Game<'_> {
screen.suggest_command(&m, &c, &command);
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
@@ -781,6 +854,22 @@ impl Game<'_> {
self.answer_link_prompt(yes);
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
/// without adding it to the chat history. A client mod's command goes
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
self.run_command(command);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
@@ -815,17 +904,59 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
batch.height as i32,
f64::from(self.cursor.0),
f64::from(self.cursor.1),
) {
let lines = hover_rows(&hover);
if self.ui.in_bed_chat && self.sleeping.is_some() {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
// or the text of the message tag under the cursor.
let height = batch.height as i32;
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = style
.hover_item
.as_deref()
.and_then(horizon_client::item_data::stack_from_hover)
.filter(|s| self.res.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
let tag = Text::translate("chat.tag.system_single_player", &[]);
let lines = self.wrapped_rows(&tag, 210);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
.font
.split(&text.resolve_legacy(), width as f32);
split.into_iter().map(|row| (row, u32::MAX)).collect()
}
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
@@ -970,6 +1101,7 @@ impl Game<'_> {
let cause = self.ui.death_cause.take();
self.ui.died_at = Some(std::time::Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
if !self.ui.rules.bool("show_death_messages") {
return;
}
@@ -1078,26 +1210,3 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// A hover text's rows for the tooltip: split at line breaks, each in the
/// colour of its first styled character (white without one).
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
let mut rows = vec![(String::new(), None)];
for (c, style) in hover.resolve_legacy().chars() {
if c == '\n' {
rows.push((String::new(), None));
continue;
}
let row = rows.last_mut().expect("one row");
if row.0.is_empty() {
row.1 = style.color;
}
row.0.push(c);
}
rows.into_iter()
.map(|(text, color)| {
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
(text, horizon_client::hud::rgba(r, g, b, 255))
})
.collect()
}
+4
View File
@@ -42,6 +42,10 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
+147 -78
View File
@@ -1162,6 +1162,17 @@ impl Game<'_> {
// An air stack is empty: nothing is given (but the message still is).
let empty = id == "minecraft:air";
for &t in targets.iter().filter(|_| !empty) {
// The player's UUID, which an item they could not hold is set to.
let conn = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
let uuid = match conn {
Some(conn) => self
.command_guest(conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
None => Some(self.level.ensure_player_uuid()),
};
self.for_player(t, |g| {
let mut remaining = count;
while remaining > 0 {
@@ -1178,8 +1189,19 @@ impl Game<'_> {
.target_position(Target::Player)
.unwrap_or(g.source_position());
if let Some(rest) = left {
// Inventory full: drop the rest at the player's feet without pickup delay.
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
// Inventory full: Player.createItemStackToDrop (thrown from
// the eyes), then setNoPickUpDelay and setTarget(player).
let eye = g.eye_position().unwrap_or([
p[0],
p[1] + g.target_eye_height(Target::Player),
p[2],
]);
g.throw_from_eyes(rest, eye, false);
if let Some(item) = g.dropped.last_mut() {
item.thrower = None;
item.pickup_delay = 0;
item.target = uuid;
}
} else {
let r = &mut g.world.random;
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
@@ -1447,7 +1469,7 @@ impl Game<'_> {
{
return Err(err("commands.teleport.invalidPosition", &[]));
}
let mut rotation = match ctx.get("rotation") {
let rotation = match ctx.get("rotation") {
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
_ => None,
};
@@ -1462,21 +1484,31 @@ impl Game<'_> {
(Some(f), _) => Some(f),
(None, Some(_)) => {
let e = self.entities(ctx, "facingEntity", false)?[0];
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
self.target_position(e)
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
.map(|p| [p[0], p[1] + eye_height, p[2]])
}
_ => None,
};
if let Some(f) = facing {
let eye = pos[1] + 1.62;
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
// eyes`) on the teleported entity, so the eyes depend on the target.
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
let look_at = |this: &Self, t: Target| {
let f = facing?;
let eye = if anchored_eyes {
pos[1] + this.target_eye_height(t)
} else {
pos[1]
};
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
rotation = Some([
Some([
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
(-(dy.atan2(horizontal).to_degrees())) as f32,
]);
}
])
};
// A server's player in another level than the source's (`execute
// in`, the console's Overworld) moves there (ServerPlayer.teleport
// into another level), after the command.
@@ -1486,6 +1518,7 @@ impl Game<'_> {
None => self.dim,
};
for &t in &targets {
let rotation = look_at(self, t).or(rotation);
if self.teleport_across(t, source_level, pos, rotation) {
continue;
}
@@ -2210,7 +2243,8 @@ impl Game<'_> {
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
if !ctx.has("force") && overlap {
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -2419,26 +2453,54 @@ impl Game<'_> {
let levels = ctx.has("levels");
let unit = if levels { "levels" } else { "points" };
if ctx.has("query") {
let _ = self.entities(ctx, "target", true)?;
let e = &self.experience;
let value = if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
};
let target = self.entities(ctx, "target", true)?[0];
// ExperienceCommand.queryExperience: the target's own level or points.
let value = self
.for_player(target, |g| {
let e = &g.experience;
if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
as i32
}
})
.unwrap_or(0);
self.system_message(Text::translate(
&format!("commands.experience.query.{unit}"),
&[
Text::literal(self.acting_player_name()),
Text::literal(value.to_string()),
],
&[self.target_name(target), Text::literal(value.to_string())],
));
return Ok(value);
}
let targets = self.entities(ctx, "target", true)?;
let amount = Self::int(ctx, "amount").unwrap_or(0);
let op = if ctx.has("add") { "add" } else { "set" };
if op == "add" {
let add = ctx.has("add");
let op = if add { "add" } else { "set" };
// Every target changes (a set of points can be refused for some).
let changed: Vec<Target> = targets
.iter()
.copied()
.filter(|&t| {
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
})
.collect();
if changed.is_empty() {
return Err(err("commands.experience.set.points.invalid", &[]));
}
let (key, who) = match changed[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(changed.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.experience.{op}.{unit}.success.{key}"),
&[Text::literal(amount.to_string()), who],
));
Ok(changed.len() as i32)
}
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
@@ -2452,26 +2514,11 @@ impl Game<'_> {
} else {
let needed = horizon_client::entities::Experience::needed(self.experience.level);
if amount > needed - 1 {
return Err(err("commands.experience.set.points.invalid", &[]));
return false;
}
self.experience.progress = amount as f32 / needed as f32;
}
let key = format!(
"commands.experience.{op}.{unit}.success.{}",
if targets.len() == 1 {
"single"
} else {
"multiple"
}
);
self.feedback(Text::translate(
&key,
&[
Text::literal(amount.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(targets.len() as i32)
true
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
@@ -2556,21 +2603,26 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
// SetSpawnCommand: a forced point in the source's level, for each target.
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
});
}
let dim = Text::literal(self.dim.id());
@@ -2750,6 +2802,44 @@ impl Game<'_> {
},
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// ClearInventoryCommands.clearInventory: each player on their own.
let mut removed = 0i32;
let mut found = Vec::new();
for &t in &targets {
let n = self
.for_player(t, |g| g.clear_matching(&predicate, max))
.unwrap_or(0);
removed += n;
if n != 0 {
found.push(t);
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[self.target_name(targets[0])],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
let (key, who) = match found[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(found.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.clear.{kind}.{key}"),
&[Text::literal(removed.to_string()), who],
));
Ok(removed)
}
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
/// of the matching items go (all when negative, none and only counted
/// when 0). Returns how many matched.
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
let counting = max == 0;
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
@@ -2765,10 +2855,7 @@ impl Game<'_> {
};
let matching: Vec<bool> = all(self)
.iter()
.map(|s| {
s.as_ref()
.is_some_and(|s| self.stack_matches(&predicate, s))
})
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
@@ -2798,25 +2885,7 @@ impl Game<'_> {
}
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[Text::literal(self.acting_player_name())],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
self.feedback(Text::translate(
&format!("commands.clear.{kind}.single"),
&[
Text::literal(removed.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(removed)
removed
}
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
@@ -105,6 +105,7 @@ impl Game<'_> {
match ty.trim_start_matches("minecraft:") {
"player_attack" => DamageKind::PlayerAttack,
"mace_smash" => DamageKind::MaceSmash,
"spear" => DamageKind::Spear,
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
"arrow" => DamageKind::Arrow,
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
@@ -118,7 +119,8 @@ impl Game<'_> {
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"cactus" => DamageKind::Cactus,
"sweet_berry_bush" => DamageKind::SweetBerryBush,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
@@ -1219,7 +1221,7 @@ impl Game<'_> {
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
fn dismount_mob(&mut self, rider: u32) {
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
@@ -204,6 +204,64 @@ impl Game<'_> {
}
}
// ---- interactions -----------------------------------------------------
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
/// wearing the axe. Weathered statues are scraped by the axe's own
/// transform instead.
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
return false;
}
let registry = self.pack.assets.registry;
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
return false;
};
if state.name != "minecraft:copper_golem_statue" {
return false;
}
// `Direction.toYRot` of the statue's facing.
let yaw = match state.properties.get("facing").map(String::as_str) {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
};
let waterlogged = state
.properties
.get("waterlogged")
.is_some_and(|w| w == "true");
// initCopperGolem: at the block's feet, facing as the statue does.
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
return false;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
m.body_yaw = yaw;
m.previous_body_yaw = yaw;
m.head_yaw = yaw;
m.previous_head_yaw = yaw;
}
self.damage_held(1);
self.audio
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
// Level.removeBlock(pos, false): air, or the water the statue stood in.
let replacement = registry.default_state(if waterlogged {
"minecraft:water"
} else {
"minecraft:air"
});
if let Some(replacement) = replacement {
let _ = self.level_set(pos, replacement);
}
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
true
}
/// `CopperGolem.mobInteract`; true when handled.
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
@@ -0,0 +1,39 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
self.inventory[slot] = stack.clone();
}
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
&[
key("key.loadToolbarActivator"),
key(&format!("key.hotbar.{}", index + 1)),
],
);
self.titles.set_overlay(message, false);
}
}
+459 -63
View File
@@ -21,7 +21,7 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
geometry::{Aabb, Direction, Vec3, traverse_blocks},
position::BlockPos,
random::RandomSource,
};
@@ -46,9 +46,16 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// The players a fishing hook can catch, taken as the hooks tick, and
/// the pulls on them waiting for the hub (game/fishing.rs).
pub hook_targets: Vec<super::fishing::HookTarget>,
pub hook_pulls: Vec<(u32, horizon_core::geometry::Vec3)>,
/// Mace smash knockback on the other players from a guest's turn, waiting
/// for it to end (game/mace.rs).
pub smash_pushes: Vec<super::mace::SmashPush>,
/// Sweep attacks from a guest's turn, waiting for it to end
/// (game/lan_pvp.rs).
pub sweeps: Vec<super::lan_pvp::Sweep>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
@@ -56,6 +63,8 @@ pub(super) struct Creatures {
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// When each player last put a parrot on a shoulder (`timeEntitySatOnShoulder`).
pub shoulder_time: std::collections::HashMap<[i32; 4], i64>,
/// The cat spawner's countdown (`CatSpawner.nextTick`).
pub cat_tick: i32,
/// Particles from a guest's turn, waiting for it to end
@@ -201,6 +210,7 @@ impl Game<'_> {
// EntitySelector.NO_CREATIVE_OR_SPECTATOR.
creative: self.creative || self.spectator(),
sneaking: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
swimming: p.swimming,
look: view_vector(self.yaw, self.pitch),
main_hand: self.held().map(|s| s.item.clone()),
off_hand: None,
@@ -220,6 +230,8 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
sleeping: self.sleeping.is_some(),
sleep_timer: self.sleep_timer,
passenger: self.riding.is_some(),
})
}
@@ -450,9 +462,10 @@ impl Game<'_> {
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
lid_openers: &self.lid_openers,
// The first player in the level: a server has no player of its own.
difficulty: self.local_difficulty(
self.player
.as_ref()
self.level_players()
.first()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]),
),
}
@@ -507,6 +520,7 @@ impl Game<'_> {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
}
self.tick_raids();
self.tick_creature_entities()
@@ -772,11 +786,12 @@ impl Game<'_> {
let by_player = killer.is_some() || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
// takes the experience (before the loot is dropped).
// takes the experience (before the loot is dropped). It takes what
// would drop, whoever killed the mob.
self.sculk_mob_died(m);
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if by_player {
if horizon_client::mobs::drops::drops_experience(&dying) {
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
@@ -936,6 +951,55 @@ impl Game<'_> {
}
}
}
/// Melee or ranged damage to another mob; whether the hit landed.
fn mob_hits_mob(&mut self, target: u32, amount: f32, source: Damage) -> bool {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
}
}
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
{
self.audio.play(
"minecraft:entity.axolotl.attack",
Some([m.position.x, m.position.y, m.position.z]),
1.0,
1.0,
);
}
if killed && let Some(Who::Mob(killer)) = source.attacker {
self.zombie_killed(killer, target);
}
hurt
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
@@ -1015,6 +1079,20 @@ impl Game<'_> {
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
MobEvent::LevelParticles {
kind,
at,
spread,
speed,
count,
} => {
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
let options = horizon_client::particles::ParticleOptions::simple(ty);
self.particles
.send_particles(&options, at, spread, speed, count);
self.lan_level_particles(&options, at, spread, speed, count);
}
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -1043,6 +1121,18 @@ impl Game<'_> {
MobEvent::AttackPlayer(amount, from) => {
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
MobEvent::AttackPlayerWithEffect {
amount,
from,
effect,
ticks,
amplifier,
} => {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.give_player_effect(effect, ticks, amplifier);
}
}
MobEvent::DamagePlayer { amount, from, kind } => {
self.mob_hits_player(amount, from, kind);
}
@@ -1141,54 +1231,48 @@ impl Game<'_> {
amount,
source,
} => {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
if hit {
killed = !t.alive();
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
}
}
self.mob_hits_mob(target, amount, source);
}
MobEvent::SonicBoom {
target,
attacker,
from,
direction,
} => {
// SonicBoom: the push only follows a hit that landed, scaled
// by the target's KNOCKBACK_RESISTANCE.
let (hurt, resistance) = match target {
Who::Player => {
let hurt = self.mob_hits_player(10.0, from, "minecraft:sonic_boom");
let resistance = self.player.as_mut().map_or(0.0, |p| {
p.attribute("minecraft:knockback_resistance").unwrap_or(0.0)
});
(hurt, resistance)
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Axolotl)
{
self.audio.play(
"minecraft:entity.axolotl.attack",
Some([m.position.x, m.position.y, m.position.z]),
1.0,
1.0,
);
}
if killed && let Some(Who::Mob(killer)) = source.attacker {
self.zombie_killed(killer, target);
Who::Mob(id) => {
let source = Damage {
kind: DamageKind::SonicBoom,
attacker: Some(Who::Mob(attacker)),
from: Some(from),
};
let hurt = self.mob_hits_mob(id, 10.0, source);
let resistance = self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| self.mob_knockback_resistance(m));
(hurt, resistance)
}
};
if hurt {
let horizontal = 2.5 * (1.0 - resistance);
let vertical = 0.5 * (1.0 - resistance);
let velocity = [
direction[0] * horizontal,
direction[1] * vertical,
direction[2] * horizontal,
];
self.handle_mob_events(vec![MobEvent::Push { target, velocity }])?;
}
}
MobEvent::FinishAdmiring { mob } => self.finish_admiring(mob),
@@ -1218,6 +1302,7 @@ impl Game<'_> {
}
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
MobEvent::ParrotToShoulder { mob } => self.parrot_to_shoulder(mob),
MobEvent::Infested { mob } => self.mob_infested(mob),
MobEvent::Gift { target, item } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
@@ -1236,6 +1321,14 @@ impl Game<'_> {
m.ai.target = Some(target);
}
}
MobEvent::HuntHoglin { mob, target, ticks } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.ints[0] = ticks;
if m.ai.target.is_none() {
m.ai.target = Some(Who::Mob(target));
}
}
}
MobEvent::Projectile(mut p) => {
self.next_entity += 1;
p.id = self.next_entity;
@@ -1269,6 +1362,22 @@ impl Game<'_> {
[0.0, 0.2, 0.0],
10,
),
MobEvent::DropGift { table, at } => {
let stacks =
self.res
.items()
.loot_table(table, false, None, &mut self.world.random);
for stack in stacks {
// The ItemEntity constructor's random motion.
let r = &mut self.world.random;
let v = [
r.next_double() * 0.2 - 0.1,
0.2,
r.next_double() * 0.2 - 0.1,
];
self.spawn_item(stack, at, v, 10);
}
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
@@ -1331,8 +1440,50 @@ impl Game<'_> {
self.world.set(pos[0], pos[1], pos[2], state)?;
}
}
MobEvent::Infest {
pos,
infested,
poof,
} => {
let registry = &self.pack.assets.registry;
let old = self.world.block(pos[0], pos[1], pos[2]);
if let Some(mut state) = registry.state(old).cloned() {
let (from, to) = if infested {
("minecraft:", "minecraft:infested_")
} else {
("minecraft:infested_", "minecraft:")
};
state.name = state.name.replacen(from, to, 1);
if let Ok(id) = registry.resolve(&state) {
self.world.set(pos[0], pos[1], pos[2], id)?;
}
}
if let Some(mob) = poof {
self.mob_poof(mob);
}
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
// InfestedBlock.spawnAfterBreak: its silverfish comes out
// when blocks drop.
if drop
&& self
.res
.facts()
.get(old)
.name
.starts_with("minecraft:infested_")
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
@@ -1420,6 +1571,27 @@ impl Game<'_> {
.list
.retain(|e| e.id != "mining_fatigue");
}
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
let Some(at) = self.mobs.iter().find(|m| m.id == mob).map(|m| {
[
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
]
}) else {
continue;
};
let names = self.structure_tag("minecraft:dolphin_located");
let found = self
.end
.locator()
.filter(|_| !names.is_empty())
.and_then(|t| t.locate(&names, at, 50));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.extra.home = found.map(|(_, p)| p);
}
}
MobEvent::EffectPlayer {
effect,
ticks,
@@ -2344,6 +2516,22 @@ impl Game<'_> {
8.0,
)
}
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
/// rings it, and a player's shot counts as the player's ring.
fn bell_projectile_hit(&mut self, p: &Projectile, pos: [i32; 3], at: Vec3, face: [i32; 3]) {
let Some(face) = Direction::ALL.into_iter().find(|d| d.normal() == face) else {
return;
};
let block = BlockPos::new(pos[0], pos[1], pos[2]);
let rang = self
.with_level(|b, level, ticks| b.bell_projectile_hit(level, ticks, block, face, at.y));
if rang {
let _ = self.process_level_events();
if p.owner == Some(Who::Player) {
self.stats.custom("bell_ring", 1);
}
}
}
/// 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};
@@ -2486,6 +2674,13 @@ impl Game<'_> {
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
if let Hit::Block { pos, at, face } = hit
&& face != [0; 3]
{
self.bell_projectile_hit(p, pos, at, face);
}
self.combat.projectile_owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
_ => None,
@@ -2660,6 +2855,17 @@ impl Game<'_> {
};
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
}
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
// (piercing arrows are not modelled).
if hurt
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
&& !m.alive()
{
let victim = self.mob_entity(m);
let weapon =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
self.trigger_killed_by_arrow(&[victim], weapon);
}
}
hurt
}
@@ -2963,7 +3169,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& m.damage(4.0, source)
{
m.extra.levitation = 200;
m.add_effect("minecraft:levitation", 200, 0);
// Shulker.hurtServer: a shulker hit by a bullet.
horizon_client::mobs::shulker::hit_by_bullet(m);
}
@@ -3127,6 +3333,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_shear_mob(index, &item) {
self.swing_arm();
return Ok(true);
}
// AbstractHorse.mobInteract: feed, equip or ride.
if self.interact_mount(index, &item) {
self.swing_arm();
@@ -3151,18 +3361,37 @@ impl Game<'_> {
.item_tags
.get("minecraft:creeper_igniters")
.is_some_and(|t| t.contains(&*item));
let fish = self
.res
.items()
.item_tags
.get("minecraft:fishes")
.is_some_and(|t| t.contains(&*item));
let damageable = self
.res
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let parrot_poisonous = self
.res
.items()
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item));
let player_at = self.eye_position();
let acting = self.acting_player_uuid();
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft)
|| self.keys.contains(&winit::keyboard::KeyCode::ShiftRight);
let held_damage = held.as_ref().map_or(0, |h| h.damage as i32);
let held_extra = held.as_ref().and_then(|h| h.extra.clone());
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
}
let species = m.species();
let center = m.center();
// Wolf armor the owner takes off with shears or leaves a dropped stack of.
let mut armor_drop: Option<ItemStack> = None;
let mut consume = false;
let mut give: Option<&str> = None;
let mut sound: Option<&str> = None;
@@ -3247,6 +3476,23 @@ impl Game<'_> {
handled = false;
}
}
// Entity.interact: shears take a tamed wolf's armor off for its owner.
Species::Wolf
if item == "minecraft:shears"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.head.is_some()
&& !sneaking =>
{
let mut stack = ItemStack::new("minecraft:wolf_armor", 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.take();
armor_drop = Some(stack);
m.extra.head = None;
m.extra.body_damage = 0;
eat = Some(("item.armor.unequip_wolf", 1.0, 1.0));
self.damage_held_tool();
}
// Wolf.mobInteract: heal, dye the collar or sit/stand when tame;
// a bone tames a wild one a third of the time.
Species::Wolf if m.extra.tame && is_food && m.health < m.attrs.max_health => {
@@ -3255,11 +3501,42 @@ impl Game<'_> {
.items()
.get(&item)
.and_then(|i| i.food.as_ref())
.map_or(2.0, |f| f.0 as f32);
// TamableAnimal.feed: playEatingSound, which wolves leave empty.
.map_or(2.0, |f| f.0 as f32 * 2.0);
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
// then playEatingSound, which wolves leave empty.
m.health = (m.health + heal).min(m.attrs.max_health);
consume = true;
}
// Wolf.mobInteract: the owner puts wolf armor on a tamed adult.
Species::Wolf
if item == "minecraft:wolf_armor"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.head.is_none()
&& !m.is_baby() =>
{
m.extra.head = Some(item.as_str().into());
m.extra.body_damage = held_damage;
m.extra.body_extra = held_extra;
consume = true;
eat = Some(("item.armor.equip_wolf", 1.0, 1.0));
}
// Wolf.mobInteract: an armadillo scute repairs a sitting wolf's
// armor by an eighth of its durability.
Species::Wolf
if item == "minecraft:armadillo_scute"
&& m.extra.tame
&& m.extra.owner == Some(acting)
&& m.extra.sitting
&& m.extra.head.is_some()
&& m.extra.body_damage > 0 =>
{
m.extra.body_damage = (m.extra.body_damage
- (horizon_client::mobs::WOLF_ARMOR_DURABILITY as f32 * 0.125) as i32)
.max(0);
consume = true;
eat = Some(("item.wolf_armor.repair", 1.0, 1.0));
}
Species::Wolf
if m.extra.tame
&& m.extra.owner == Some(acting)
@@ -3379,6 +3656,20 @@ impl Game<'_> {
m.position.z.floor() as i32 >> 4,
));
}
// Parrot.mobInteract: the owner makes a tame parrot on the ground
// sit or stand (poisonous food is handled apart).
Species::Parrot
if m.extra.tame
&& m.extra.owner == Some(acting)
&& m.on_ground
&& !parrot_poisonous =>
{
m.extra.sitting = !m.extra.sitting;
self.world.modified.insert((
m.position.x.floor() as i32 >> 4,
m.position.z.floor() as i32 >> 4,
));
}
Species::Cat if !m.extra.tame && is_food => {
consume = true;
m.persistent = true;
@@ -3425,6 +3716,29 @@ impl Game<'_> {
consume = true;
}
}
// Parrot.mobInteract: #parrot_poisonous_food (a cookie) poisons the
// parrot for 45 s and then hurts it with a player attack of
// Float.MAX_VALUE, which an invulnerable one shrugs off.
Species::Parrot
if self
.res
.items()
.item_tags
.get("minecraft:parrot_poisonous_food")
.is_some_and(|t| t.contains(&*item)) =>
{
consume = true;
m.add_effect("minecraft:poison", 900, 0);
let id = m.id;
let hurt = m.damage(
f32::MAX,
horizon_client::mobs::Damage::player(player_at.unwrap_or(center)),
);
// The feeder gets the kill, as for a normal player attack.
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
// Parrot.mobInteract: parrot food may tame a wild one (one time in
// ten); a parrot never takes it as Animal food (isFood is false).
Species::Parrot if !m.extra.tame && is_food => {
@@ -3436,7 +3750,7 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(10).unwrap_or(1) == 0;
if tamed {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
}
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
@@ -3445,6 +3759,18 @@ impl Game<'_> {
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Dolphin.mobInteract: a fish makes a baby grow by a tenth and an
// adult remember it (`gotFish`), to swim to a treasure.
Species::Dolphin if fish => {
eat = Some(("entity.dolphin.eat", 1.0, 1.0));
if m.extra.age < 0 {
let secs = ((-m.extra.age / 20) as f32 * 0.1) as i32;
horizon_client::mobs::ai::age_up(m, secs, true);
} else {
m.extra.flag = true;
}
consume = true;
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
@@ -3458,6 +3784,22 @@ impl Game<'_> {
horizon_client::particles::Burst::HORSE_EATING,
);
}
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
// its `getBeeInteractionEffect` instead of breeding the bee.
Species::Bee
if is_food
&& matches!(
&*item,
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
) =>
{
if &*item == "minecraft:wither_rose" {
m.add_effect("minecraft:wither", 40, 0);
} else {
m.add_effect("minecraft:poison", 25, 0);
}
consume = true;
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3528,6 +3870,9 @@ impl Game<'_> {
self.spawn_item(rest, at, [0.0, 0.0, 0.0], 40);
}
}
if let Some(stack) = armor_drop {
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let v = [
@@ -3550,6 +3895,39 @@ impl Game<'_> {
}
Ok(true)
}
/// `SnowGolem.mobInteract` and `Bogged.mobInteract` with shears: the
/// pumpkin or the mushrooms come off (the shearing loot table), the shear
/// game event is posted and the tool takes one damage.
fn interact_shear_mob(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:shears" || !m.alive() || m.extra.sheared {
return false;
}
let (sound, table, height) = match m.species() {
Species::SnowGolem => (
"minecraft:entity.snow_golem.shear",
"minecraft:shearing/snow_golem",
m.eye_height(),
),
Species::Bogged => (
"minecraft:entity.bogged.shear",
"minecraft:shearing/bogged",
f64::from(m.height()),
),
_ => return false,
};
let at = m.position;
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", [at.x, at.y, at.z], ctx);
self.damage_held_tool();
true
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
@@ -3898,6 +4276,18 @@ impl Game<'_> {
&armor,
out,
);
if &*mob.kind.id == "minecraft:player" {
// PlayerRenderer's WingsLayer and SpinAttackEffectLayer.
self.player_elytra_geometry(
mob,
mob.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
}
self.mob_block_geometry(mob, camera, alpha, packed, out);
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
@@ -4132,6 +4522,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
match kind {
K::PlayerAttack => "minecraft:player_attack",
K::MaceSmash => "minecraft:mace_smash",
K::Spear => "minecraft:spear",
K::MobAttack => "minecraft:mob_attack",
K::Arrow => "minecraft:arrow",
K::Thrown => "minecraft:thrown",
@@ -4148,6 +4539,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
K::SweetBerryBush => "minecraft:sweet_berry_bush",
K::Generic => "minecraft:generic",
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
@@ -4172,6 +4564,7 @@ fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
}
}
/// A generated loot stack as a Horizon item stack.
@@ -4191,6 +4584,9 @@ fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
item.data_mut().ominous_amplifier = Some(amplifier as u8);
}
for (id, text) in &stack.names {
item.data_mut().set_loot_name(id, text);
}
item
}
impl Game<'_> {
@@ -4204,13 +4600,10 @@ impl Game<'_> {
f64::from((pos[0] >> 4) * 16 + 8),
f64::from((pos[2] >> 4) * 16 + 8),
];
let spectator = self.spectator();
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
let close = self.level_players().iter().any(|p| {
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
dx * dx + dz * dz < 16384.0
}) else {
return;
};
!p.spectator && dx * dx + dz * dz < 16384.0
});
if !close {
return;
}
@@ -4234,6 +4627,9 @@ impl Game<'_> {
f64::from(y + 1),
f64::from(pos[2]) + 0.5,
];
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
// setPortalCooldown: the new piglin does not travel at once.
if let Some(id) = self.spawn_mob("minecraft:zombified_piglin", at, false) {
self.set_portal_cooldown(id);
}
}
}
@@ -219,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
@@ -235,6 +236,11 @@ impl Game<'_> {
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
self.damage_held(if rocket { 3 } else { 1 });
}
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
}
}
}
+3 -2
View File
@@ -244,7 +244,8 @@ impl Game<'_> {
if on { "visible" } else { "hidden" }
)
};
let remote = self.remote.is_some();
// `DebugScreenOverlay`: the single-player variants for a local server.
let remote = !self.has_singleplayer_server();
let c = &self.debug_charts;
[
format!(
@@ -463,7 +464,7 @@ impl Game<'_> {
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
self.remote.is_some(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
+62 -1
View File
@@ -412,6 +412,8 @@ struct ServerState {
paused: bool,
/// `broadcast-console-to-ops`.
broadcast_console: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -573,6 +575,14 @@ impl Game<'_> {
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
self.set_default_game_mode(game_mode);
self.ui.difficulty = props.difficulty as u8;
// DedicatedServer.initServer: a legacy `announce-player-achievements`
// sets the show_advancement_messages rule once, as the file is read.
if let Some(announce) = props.announce_player_achievements {
self.ui.rules.set(
"show_advancement_messages",
horizon_client::gamerules::Value::Bool(announce),
);
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
@@ -663,6 +673,9 @@ impl Game<'_> {
return Ok(());
}
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
}
log(
"INFO",
&format!(
@@ -685,6 +698,7 @@ impl Game<'_> {
ticks: Default::default(),
paused: false,
broadcast_console: props.broadcast_console_to_ops,
op_level: props.op_permission_level,
};
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
@@ -946,7 +960,7 @@ impl Game<'_> {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let level = 4;
let level = state.op_level;
let changed = {
let mut l = state.lists.lock().unwrap();
if command == "op" {
@@ -972,6 +986,10 @@ impl Game<'_> {
self.console_reply(Text::translate(key, &[lit(&user.name)]));
let lists = state.lists.clone();
self.sync_ops(&lists);
// DeOpCommands.deopPlayers.
if command == "deop" && changed {
self.kick_unlisted_players(&lists);
}
}
"whitelist" => self.console_whitelist(state, &rest),
"ban" if !rest.is_empty() => {
@@ -1183,6 +1201,7 @@ impl Game<'_> {
["on"] | ["off"] => {
let on = rest[0] == "on";
let mut l = state.lists.lock().unwrap();
let turned_on = on && !l.use_whitelist;
let key = match (on, l.use_whitelist) {
(true, true) => "commands.whitelist.alreadyOn",
(true, false) => "commands.whitelist.enabled",
@@ -1193,6 +1212,10 @@ impl Game<'_> {
drop(l);
self.store_property("white-list", on.to_string());
self.console_reply(Text::translate(key, &[]));
if turned_on {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
["list"] => {
let names: Vec<String> = state
@@ -1216,6 +1239,8 @@ impl Game<'_> {
["reload"] => {
let _ = state.lists.lock().unwrap().white.load();
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
["add", name] | ["remove", name] => {
let Some(user) = self.resolve(name) else {
@@ -1241,6 +1266,10 @@ impl Game<'_> {
Vec::new()
};
self.console_reply(Text::translate(key, &args));
if ok && rest[0] == "remove" {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
}
@@ -1256,6 +1285,38 @@ impl Game<'_> {
}
}
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
/// whitelist on, whoever is online and not on the whitelist file is
/// disconnected (operators are not spared here).
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
let unlisted: Vec<u128> = {
let l = lists.lock().unwrap();
let Some(host) = self.lan.as_ref() else {
return;
};
if !(l.enforce_whitelist && l.use_whitelist) {
return;
}
host.hub
.guests
.iter()
.filter(|g| {
!l.white.contains(&NameAndId {
id: g.uuid,
name: g.name.clone(),
})
})
.map(|g| g.uuid)
.collect()
};
for uuid in unlisted {
self.kick_uuid(
uuid,
translatable("multiplayer.disconnect.not_whitelisted", &[]),
);
}
}
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h
@@ -672,6 +672,7 @@ impl Game<'_> {
}
let source = match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
_ => "minecraft:player_attack",
};
out.push(format!(
@@ -1548,6 +1548,7 @@ impl Game<'_> {
return Ok((Some(stack), FAIL));
};
let target = (placed.x, placed.y, placed.z);
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
self.world.block_entities.insert(target, entity);
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
+19 -16
View File
@@ -1122,14 +1122,11 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let modifiers = self
.attack_item_info()
.map(|i| (i.attack_damage, i.attack_speed))
.unwrap_or((0.0, 0.0));
let speed = (4.0 + modifiers.1).max(0.1);
let scale = ((self.attack_ticks as f32 + 0.5) / (20.0 / speed)).clamp(0.0, 1.0);
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
let mut amount =
(1.0 + modifiers.0 + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
let falling = self
.player
.as_ref()
@@ -1465,7 +1462,16 @@ impl Game<'_> {
}
}
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
if d2 < 4096.0 {
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
// its 8 block height), blocks only.
let sees = d2 < 4096.0 && {
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
let target_eye =
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
self.mob_world_base(&[])
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
};
if sees {
*charge += 1;
let d = self.dragon.as_ref().expect("dragon");
let r = d.yaw.to_radians();
@@ -3020,14 +3026,11 @@ impl Game<'_> {
let (a, b) = (room.min_position(), room.max_position());
lo.x < b.x && hi.x > a.x && lo.y < b.y && hi.y > a.y && lo.z < b.z && hi.z > a.z
};
let player = self.player.as_ref().is_some_and(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
touches(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(p.position.x + w, p.position.y + h, p.position.z + w),
)
});
// Level.getEntities: the host's player and every guest in the level.
let player = self
.fight_players()
.iter()
.any(|p| !p.spectator && touches(p.bounds.min_position(), p.bounds.max_position()));
player
|| self.crystals.iter().any(|c| {
touches(
@@ -3,14 +3,18 @@
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
//! the armor attribute effects (respiration, depth strider, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
//! the armor attribute effects (respiration, depth strider, ...), Soul
//! Speed's `location_changed` and `tick` effects and the
//! `minecraft:location_changed` disk effect (frost walker).
use super::Game;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
mobs::Who,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
use serde_json::Value;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -69,7 +73,7 @@ impl Game<'_> {
}
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return 0.0;
};
let ctx = EffectContext {
@@ -102,7 +106,7 @@ impl Game<'_> {
/// whether the hit is that knockback attack.
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
let enchanted = match self.inventory[self.selected].clone() {
let enchanted = match self.held().cloned() {
Some(held) => {
let ctx = EffectContext {
this: Some(target_kind),
@@ -144,7 +148,7 @@ impl Game<'_> {
}
/// After a successful melee hit: fire aspect and wind burst.
pub(super) fn post_attack(&mut self, mob: usize) {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return;
};
let Some(m) = self.mobs.get(mob) else { return };
@@ -211,6 +215,106 @@ impl Game<'_> {
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
/// `minecraft:post_piercing_attack` effects of the weapon in hand after a
/// jab (Lunge's dash).
pub(super) fn post_piercing_attack(&mut self) {
let Some(held) = self.held().cloned() else {
return;
};
let mut effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
for entry in def.effects["minecraft:post_piercing_attack"]
.as_array()
.into_iter()
.flatten()
{
effects.push((*level, entry["effect"].clone()));
}
}
// Lunge's requirements: not riding, gliding or in water, and a
// player outside creative with at least 7 food.
let Some(p) = self.player.as_ref() else {
return;
};
if effects.is_empty()
|| self.riding.is_some()
|| p.fall_flying
|| p.fluids.was_touching_water
|| !(self.creative || p.health.food.food >= 7)
{
return;
}
for (level, effect) in effects {
self.apply_piercing_effect(&effect, level);
}
}
/// One `post_piercing_attack` effect on the player: `AllOf`,
/// `ChangeItemDamage`, `ApplyExhaustion`, `ApplyEntityImpulse` and
/// `PlaySoundEffect`.
fn apply_piercing_effect(&mut self, effect: &serde_json::Value, level: u32) {
let amount = || level_value(&effect["amount"], level);
match effect["type"].as_str() {
Some("minecraft:all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.apply_piercing_effect(e, level);
}
}
Some("minecraft:change_item_damage") => self.damage_held(amount() as u32),
Some("minecraft:apply_exhaustion") => {
if let Some(p) = self.player.as_mut() {
p.cause_food_exhaustion(amount());
}
}
// The direction turned by the look (`Entity.getLookQuaternion`:
// pitch about x, then yaw about y), scaled by coordinate_scale
// and the magnitude, added to the velocity (the guest's client
// is told of it by `guest_hit_by`).
Some("minecraft:apply_impulse") => {
let vec3 = |key: &str| [0, 1, 2].map(|i| effect[key][i].as_f64().unwrap_or(0.0));
let (d, scale) = (vec3("direction"), vec3("coordinate_scale"));
let magnitude = f64::from(level_value(&effect["magnitude"], level));
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
);
let (y1, z1) = (
d[1] * pitch.cos() - d[2] * pitch.sin(),
d[1] * pitch.sin() + d[2] * pitch.cos(),
);
let impulse = horizon_core::geometry::Vec3::new(
(d[0] * yaw.cos() - z1 * yaw.sin()) * scale[0] * magnitude,
y1 * scale[1] * magnitude,
(d[0] * yaw.sin() + z1 * yaw.cos()) * scale[2] * magnitude,
);
if let Some(p) = self.player.as_mut() {
p.velocity = p.velocity.add_vector(impulse);
}
}
// The sound for the level (a list picks by level), heard by all.
Some("minecraft:play_sound") => {
let sounds: Vec<&str> = match &effect["sound"] {
serde_json::Value::Array(list) => {
list.iter().filter_map(|s| s.as_str()).collect()
}
one => one.as_str().into_iter().collect(),
};
let index = (level as usize).saturating_sub(1);
if let Some(sound) = sounds.get(index.min(sounds.len().saturating_sub(1))) {
let float = |key: &str| effect[key].as_f64().unwrap_or(1.0) as f32;
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio.play(sound, at, float("volume"), float("pitch"));
}
}
_ => {}
}
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
@@ -636,6 +740,173 @@ impl Game<'_> {
self.apply_equipment();
}
}
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
/// runs it when the block position changed, `checkFallDamage` when it lands.
/// `from` and `was_on_ground` are where and how the tick began.
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let on_ground = p.ground.on_ground;
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
return;
}
let riding = self.riding.is_some();
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
for (effect, level) in disks {
self.replace_disk(&effect, level, at);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
return;
};
let Some(mut state) = registry.state(default).cloned() else {
return;
};
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
state
.properties
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
}
let new = registry.resolve(&state).unwrap_or(default);
let radius = level_value(&effect["radius"], level) as i32;
let height = level_value(&effect["height"], level) as i32;
let offset = cell_offset(effect);
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
for dy in (height - 1).min(0)..=0 {
for dz in -radius..=radius {
for dx in -radius..=radius {
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
let (x, z) = (
f64::from(pos[0]) + 0.5 - at[0],
f64::from(pos[2]) + 0.5 - at[2],
);
if x * x + z * z >= f64::from(radius * radius)
|| !self.disk_predicate(&effect["predicate"], pos, new)
|| self.level_set(pos, new).is_err()
{
continue;
}
if event {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(Who::Player),
Some(new),
);
}
}
}
}
}
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
/// predicate matches everything.
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
if p.is_null() {
return true;
}
let offset = cell_offset(p);
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
let state = self.world.block(at[0], at[1], at[2]);
let b = self.res.behaviours();
let names = |v: &Value| -> Vec<String> {
match v {
Value::String(s) => vec![s.clone()],
Value::Array(a) => a
.iter()
.filter_map(|n| n.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
};
match p["type"].as_str().unwrap_or("") {
"minecraft:all_of" => p["predicates"]
.as_array()
.into_iter()
.flatten()
.all(|q| self.disk_predicate(q, pos, placed)),
// `MatchingBlockTagPredicate`
"minecraft:matching_block_tag" => b
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
.is_some_and(|t| t.contains(b.block(state))),
// `MatchingBlocksPredicate`
"minecraft:matching_blocks" => {
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
}
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
"minecraft:matching_fluids" => {
let fluid = b.fluid(state);
names(&p["fluids"]).iter().any(|n| match n.as_str() {
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
"minecraft:flowing_water" => {
fluid.kind() == FluidKind::Water && !fluid.is_source()
}
_ => false,
})
}
// `UnobstructedPredicate`: no building-blocking entity in the block.
"minecraft:unobstructed" => self.with_level(|_, level, _| {
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
}),
_ => false,
}
}
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
/// damage (frost walker's `minecraft:damage_immunity`).
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
self.armor
.iter()
.zip(["head", "chest", "legs", "feet"])
.any(|(stack, slot)| {
stack
.iter()
.flat_map(ItemStack::enchantments)
.any(|(id, _)| {
self.res.stations().enchantments.get(id).is_some_and(|def| {
def.effects.get("minecraft:damage_immunity").is_some()
&& def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
})
})
})
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
@@ -684,3 +955,35 @@ fn soul_speed_modifiers(
})
.collect()
}
/// An effect's or predicate's `offset` vector (zero when absent).
fn cell_offset(v: &Value) -> [i32; 3] {
let at = |i: usize| {
v["offset"]
.get(i)
.and_then(Value::as_i64)
.map_or(0, |n| n as i32)
};
[at(0), at(1), at(2)]
}
/// The loot-condition subset a `location_changed` effect requires of the
/// wearer: standing on the ground and not riding (frost walker).
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
match req["type"].as_str().unwrap_or("") {
"" if req.is_null() => true,
"minecraft:all_of" => req["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| location_requirements(t, on_ground, riding)),
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
"minecraft:entity_properties" => {
let p = &req["predicate"];
p["minecraft:flags"]["is_on_ground"]
.as_bool()
.is_none_or(|v| v == on_ground)
&& (p["minecraft:vehicle"].is_null() || riding)
}
_ => false,
}
}
+33 -8
View File
@@ -12,6 +12,7 @@ use horizon_client::{
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
use horizon_core::{
geometry::Vec3,
@@ -581,7 +582,15 @@ impl Game<'_> {
let position = if let Some(job) = self.end.job.take() {
self.resolve_gateway(job, target)?
} else {
let (position, yaw) = self.end_arrival(target)?;
let mut finder = self.player_spawn.take();
let arrival = self.end_arrival(target, &mut finder);
self.player_spawn = finder;
let Some((position, yaw)) = arrival? else {
// A spawn candidate's chunk is loading: try again next tick.
self.travelling = Some((player, target));
self.end_travel = true;
return Ok(());
};
if let Some(yaw) = yaw {
self.yaw = yaw;
player.yaw = yaw;
@@ -606,7 +615,13 @@ impl Game<'_> {
/// Where an End portal's traveller lands once `target` has loaded, and
/// the yaw it faces there: the platform below the End's spawn point,
/// facing west, or the top of the Overworld's spawn column.
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
/// None while a candidate of the world spawn's search loads (`finder`
/// keeps it, see `search_spawn`).
pub(super) fn end_arrival(
&mut self,
target: Vec3,
finder: &mut Option<PlayerSpawnFinder>,
) -> Result<Option<(Vec3, Option<f32>)>> {
if self.dim == Dim::End {
// EndPlatformFeature.createEndPlatform below the spawn point.
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
@@ -626,22 +641,32 @@ impl Game<'_> {
}
}
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
return Ok(Some((target, Some(90.0))));
}
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok((at, Some(yaw)));
return Ok(Some((at, Some(yaw))));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
}
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
Ok((Vec3::new(target.x, y, target.z), None))
let (x, z) = (target.x.floor() as i32, target.z.floor() as i32);
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder around
// the world spawn.
if let Some(world) = self.level.world_spawn.filter(|w| w[0] == x && w[2] == z) {
return Ok(match self.search_spawn(world, finder) {
Ok(at) => Some((at, None)),
Err(chunk) => {
self.spawn_wait.push(chunk);
None
}
});
}
let y = self.land_column(x, z).map_or(target.y, f64::from);
Ok(Some((Vec3::new(target.x, y, target.z), None)))
}
/// Dying away from the Overworld respawns at the world spawn there.
+107 -8
View File
@@ -1,7 +1,7 @@
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
@@ -15,6 +15,7 @@ use horizon_client::{
};
use horizon_core::{
geometry::{Aabb, Vec3, trig},
position::BlockPos,
random::RandomSource,
};
use horizon_protocol::entity_data::{Value, serializer};
@@ -46,6 +47,13 @@ pub(super) struct Hook {
luck: i32,
lure: i32,
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
pub(super) struct HookTarget {
id: u32,
conn: Option<u32>,
bounds: Aabb,
}
const SIZE: f64 = 0.25;
fn bounds(p: Vec3) -> Aabb {
let h = SIZE / 2.0;
@@ -167,6 +175,11 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
.hook_pulls
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
@@ -248,7 +261,7 @@ impl Game<'_> {
item,
[hook.position.x, hook.position.y, hook.position.z],
v,
10,
0,
);
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
@@ -339,9 +352,12 @@ impl Game<'_> {
let mut speed = 1;
let above = [b[0], b[1] + 1, b[2]];
let sky = self.world.light(above[0], above[1], above[2]) >> 4 == 15;
let raining = self.world.weather.raining;
let r = &mut self.world.random;
if r.next_float() < 0.25 && raining && sky {
// Level.isRainingAt: rain (not snow) reaches the position.
if self.world.random.next_float() < 0.25
&& self.with_level(|b, level, _| {
b.is_raining_at(level, BlockPos::new(above[0], above[1], above[2]))
})
{
speed += 1;
}
let r = &mut self.world.random;
@@ -472,6 +488,15 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
&& matches!(h.state, State::Flying | State::Hooked(_))
&& let Some(host) = self.lan.take()
{
self.refresh_hook_targets(&host);
self.lan = Some(host);
}
h.previous = h.position;
if h.on_ground {
h.life += 1;
@@ -497,8 +522,8 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob it touches, or (via
// `canHitEntity`) a dropped item.
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
@@ -510,6 +535,14 @@ impl Game<'_> {
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
self.dropped
.iter()
@@ -524,12 +557,25 @@ impl Game<'_> {
}
}
State::Hooked(id) => {
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.creatures
.hook_targets
.iter()
.find(|t| t.id == id)
.map(|t| {
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
(
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
hi.y - lo.y,
)
})
})
.or_else(|| {
self.dropped
.iter()
@@ -598,6 +644,59 @@ impl Game<'_> {
);
self.hook = Some(h);
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
let mut targets = Vec::new();
if self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
{
targets.push(HookTarget {
id: host.host_entity as u32,
conn: None,
bounds: p.bounds(),
});
}
for g in &host.hub.guests {
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
targets.push(HookTarget {
id: g.entity_id as u32,
conn: Some(g.conn),
bounds: guest_bounds(g),
});
}
}
self.creatures.hook_targets = targets;
}
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
/// pulled: their velocity moves, and a guest's reaches their client.
pub(super) fn flush_hook_pulls(&mut self) {
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
let Some(mut host) = self.lan.take() else {
return;
};
for (id, delta) in pulls {
let push = |g: &mut Self| {
if let Some(p) = g.player.as_mut() {
p.velocity = p.velocity.add_vector(delta);
}
};
if id == host.host_entity as u32 {
push(self);
} else if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id)
.map(|g| g.conn)
{
self.guest_hit_by(&mut host, conn, push);
}
}
self.lan = Some(host);
}
/// `FishingRodCast`: the stack in the arm holding the rod
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
+14
View File
@@ -7,6 +7,7 @@ use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use std::sync::Arc;
@@ -223,6 +224,7 @@ impl Game<'_> {
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
self.swing_arm();
true
}
@@ -255,8 +257,17 @@ impl Game<'_> {
}
self.drop_frame_items(&f, with_frame, true);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
true
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
/// the comparators reading the frame take its new signal.
fn frame_output_changed(&mut self, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
@@ -300,6 +311,9 @@ impl Game<'_> {
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
continue;
}
}
+37 -10
View File
@@ -144,6 +144,11 @@ impl Game<'_> {
let harvest = !facts.requires_tool || self.res.items().correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
}
/// A key for the main-hand stack (`destroyingItem`): its item and components.
pub(super) fn main_hand_key(&self) -> String {
self.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
}
/// Whether an attack would hit an entity rather than the block behind
/// it: the crosshair's `EntityHitResult`.
pub(super) fn entity_targeted(&self) -> bool {
@@ -347,12 +352,21 @@ impl Game<'_> {
_ => name.ends_with("shulker_box"),
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier and the
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
/// the attribute's range.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.attack_item_info()
.map_or(0.0, |i| f64::from(i.attack_speed))
.map_or(0.0, |i| f64::from(i.attack_speed));
self.potions
.effects
.modifiers()
.into_iter()
.filter(|m| m.0 == "minecraft:attack_speed")
.fold(value, |v, m| v * (1.0 + m.2))
.clamp(0.0, 1024.0)
}
/// The item whose attack damage and speed modifiers the player has (`attack_item`).
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
@@ -389,6 +403,12 @@ struct GameHands<'g, 'a> {
error: Option<Box<dyn std::error::Error>>,
}
impl GameHands<'_, '_> {
/// The main-hand item's `kinetic_weapon` and `piercing_weapon`, a spear's.
fn attack_kinetic(&self) -> Option<horizon_client::spear::Kinetic> {
self.game
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
}
fn keep(&mut self, r: Result<()>) {
if let Err(e) = r
&& self.error.is_none()
@@ -456,18 +476,27 @@ impl HandWorld for GameHands<'_, '_> {
self.game.creative
}
fn cannot_attack(&mut self) -> bool {
false
let ticker = self.game.hand_strength.ticker;
self.game.cannot_attack_with_item(ticker, 0)
}
fn piercing_weapon(&mut self) -> bool {
false
self.attack_kinetic().is_some()
}
fn attack_animation(&mut self) -> SwingAnimation {
SwingAnimation::DEFAULT
self.attack_kinetic()
.map_or(SwingAnimation::DEFAULT, |k| k.attack_animation())
}
fn interact_animation(&mut self, _: Hand) -> SwingAnimation {
SwingAnimation::DEFAULT
}
fn piercing_attack(&mut self, _: SwingAnimation) {}
fn piercing_attack(&mut self, _: SwingAnimation) {
// The server's part (`PiercingWeapon.attack`), then the client's:
// the sound `MultiPlayerGameMode.piercingAttack` plays.
if let Some(sound) = self.game.jab() {
self.game.play_at_feet(sound);
}
self.game.hand_strength.reset_attack();
}
fn attack_entity(&mut self) -> bool {
self.game.attack()
}
@@ -503,9 +532,7 @@ impl HandWorld for GameHands<'_, '_> {
true
}
fn main_hand_key(&mut self) -> String {
self.game
.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
self.game.main_hand_key()
}
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
+35 -16
View File
@@ -212,39 +212,58 @@ impl Game<'_> {
)
.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>> {
let mut finder = match self.player_spawn {
Some(finder) => finder,
None => self.spawn_finder(suggestion),
};
let found = loop {
/// PlayerSpawnFinder.findSpawn around the world spawn, as far as the loaded
/// chunks allow: the feet of the first free column within the respawn
/// radius, else the suggestion's own column. The finder waits in `state`
/// (None to begin); Err gives the chunk of the candidate being waited for,
/// which the caller must have loaded (the finder tries at most 1024
/// columns).
pub(super) fn search_spawn(
&self,
suggestion: [i32; 3],
state: &mut Option<PlayerSpawnFinder>,
) -> std::result::Result<Vec3, (i32, i32)> {
let mut finder = state
.take()
.unwrap_or_else(|| self.spawn_finder(suggestion));
let suggestion = finder.suggestion;
loop {
let Some(candidate) = finder.next_candidate() else {
break Some(self.fixup_spawn_height(suggestion));
return Ok(self.fixup_spawn_height(suggestion));
};
let (x, z) = match candidate {
SpawnCandidate::Column(x, z) => (x, z),
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
};
if !self.streaming.is_lit(x >> 4, z >> 4) {
self.streaming.center = (x >> 4, z >> 4);
finder.retry();
break None;
*state = Some(finder);
return Err((x >> 4, z >> 4));
}
match candidate {
SpawnCandidate::Column(x, z) => {
if let Some(pos) = level_respawn_pos(&self.spawn_columns(), x, z)
&& self.player_fits(pos)
{
break Some(bottom_center(pos));
return Ok(bottom_center(pos));
}
}
SpawnCandidate::Suggestion => break Some(self.fixup_spawn_height(suggestion)),
SpawnCandidate::Suggestion => return Ok(self.fixup_spawn_height(suggestion)),
}
};
self.player_spawn = found.is_none().then_some(finder);
Ok(found)
}
}
/// 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>> {
let mut state = self.player_spawn.take();
match self.search_spawn(suggestion, &mut state) {
Ok(feet) => Ok(Some(feet)),
Err(chunk) => {
self.streaming.center = chunk;
self.player_spawn = state;
Ok(None)
}
}
}
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
/// by the world border; the suggestion itself in adventure mode.
+15 -3
View File
@@ -165,12 +165,18 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
..Default::default()
}
}
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
/// and for `blocks_attacks` items (shields), the consume time for food.
fn use_duration(components: &Components) -> i32 {
if let Some(c) = components.get("minecraft:consumable") {
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
return (seconds * 20.0) as i32;
}
if components.get("minecraft:blocks_attacks").is_some()
|| components.get("minecraft:kinetic_weapon").is_some()
{
return 72_000;
}
let item_model = components
.get("minecraft:item_model")
.and_then(Value::as_str)
@@ -180,8 +186,9 @@ fn use_duration(components: &Components) -> i32 {
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:goat_horn"
| "minecraft:brush" => 72_000,
| "minecraft:goat_horn" => 72_000,
// BrushItem.USE_DURATION.
"minecraft:brush" => 200,
_ => 0,
}
}
@@ -249,6 +256,11 @@ impl Game<'_> {
owner.main_arm_left = self.settings.main_hand_left;
owner.is_local_player = true;
owner.is_view_entity = true;
owner.last_death_location = self
.level
.last_death
.clone()
.map(|(dimension, pos)| GlobalPos { dimension, pos });
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
&& self.stack_in(slot) == Some(stack)
{
+34 -3
View File
@@ -6,10 +6,15 @@ use super::Game;
use horizon_client::{
containers::{BlockEntity, Jukebox},
items::ItemStack,
mobs::Species,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::level::flags as level_flags;
impl Game<'_> {
@@ -97,7 +102,7 @@ impl Game<'_> {
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
self.stop_song(pos);
self.stop_sound(pos);
let Some(s) = self.res.items().songs.get(song).cloned() else {
return;
};
@@ -109,12 +114,38 @@ impl Game<'_> {
self.titles
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
self.update_song_volumes();
self.notify_record_playing(pos, true);
if self.audio.capture.is_some() {
self.lan_songs.push((pos, Some(song.to_owned())));
}
}
fn stop_song(&mut self, pos: [i32; 3]) {
fn stop_sound(&mut self, pos: [i32; 3]) {
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
self.audio.stop_and_release(id);
}
}
/// Level event 1011: the song ends, here and for the guests.
fn stop_song(&mut self, pos: [i32; 3]) {
self.stop_sound(pos);
self.notify_record_playing(pos, false);
if self.audio.capture.is_some() {
self.lan_songs.push((pos, None));
}
}
/// `LevelEventHandler.notifyNearbyEntities` (level events 1010 and 1011):
/// parrots within three blocks of the jukebox start or stop dancing.
pub(super) fn notify_record_playing(&mut self, pos: [i32; 3], playing: bool) {
let min = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let area = Aabb::new(min, min.add_vector(Vec3::new(1.0, 1.0, 1.0)))
.inflate(Vec3::new(3.0, 3.0, 3.0));
for m in self
.mobs
.iter_mut()
.filter(|m| m.species() == Species::Parrot && m.bounds().intersects(area))
{
m.extra.party_jukebox = playing.then_some(pos);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
fn eject_record(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
+277 -20
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
mobs::{Mob, Species, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -168,6 +168,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:hoglin"
| "minecraft:zoglin"
| "minecraft:goat"
| "minecraft:ocelot"
| "minecraft:turtle"
@@ -324,9 +325,13 @@ fn mob_meta(
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
// (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, swimming 0x10 (a drowned
// under water, `Drowned.setSwimming`), invisible 0x20 (the effect's flag)
// and glowing 0x40 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0)
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
@@ -336,6 +341,12 @@ fn mob_meta(
if pose != 0 {
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
if horizon_client::mobs::villager::sleeping(m)
&& let Some(bed) = m.extra.bed
{
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
}
if let Some(name) = &m.custom_name {
meta.push((
2,
@@ -430,8 +441,14 @@ fn mob_meta(
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (16: with the dimension it
// makes it shake) and Piglin.DATA_IS_DANCING (after the baby flag and
// the crossbow charge).
"minecraft:piglin" => {
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
@@ -457,6 +474,13 @@ fn mob_meta(
}
defaults.push((19, serializer::BYTE, Value::Byte(0)));
}
"minecraft:parrot" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
if flags != 0 {
meta.push((18, serializer::BYTE, Value::Byte(flags)));
}
}
"minecraft:cat" | "minecraft:wolf" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
@@ -478,6 +502,15 @@ fn mob_meta(
if let Some(v) = id(registry, name) {
meta.push((index, serializer, Value::Id(v)));
}
// Cat.IS_LYING and RELAX_STATE_ONE.
if kind == "minecraft:cat" {
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
if on {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
}
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
let red = i32::from(horizon_client::mobs::tame::RED);
@@ -502,10 +535,16 @@ fn mob_meta(
| "minecraft:skeleton_horse"
| "minecraft:zombie_horse"
| "minecraft:camel" => {
// AbstractHorse.DATA_ID_FLAGS: tame 2.
if e.tame {
meta.push((18, serializer::BYTE, Value::Byte(2)));
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
// 32 (rearing).
let grazes = horizon_client::mobs::equine::grazes(m.species());
let flags = if e.tame { 2 } else { 0 }
| if grazes && e.flag { 16 } else { 0 }
| if grazes && e.counter > 0 { 32 } else { 0 };
if flags != 0 {
meta.push((18, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
if kind == "minecraft:horse" {
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
if v != 0 {
@@ -527,9 +566,26 @@ fn mob_meta(
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
}
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
"minecraft:happy_ghast" => {
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
"minecraft:villager" | "minecraft:zombie_villager" => {
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
if kind == "minecraft:villager" {
unhappy_counter(m, meta, defaults);
} else {
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
if horizon_client::mobs::zombie_villager::is_converting(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
if let (Some(t), Some(p)) = (
@@ -724,7 +780,7 @@ fn mob_meta(
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
}
// Enderman.DATA_CARRY_STATE.
// Enderman.DATA_CARRY_STATE and DATA_CREEPY (angry: the open mouth).
"minecraft:enderman" => {
if let Some(state) = e.carried {
meta.push((
@@ -733,6 +789,64 @@ fn mob_meta(
Value::Id(state.0 as i32),
));
}
if e.flag {
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
"minecraft:zombie" | "minecraft:husk" => {
if e.counter2 > 0 {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's, the first entry of its subclasses (the piglin's is
// in its own arm): with the dimension it makes them shake.
"minecraft:hoglin" | "minecraft:piglin_brute" => {
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
if e.flag2 {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
"minecraft:blaze" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
"minecraft:turtle" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
// main target): the entities the side heads look at, by id.
"minecraft:wither" => {
for (k, index) in [(0, 17), (1, 18)] {
let target = match e.ints[k] {
0 => None,
-1 => player_entity,
id => Some(id),
};
if let Some(target) = target {
meta.push((index, serializer::INT, Value::Int(target)));
}
defaults.push((index, serializer::INT, Value::Int(0)));
}
}
_ => {}
}
@@ -750,6 +864,19 @@ fn mob_meta(
}
}
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
/// shake of a villager saying no counts it down from 40.
fn unhappy_counter(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.extra.ints[0] > 0 {
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
}
defaults.push((18, serializer::INT, Value::Int(0)));
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
@@ -794,8 +921,17 @@ fn mob_equipment(
let stack = ItemStack {
item: item.clone(),
count: 1,
damage: 0,
extra: None,
// A wolf's armor shows its cracks.
damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
e.body_damage.max(0) as u32
} else {
0
},
extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
e.body_extra.clone()
} else {
None
},
};
out.push((slot, hub.stack(&Some(stack))));
}
@@ -1014,7 +1150,8 @@ impl Game<'_> {
let host_uuid = host.unwrap_or(0);
self.next_entity += 1;
let host_entity = self.next_entity as i32;
let hub = Hub::new(lan, sync, encoder, config);
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
let port = hub.lan.port;
self.world.block_log = Some(Vec::new());
self.world.light_log = Some(Default::default());
@@ -1077,6 +1214,7 @@ impl Game<'_> {
self.world.light_log = None;
self.audio.capture = None;
self.lan_events.clear();
self.lan_songs.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
@@ -1299,6 +1437,9 @@ impl Game<'_> {
&mut e.meta,
&mut e.defaults,
);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
@@ -1477,6 +1618,16 @@ impl Game<'_> {
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
@@ -1511,6 +1662,13 @@ impl Game<'_> {
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
@@ -1551,6 +1709,10 @@ impl Game<'_> {
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
let _ = self.flush_wind_bursts();
// A sweep attack's reach to the other players from a guest's attack.
self.flush_sweeps();
// A reeled-in hook pulling a player.
self.flush_hook_pulls();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
@@ -1653,10 +1815,11 @@ impl Game<'_> {
match e {
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), nor
// the digger's own crack and hit sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing (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 | 3008) {
None
} else {
self.lan_acting
@@ -1690,6 +1853,11 @@ impl Game<'_> {
if self.audio.capture.is_some() {
self.lan_entity_events.push((id, event, at));
}
// Event 21: a window that simulates the world itself is its own
// client, and hears the guardian's hum too.
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
}
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
@@ -1718,6 +1886,7 @@ impl Game<'_> {
/// 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);
let kinetic_hits = std::mem::take(&mut self.lan_kinetic_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
@@ -1752,6 +1921,16 @@ impl Game<'_> {
}
host.hub.level_event(kind, pos, data, acting);
}
// Level events 1010 and 1011: the guests' parrots dance or stop.
for (pos, song) in std::mem::take(&mut self.lan_songs) {
match song {
Some(name) => {
let id = host.hub.registry_id("minecraft:jukebox_song", &name);
host.hub.level_event(1010, pos, id.unwrap_or(0), None);
}
None => host.hub.level_event(1011, pos, 0, None),
}
}
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
host.hub.block_event(pos, b0, b1, state);
}
@@ -1796,7 +1975,12 @@ impl Game<'_> {
// A projectile is the direct entity of its hit: not tracked here.
let melee = matches!(
source.kind,
K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom
K::PlayerAttack
| K::MaceSmash
| K::Spear
| K::MobAttack
| K::Sting
| K::SonicBoom
);
let direct = cause.filter(|_| melee);
host.hub.damage_event(m.id as i32, kind, cause, direct, at);
@@ -1817,6 +2001,19 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
for attacker in kinetic_hits {
let hit = match attacker {
None => self
.player
.as_ref()
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
};
if let Some((id, at)) = hit {
host.hub.entity_event(id, 2, at);
}
}
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)
@@ -1881,10 +2078,18 @@ impl Game<'_> {
m.head_yaw = g.head_yaw;
m.head_pitch = g.pitch;
let mut target = m.body_yaw;
if dx * dx + dz * dz > 0.002_500_000_2 {
target = (dz.atan2(dx) as f32).to_degrees() - 90.0;
}
let wrap = |a: f32| (a + 180.0).rem_euclid(360.0) - 180.0;
if dx * dx + dz * dz > 0.002_500_000_2 {
// LivingEntity.tick: walking backwards the body faces away
// from the walk direction, not along it.
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing = (wrap(g.yaw) - walk).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
m.body_yaw += wrap(target - m.body_yaw) * 0.3;
let diff = wrap(g.yaw - m.body_yaw);
if diff.abs() > 50.0 {
@@ -1908,12 +2113,43 @@ impl Game<'_> {
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// Entity.DATA_POSE and the shared and living flags the others
// get (hub::guest_body): gliding, swimming, Riptide's spin.
let body = lan::hub::guest_body(g);
m.extra.fall_flying = body.gliding;
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
m.extra.auto_spin_attack = body.spin_attack;
m.velocity = g
.motion
.as_ref()
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
m.tick_avatar_pose(
g.motion
.as_ref()
.is_some_and(|motion| motion.fluids.was_touching_water),
);
// CrossbowItem.isCharged: the held crossbow's projectiles.
m.extra.charged = g
.inventory
.get(g.selected)
.and_then(|s| s.as_ref())
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
// 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);
// Entity.isInvisible: the invisibility effect hides the body (the
// model pass, the name tag rules and `trader::is_invisible` read it).
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
if let Some(e) = g.effects.get("invisibility") {
m.extra
.effects
.push(("minecraft:invisibility", e.duration, e.amplifier));
}
// 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.
@@ -1924,6 +2160,18 @@ impl Game<'_> {
let using = host.play.get(&g.conn).and_then(|p| p.using());
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
// item with `blocks_attacks`) raises the arm.
m.extra.blocking = using
.and_then(|(slot, _)| {
if slot == OFFHAND_SLOT {
m.extra.off_hand.as_ref()
} else {
m.extra.main_hand.as_ref()
}
})
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
}
@@ -1974,6 +2222,15 @@ impl Game<'_> {
),
out,
);
self.player_elytra_geometry(
m,
m.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
+328 -35
View File
@@ -5,7 +5,9 @@
//! blocks, attacking and the commands all behave exactly as they do for the
//! host, and every world change they cause reaches all players through the
//! block log, the level events and the entity tracker.
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
use super::{
Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig,
};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
@@ -388,6 +390,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
@@ -643,6 +646,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
@@ -971,6 +975,45 @@ impl Game<'_> {
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
}
/// DedicatedServer.isUnderSpawnProtection: no operators, an operator or a
/// radius of 0 or less is never under it; else a block within the radius
/// of the world spawn (square, in the Overworld) is.
fn under_spawn_protection(&self, host: &LanHost, gi: usize, pos: [i32; 3]) -> bool {
let g = &host.hub.guests[gi];
let radius = host.hub.spawn_protection;
if self.options.dedicated.is_none() || radius <= 0 || g.dimension != "minecraft:overworld" {
return false;
}
let Some(spawn) = self.level.world_spawn else {
return false;
};
if host.hub.ops.is_empty() || host.hub.ops.contains_key(&g.uuid) {
return false;
}
let dx = pos[0].abs_diff(spawn[0]);
let dz = pos[2].abs_diff(spawn[2]);
dx.max(dz) <= radius as u32
}
/// ServerPlayer.sendSpawnProtectionMessage.
fn spawn_protection_message(&self, host: &LanHost, conn: u32, pos: [i32; 3]) {
let message = horizon_client::text::Text::translate(
"build.spawn_protection",
&[horizon_client::text::Text::literal(format!(
"{}, {}, {}",
pos[0], pos[1], pos[2]
))],
)
.with_color(horizon_client::text::RED);
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
}),
);
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -995,6 +1038,12 @@ impl Game<'_> {
if spectator {
return;
}
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
// under spawn protection (the ack puts the block back).
if self.under_spawn_protection(host, gi, a.pos) {
self.spawn_protection_message(host, conn, a.pos);
return;
}
let face = direction(i32::from(a.direction));
let play = host.play.entry(conn).or_default();
play.digging = Some((a.pos, face));
@@ -1086,6 +1135,8 @@ impl Game<'_> {
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
});
}
// STAB: `PiercingWeapon.attack` of a left click with a spear.
8 if !spectator => self.guest_jab(host, conn),
_ => {}
},
S::SetCarriedItem(p) => {
@@ -1122,19 +1173,25 @@ impl Game<'_> {
],
};
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.forced_hit = None;
g.guest_use = None;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_use_sounds(host, conn, logged, heard);
// handleUseItemOn: under spawn protection nothing is used or
// placed; the player is told and the blocks below go back.
if self.under_spawn_protection(host, gi, p.pos) {
self.spawn_protection_message(host, conn, p.pos);
} else {
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.forced_hit = None;
g.guest_use = None;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_use_sounds(host, conn, logged, heard);
}
// The client predicted a result: correct it with what really is.
for pos in [
p.pos,
@@ -1384,6 +1441,9 @@ impl Game<'_> {
}
self.sync_rules();
}
// handleTeleportToEntityPacket: a spectator goes to the player,
// in whichever level they are.
S::TeleportToEntity(p) if spectator => self.guest_spectate_teleport(host, gi, p.uuid),
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(mode) = usize::try_from(p.mode)
.ok()
@@ -1434,19 +1494,68 @@ impl Game<'_> {
.unwrap_or_default();
host.hub.send_stats(conn, &stats);
}
// handlePickItemFromBlock: a block within the block reach plus 1, its
// data only with infinite materials.
S::PickItemFromBlock(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let item = self.res.facts().get(state).item.clone();
let g = &mut host.hub.guests[gi];
if g.creative() && &*item != "minecraft:air" {
let slot = g.selected;
g.inventory[slot] = Some(ItemStack::new(&item, 1));
}
self.guest_pick_item(host, conn, |g| {
let eye = g.eye_position()?;
let d2: f64 = (0..3)
.map(|a| {
let lo = f64::from(p.pos[a]);
(eye[a] - eye[a].clamp(lo, lo + 1.0)).powi(2)
})
.sum();
let range = g.reach() + 1.0;
if d2 >= range * range {
return None;
}
g.picked_stack(p.pos, g.creative && p.include_data)
});
}
// handlePickItemFromEntity.
S::PickItemFromEntity(p) => {
self.guest_pick_item(host, conn, |g| {
g.within_entity_range(p.id)
.then(|| g.entity_pick_result(p.id))
.flatten()
});
}
_ => {}
}
}
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
/// is selected, else a creative player gets the item; the held slot is sent
/// back and the changed slots follow with the tick's inventory changes.
fn guest_pick_item(
&mut self,
host: &mut LanHost,
conn: u32,
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
) {
let picked = self.as_guest(host, conn, |g| {
let stack = stack(g)?;
let infinite = g.creative;
horizon_client::pick_item::pick(
&mut g.inventory[..36],
&mut g.selected,
&stack,
infinite,
);
Some(())
});
if picked.flatten().is_some()
&& let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn)
{
host.hub.send_to(
conn,
&C::SetHeldSlot(play::SetHeldSlot {
slot: g.selected as i32,
}),
);
}
}
fn item_from_network(
&self,
host: &LanHost,
@@ -1462,6 +1571,51 @@ impl Game<'_> {
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
/// sooner removes it too.
/// `ServerGamePacketListenerImpl.handleTeleportToEntityPacket` for a
/// player: the host's own, or a guest in the world.
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
let own = host.hub.guests[gi].conn;
let host_player = self
.player
.as_ref()
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
.map(|p| {
(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim,
)
});
let target = host_player.or_else(|| {
host.hub
.guests
.iter()
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
});
let Some((at, yaw, pitch, dim)) = target else {
return;
};
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
// Only a dedicated server keeps its players in different levels.
if self.options.dedicated.is_some() {
self.level_teleports
.push(super::server_travel::LevelTeleport {
conn: own,
dim,
at,
rotation: Some([yaw, pitch]),
});
}
return;
}
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (yaw, pitch);
// A new body there: no fall distance or motion carried over.
g.motion = None;
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
g.death_time += 1;
@@ -1574,6 +1728,37 @@ impl Game<'_> {
Some((centre, eye))
}
/// `ServerGamePacketListenerImpl.handlePlayerAction` for STAB: the spear in
/// the guest's main hand jabs once its charge allows it (with five ticks
/// of tolerance). The guest's client is sent the dash Lunge gave them.
fn guest_jab(&mut self, host: &mut LanHost, conn: u32) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let at = g.position;
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = g
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
let sound = self
.guest_hit_by(host, conn, |g| {
g.known_movement = movement;
if g.cannot_attack_with_item(g.attack_ticks as i32, 5) {
None
} else {
g.jab()
}
})
.flatten();
// PiercingWeapon.makeSound: `playSound(causer, ...)`, so the guest's
// own client plays it.
if let Some(name) = sound {
let source = horizon_client::settings::sound_source(name) as i32;
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
}
}
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
@@ -1614,9 +1799,21 @@ impl Game<'_> {
if self.look_at_entity(host, gi, id, None).is_none() {
return;
}
self.as_guest(host, conn, |g| {
g.attack();
});
// A mace's smash sets the attacker's velocity and sends it at once, before the
// Wind Burst's push (MaceItem.hurtEnemy, then the post-attack effects).
let sent = self
.as_guest(host, conn, |g| {
let marked = g
.player
.as_mut()
.is_some_and(|p| std::mem::take(&mut p.health.sync_velocity));
g.attack();
let p = g.player.as_mut()?;
let sent = std::mem::replace(&mut p.health.sync_velocity, marked);
sent.then_some(p.velocity)
})
.flatten();
self.send_guest_motion(host, conn, sent);
// The mob now answers to this guest (game/lan_mobs.rs).
if let Ok(id) = u32::try_from(id) {
host.mob_players.insert(id, conn);
@@ -1919,6 +2116,10 @@ impl Game<'_> {
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
// TeleportTransition.createDefault: no point of its own, so the
// world spawn's free column once its chunks have loaded.
self.world_spawn_trip(conn);
}
if let Some(c) = in_frame_bed
&& self.dim == super::Dim::Overworld
@@ -1937,6 +2138,10 @@ impl Game<'_> {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
if found.is_none() {
// TeleportTransition.missingRespawnBlock.
self.world_spawn_trip(conn);
}
host.hub.teleport(i, Some(at));
}
if away && let Err(e) = self.enter_level(Some(host), back) {
@@ -1959,6 +2164,10 @@ impl Game<'_> {
.filter(|g| host.hub.here(g) && g.loaded)
.map(|g| g.conn)
.collect();
// The players the guests' hooks can catch.
if host.play.values().any(|p| p.hook.is_some()) {
self.refresh_hook_targets(host);
}
for conn in conns {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
continue;
@@ -1982,7 +2191,13 @@ impl Game<'_> {
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
if digging.is_some() || using || delayed {
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = host.hub.guests[gi]
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
self.as_guest(host, conn, |g| {
g.known_movement = movement;
g.attack_ticks = g.attack_ticks.saturating_add(1);
if let Some((pos, face)) = digging {
g.forced_hit = Some(Hit {
@@ -2054,6 +2269,7 @@ impl Game<'_> {
self.as_guest(host, conn, |g| {
g.tick_fishing();
g.tick_maps();
g.tick_lodestone_compasses();
g.count_tick_stats(sleeping);
g.tick_advancements();
g.update_player_attributes();
@@ -2080,7 +2296,7 @@ impl Game<'_> {
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
g.last_death = Some((g.dimension.clone(), g.position.map(|v| v.floor() as i32)));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
@@ -2161,11 +2377,10 @@ impl Game<'_> {
}
}
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
/// host's player and the guests: of the players not in spectator mode,
/// players_sleeping_percentage of them (at least one) are in bed, and as
/// many have slept 100 ticks.
pub(super) fn enough_slept(&self) -> bool {
/// `SleepStatus.update`'s counts over the host's player and the guests:
/// the players not in spectator mode, how many are in bed, and how many
/// of those have slept 100 ticks.
fn sleep_counts(&self) -> (i64, i64, i64) {
let (mut active, mut sleeping, mut deep) = (0i64, 0i64, 0i64);
let mut count = |in_bed: bool, timer: u32| {
active += 1;
@@ -2189,11 +2404,64 @@ impl Game<'_> {
count(g.bed.is_some(), g.sleep_timer);
}
}
(active, sleeping, deep)
}
/// `SleepStatus.sleepersNeeded`.
fn sleepers_needed(&self, active: i64) -> i64 {
let percent = i64::from(self.ui.rules.int("players_sleeping_percentage").max(0));
let needed = ((active * percent + 99) / 100).max(1);
((active * percent + 99) / 100).max(1)
}
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
/// host's player and the guests: of the players not in spectator mode,
/// players_sleeping_percentage of them (at least one) are in bed, and as
/// many have slept 100 ticks.
pub(super) fn enough_slept(&self) -> bool {
let (active, sleeping, deep) = self.sleep_counts();
let needed = self.sleepers_needed(active);
sleeping >= needed && deep >= needed
}
/// `ServerLevel.updateSleepingPlayerList` and `announceSleepStatus`: when
/// the players or the sleepers change, a published or dedicated world
/// shows everyone "n/m players sleeping", or "Sleeping through this
/// night" once enough are in bed, on the action bar.
pub(super) fn announce_sleep_status(&mut self) {
let (active, sleeping, _) = self.sleep_counts();
let (old_active, old_sleeping) = self.sleep_seen;
self.sleep_seen = (active, sleeping);
let changed = (old_sleeping > 0 || sleeping > 0)
&& (old_active != active || old_sleeping != sleeping);
let published = self.lan.is_some() || self.options.dedicated.is_some();
if !changed || !published {
return;
}
// ServerLevel.canSleepThroughNights.
if self.ui.rules.int("players_sleeping_percentage") > 100 {
return;
}
let needed = self.sleepers_needed(active);
let text = if sleeping >= needed {
horizon_client::text::Text::translate("sleep.skipping_night", &[])
} else {
horizon_client::text::Text::translate(
"sleep.players_sleeping",
&[
horizon_client::text::Text::literal(sleeping.to_string()),
horizon_client::text::Text::literal(needed.to_string()),
],
)
};
if let Some(host) = self.lan.as_ref() {
host.hub
.overlay_message(horizon_client::lan::data::component_nbt(&text.component()));
}
if self.options.dedicated.is_none() {
self.titles.set_overlay(text, false);
}
}
/// `ServerLevel.wakeUpAllPlayers` for the guests.
pub(super) fn wake_guests(&mut self) {
if let Some(host) = self.lan.as_mut() {
@@ -2249,16 +2517,19 @@ impl Game<'_> {
if !g.effects.list.is_empty() {
self.as_guest(host, conn, |game| game.tick_mob_effects());
}
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
return;
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let riding = g.vehicle.is_some();
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let freeze_immune = game.wears_freeze_immune();
let burn_immune = game.wears_burn_from_stepping_immunity();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2273,6 +2544,12 @@ impl Game<'_> {
w.player.health.advance_clock();
let _ = w.fluid_base_tick();
w.player.ground.on_ground = from_ground;
// Player.aiStep: a flying player that isn't riding starts every tick
// with no fall distance, so flying down in creative and switching to
// survival lands without the flight's distance.
if w.player.flying && !riding {
w.player.fall_distance = 0.0;
}
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
// Entity.move's movementThisTick: each move, from where the
// tick's first one started.
@@ -2324,6 +2601,7 @@ impl Game<'_> {
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
w.player.burn_from_stepping_immune = burn_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
let _ = w.tick_environment_damage();
@@ -2352,12 +2630,16 @@ impl Game<'_> {
for d in travelled {
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
}
// LivingEntity.baseTick: a new block position runs the worn
// enchantments' location_changed effects (frost walker).
game.location_changed_effects([start.x, start.y, start.z], from_ground);
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
// Soul Speed's effects (LivingEntity.baseTick's location_changed
// and tick effects); its particle goes to the players around.
soul = game.tick_soul_speed();
game.handle_shoulder_entities();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2396,7 +2678,10 @@ impl Game<'_> {
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
self.as_guest(host, conn, |game| {
game.sculk_step_on_player();
game.tick_player_sculk_events();
});
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
@@ -2700,9 +2985,15 @@ impl Game<'_> {
/// Dropped items touching a guest go into their inventory.
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
let (conn, mode, position, id) = {
let (conn, mode, position, id, uuid) = {
let g = &host.hub.guests[gi];
(g.conn, g.game_mode, g.position, g.entity_id)
(
g.conn,
g.game_mode,
g.position,
g.entity_id,
horizon_client::lan::playerdata::uuid_ints(g.uuid),
)
};
if mode == SPECTATOR || self.dropped.is_empty() {
return;
@@ -2713,6 +3004,7 @@ impl Game<'_> {
.enumerate()
.filter(|(_, d)| {
d.pickup_delay == 0
&& d.target.is_none_or(|t| t == uuid)
&& (d.position.x - position[0]).abs() < 1.5
&& (d.position.z - position[2]).abs() < 1.5
&& d.position.y > position[1] - 0.6
@@ -2955,7 +3247,8 @@ impl Game<'_> {
let packet = swing_packet(id, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
if let Some(m) = host.models.get_mut(&conn) {
m.swing();
// The duration already counts the guest's haste or fatigue.
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
}
}
play.swing.tick();
@@ -24,6 +24,37 @@ fn slot_in_group(group: &str, slot: &str) -> bool {
}
impl Game<'_> {
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` of a mob: its own value plus
/// the modifiers of the items it wears or holds in matching slots.
pub(super) fn mob_knockback_resistance(&self, m: &Mob) -> f64 {
let e = &m.extra;
let slots = [
("mainhand", &e.main_hand),
("offhand", &e.off_hand),
("feet", &e.feet),
("legs", &e.legs),
("chest", &e.chest),
("head", &e.head),
];
let mut extra = Vec::new();
for (slot, item) in slots {
let Some(info) = item.as_ref().and_then(|i| self.res.items().get(i)) else {
continue;
};
for (attribute, _, amount, op, group) in &info.modifiers {
if attribute == "minecraft:knockback_resistance" && slot_in_group(group, slot) {
let op = match operation_named(op) {
1 => Op::AddMultipliedBase,
2 => Op::AddMultipliedTotal,
_ => Op::AddValue,
};
extra.push((*amount, op));
}
}
}
finalize::value_with(m, Attr::KnockbackResistance, &extra)
}
/// The registry id of a client-syncable attribute the entity type has
/// (`AttributeMap.getInstance` is null for the others), by name.
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
@@ -425,6 +425,9 @@ impl Game<'_> {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
// Projectile.getAddEntityPacket: the owner's id; a trident here is
// always the host player's.
e.data = owner(Some(horizon_client::mobs::Who::Player));
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
// ThrownTrident.ID_LOYALTY and ID_FOIL.
if t.loyalty != 0 {
@@ -491,7 +494,9 @@ impl Game<'_> {
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
// The hub is out of `self.lan` meanwhile, so it is passed in.
let breath = Self::hub_particle(
hub,
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DragonBreath,
),
+25 -2
View File
@@ -26,14 +26,28 @@ pub(super) fn player_for(
guests: &GuestViews,
bound: Option<u32>,
) -> Option<u32> {
// A tame cat reads its owner wherever they are while they sleep, or just
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
if m.species() == Species::Cat
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
if host.is_some_and(asleep) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
return Some(*conn);
}
}
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
// a player: then `Who::Player` is that attacker, whoever it is, and the
// owner goals ignore it until the wolf lets go.
if !targeting
&& matches!(m.species(), Species::Wolf | Species::Cat)
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
@@ -81,7 +95,12 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::DamagePlayer { .. }
| MobEvent::SonicBoom {
target: Who::Player,
..
}
| MobEvent::Push {
target: Who::Player,
..
@@ -100,6 +119,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
| MobEvent::ParrotToShoulder { .. }
)
}
@@ -373,6 +393,7 @@ mod tests {
alive: true,
creative,
sneaking: false,
swimming: false,
look: [0.0; 3],
main_hand: None,
off_hand: None,
@@ -392,6 +413,8 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
}
@@ -82,6 +82,40 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
/// player within 32 blocks. The window draws its own copy.
pub(super) fn lan_level_particles(
&self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.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);
}
}
}
/// `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
@@ -156,7 +190,7 @@ impl Game<'_> {
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
/// guest's turn).
fn hub_particle(
pub(super) fn hub_particle(
hub: &Hub,
options: &ParticleOptions,
state: Option<BlockStateId>,
+149 -1
View File
@@ -12,6 +12,7 @@ use horizon_client::{
mobs::Who,
projectiles::{Hit, Projectile, ProjectileKind},
};
use horizon_core::geometry::{Aabb, Vec3};
/// The player projectiles that meet other players: arrows (their hurt), and
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
@@ -24,7 +25,7 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
}
/// Who a player's attack lands on.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Victim {
Host,
Guest(u32),
@@ -40,6 +41,8 @@ pub(super) struct Strike {
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
/// `getSecondsToDisableBlocking` of the attacker's weapon.
disable_blocking: f32,
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
/// yaw), and whether the hit is a sprinting knockback attack.
@@ -47,6 +50,26 @@ pub(super) struct Strike {
knockback_attack: bool,
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
team: Option<horizon_client::scoreboard::Team>,
/// The sweep's damage to the players around the victim.
sweep_hit: Option<Sweep>,
}
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
/// worked out as the attacker.
#[derive(Clone)]
pub(super) struct Sweep {
/// The attacking guest (none: the host).
attacker: Option<u32>,
from: [f64; 3],
/// The damage after the enchantments and the attack strength.
damage: f32,
/// The push along the attacker's yaw.
knockback: (f64, f64),
team: Option<horizon_client::scoreboard::Team>,
/// The target's box grown by (1, 0.25, 1).
pub(super) hit_box: Aabb,
/// The player the sweep started from, which it spares.
skip: Option<Victim>,
}
impl Strike {
@@ -125,10 +148,22 @@ impl Game<'_> {
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let sweep_hit = sweep.map(|_| {
let base = self.sweep_base_damage(scale);
self.player_sweep(base, scale)
});
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
self.swing_arm();
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
// none while another item is in use.
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(Strike {
attacker: self.lan_acting,
amount,
@@ -136,10 +171,12 @@ impl Game<'_> {
crit,
magic,
from,
disable_blocking,
sweep,
knockback: (strength, xd, zd),
knockback_attack,
team: self.player_team().cloned(),
sweep_hit,
})
}
@@ -250,7 +287,9 @@ impl Game<'_> {
// Player.attack: the hurt (with its knockback away from the
// attacker), then causeExtraKnockback along the attacker's yaw.
let land = |g: &mut Self| {
g.combat.player_attacker = Some((s.from, s.disable_blocking));
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
@@ -331,9 +370,118 @@ impl Game<'_> {
};
if let Some(at) = s.sweep.filter(|_| hurt) {
self.sweep_attack(at, Some(s.from));
// The other players next to the victim (Player.doSweepAttack).
let bounds = match victim {
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
};
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
self.sweep_players(
host,
&Sweep {
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
skip: Some(victim),
..sweep.clone()
},
);
}
} else {
self.audio.play(sound, Some(s.from), 1.0, 1.0);
}
hurt
}
/// The sweep of the player in place, for the players it reaches: the
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
/// player, scaled by the attack strength) and who hits. The box is set
/// by the caller.
pub(super) fn player_sweep(&mut self, base: f32, scale: f32) -> Sweep {
let from = self
.player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
Sweep {
attacker: self.lan_acting,
from,
damage,
knockback: self.extra_knockback_direction(),
team: self.player_team().cloned(),
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
skip: None,
}
}
/// A sweep from a mob hit reaches the other players. Inside a guest's
/// turn the host holds the other players (`as_guest`), so it waits for
/// the turn to end.
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
if self.lan_acting.is_some() {
self.creatures.sweeps.push(sweep);
} else if let Some(mut host) = self.lan.take() {
self.sweep_players(&mut host, &sweep);
self.lan = Some(host);
}
}
/// The sweeps guests' turns put off.
pub(super) fn flush_sweeps(&mut self) {
for sweep in std::mem::take(&mut self.creatures.sweeps) {
self.sweep_other_players(sweep);
}
}
/// `Player.doSweepAttack` on the players: each other player in the box,
/// closer than 3 blocks (`distanceToSqr < 9`) and not an ally
/// (`isAlliedTo`), is hurt (`ServerPlayer.canHarmPlayer`: the `pvp` rule)
/// and pushed 0.4 along the attacker's yaw.
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
let mut victims = Vec::new();
if sweep.attacker.is_some()
&& self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
&& p.bounds().intersects(sweep.hit_box)
&& p.position.distance_squared(from) < 9.0
{
victims.push((Victim::Host, self.player_team().cloned()));
}
for g in &host.hub.guests {
if host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& sweep.attacker != Some(g.conn)
&& guest_bounds(g).intersects(sweep.hit_box)
&& Vec3::new(g.position[0], g.position[1], g.position[2]).distance_squared(from)
< 9.0
{
let team = self.score.board.players_team(&g.name).cloned();
victims.push((Victim::Guest(g.conn), team));
}
}
let (xd, zd) = sweep.knockback;
let land = |g: &mut Self| {
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
if hurt {
g.knock_back_player(0.4, xd, zd);
}
};
for (victim, team) in victims {
if sweep.skip == Some(victim)
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|| !self.ui.rules.bool("pvp")
{
continue;
}
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => {
self.guest_struck_by(host, conn, land);
}
}
}
}
}
@@ -317,6 +317,8 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
game.trigger_enchanted_item(&enchanted, need);
});
host.hub.guests[gi].xp_seed = self.world.random.next_int();
let env = self.menu_env();
@@ -46,7 +46,7 @@ impl Game<'_> {
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
match usize::try_from(data) {
Ok(axis) if id == 3002 && axis < 3 => {
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
particles::along_axis(random, axis, p, (10, 19), |p, v| spawned.push((p, v)));
}
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
}
+6 -2
View File
@@ -46,8 +46,12 @@ impl Game<'_> {
if !self.with_level(|b, level, _| b.is_raining_at(level, p)) {
continue;
}
// Skeleton trap: effective difficulty / 100 (regional difficulty approximated).
let difficulty = f64::from(self.ui.difficulty) * 0.75;
// Skeleton trap: the local effective difficulty / 100
// (`getCurrentDifficultyAt(pos)`).
let difficulty = f64::from(
self.local_difficulty([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])])
.effective,
);
let below = &self
.res
.facts()
+21 -6
View File
@@ -6,6 +6,7 @@
use super::Game;
use horizon_client::{
enchant::EffectContext,
items::ItemStack,
mobs::{Damage, Mob, Species},
particles::{ParticleOptions, ParticleType},
};
@@ -135,7 +136,7 @@ impl Game<'_> {
/// `LivingEntity.hurtServer` of the mob at `index` by the player's hit:
/// the weapon's `armor_effectiveness` enchantments (Breach) thin its armor.
pub(super) fn hurt_by_player(&mut self, index: usize, amount: f32, source: Damage) -> bool {
let held = self.inventory[self.selected].clone();
let held = self.held().cloned();
let kind = self.mobs[index].kind.id.clone();
let ctx = EffectContext {
this: Some(&kind),
@@ -144,6 +145,7 @@ impl Game<'_> {
is_direct: true,
damage_type: Some(match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
_ => "minecraft:player_attack",
}),
items: Some(self.res.items()),
@@ -173,7 +175,7 @@ impl Game<'_> {
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
/// wear.
pub(super) fn weapon_hit(&mut self, index: usize) {
let Some(stack) = self.inventory[self.selected].clone() else {
let Some(stack) = self.held().cloned() else {
return;
};
let weapon = self
@@ -193,16 +195,26 @@ impl Game<'_> {
if smash && let Some(p) = self.player.as_mut() {
p.fall_distance = 0.0;
}
// ItemStack.postHurtEnemy: hurtAndBreak(weapon.itemDamagePerAttack).
self.weapon_wear(&stack);
}
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
/// the weapon in the acting hand.
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
let weapon = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
if let Some((per_attack, max)) = weapon
&& max > 0
&& !self.creative
{
let loss = self.durability_loss(&stack, per_attack);
if let Some(held) = self.inventory[self.selected].as_mut() {
let loss = self.durability_loss(stack, per_attack);
let cell = self.held_mut();
if let Some(held) = cell.as_mut() {
held.damage += loss;
if held.damage >= max {
self.inventory[self.selected] = None;
*cell = None;
}
}
}
@@ -216,6 +228,9 @@ impl Game<'_> {
return;
};
p.velocity = Vec3::new(p.velocity.x, f64::from(0.01f32), p.velocity.z);
// A server player's client is told (ClientboundSetEntityMotionPacket): it moves by
// itself, so without it a Wind Burst's push would add to the speed of the fall.
p.health.sync_velocity = true;
// calculateImpactPosition: an earlier impact below the player stays.
let impact = match p.impulse.impact_position {
Some(at) if at.y <= p.position.y => at,
+154 -44
View File
@@ -648,27 +648,41 @@ impl Game<'_> {
self.container_changed(pos);
}
}
/// `HopperBlockEntity.ejectItems`.
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
};
let Some(target) = c.view(offset(pos, facing), false) else {
let front = offset(pos, facing);
let face = facing.opposite();
if let Some(target) = c.view(front, false) {
return self.eject_into(pos, hopper, &target, face);
}
let Some(id) = self.entity_container_at([
f64::from(front.0) + 0.5,
f64::from(front.1) + 0.5,
f64::from(front.2) + 0.5,
]) else {
return false;
};
let face = facing.opposite();
if c.is_full(&target, face) {
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
.unwrap_or(false)
}
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
if self.containers().is_full(target, face) {
return false;
}
for slot in 0..hopper.len() {
let Some(one) = self.take_one(hopper, slot) else {
continue;
};
match self.add_item(Some(pos), &target, one, Some(face)) {
match self.add_item(Some(pos), target, one, Some(face)) {
None => {
self.view_changed(&target);
self.view_changed(target);
return true;
}
Some(rest) => self.put_back(hopper, slot, rest),
@@ -679,7 +693,12 @@ impl Game<'_> {
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
let above = (pos.0, pos.1 + 1, pos.2);
if let Some(took) = self.suck_from_container(above, hopper) {
let at = [
f64::from(pos.0) + 0.5,
f64::from(pos.1) + 1.5,
f64::from(pos.2) + 0.5,
];
if let Some(took) = self.suck_from_container(above, at, hopper) {
return took;
}
let above_state = self.world.block(above.0, above.1, above.2);
@@ -713,11 +732,16 @@ impl Game<'_> {
self.suck_item_entities(hopper, min, max)
}
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
/// container at `above`, through its bottom face. `None` when there is no
/// container there.
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
/// container at `above`, through its bottom face; with no block container
/// there, from a container entity whose box meets the one around `at`
/// (`getSourceContainerOrHandler`). `None` when there is no container.
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
let c = self.containers();
let source = c.view(above, false)?;
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
return self
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
};
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
@@ -736,26 +760,133 @@ impl Game<'_> {
return Some(moved);
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
Some(self.suck_from_slots(source_hopper, &source, hopper))
}
/// `HopperBlockEntity.tryTakeInItemFromSlot` over the `DOWN` face of `source`.
fn suck_from_slots(
&mut self,
source_hopper: Option<Pos>,
source: &View,
hopper: &View,
) -> bool {
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
let Some(one) = self.take_one(source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return Some(true);
self.view_changed(source);
return true;
}
Some(rest) => self.put_back(&source, slot, rest),
Some(rest) => self.put_back(source, slot, rest),
}
}
Some(false)
false
}
/// `HopperBlockEntity.getEntityContainer`: one of the alive container
/// entities (`EntitySelector.CONTAINER_ENTITY_SELECTOR`: chest and hopper
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
/// picked at random.
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < at[0] + 0.5
&& hi.x > at[0] - 0.5
&& lo.y < at[1] + 0.5
&& hi.y > at[1] - 0.5
&& lo.z < at[2] + 0.5
&& hi.z > at[2] - 0.5
};
let mut found: Vec<u32> = self
.carts
.iter()
.filter(|c| {
matches!(
c.kind,
super::minecarts::CartKind::Chest | super::minecarts::CartKind::Hopper
) && meets(c.bounds())
})
.map(|c| c.id)
.collect();
found.extend(
self.boats
.iter()
.filter(|b| b.chest && meets(b.bounds()))
.map(|b| b.id),
);
if found.is_empty() {
return None;
}
let pick = self
.world
.random
.next_int_bound(found.len() as i32)
.unwrap_or(0);
found.get(pick as usize).copied()
}
/// Runs `f` on a container entity's items as a container, under the
/// placeholder key its menu uses (the open menu's copy when it is open),
/// then hands the items back to the entity.
fn with_entity_container<R>(
&mut self,
id: u32,
f: impl FnOnce(&mut Self, &View) -> R,
) -> Option<R> {
let mut items = if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
std::mem::take(&mut c.items)
} else {
std::mem::take(&mut self.boats.iter_mut().find(|b| b.id == id)?.items)
};
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
let (view, open) = self.lend_items(key, &mut items);
let result = f(self, &view);
self.reclaim_items(key, open, &mut items);
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.items = items;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.items = items;
}
Some(result)
}
/// A vehicle's slots as a container under its placeholder `key`. When its
/// menu is open the placeholder is the live copy and stays as it is.
/// Returns the view and whether the menu was open.
fn lend_items(&mut self, key: Pos, items: &mut Vec<Option<ItemStack>>) -> (View, bool) {
let open = self.world.block_entities.contains_key(&key);
if !open {
let slots = BlockEntity::Chest(std::mem::take(items));
self.world.block_entities.insert(key, slots);
}
let size = self
.world
.block_entities
.get(&key)
.map_or(0, |e| e.slots().len());
let view = View {
parts: vec![(key, size)],
kind: Kind::Generic,
};
(view, open)
}
/// Gives `lend_items`' slots back to the vehicle, so its own items stay
/// the one source of truth: a menu copy that is still around (open, or
/// left by a guest's close) gets the changed items copied to `items`.
fn reclaim_items(&mut self, key: Pos, open: bool, items: &mut Vec<Option<ItemStack>>) {
let slots = if open {
self.world.block_entities.get(&key).cloned()
} else {
self.world.block_entities.remove(&key)
};
if let Some(BlockEntity::Chest(slots)) = slots {
*items = slots;
}
}
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
/// adds the item entities that meet the box, until one is taken whole.
@@ -791,20 +922,7 @@ impl Game<'_> {
// The cart's slots are edited as a container under the placeholder key
// its menu uses; when that menu is open the placeholder is the live copy.
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
let open = self.world.block_entities.contains_key(&key);
if !open {
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
self.world.block_entities.insert(key, items);
}
let size = self
.world
.block_entities
.get(&key)
.map_or(0, |e| e.slots().len());
let view = View {
parts: vec![(key, size)],
kind: Kind::Generic,
};
let (view, open) = self.lend_items(key, &mut cart.items);
let p = cart.position;
// getLevelY is the cart's y + 0.5, so the container is the block at
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
@@ -814,7 +932,8 @@ impl Game<'_> {
(p.y + 1.5).floor() as i32,
p.z.floor() as i32,
);
let took = match self.suck_from_container(above, &view) {
let at = [p.x, p.y + 1.5, p.z];
let took = match self.suck_from_container(above, at, &view) {
Some(took) => took,
None => self.suck_item_entities(
&view,
@@ -832,17 +951,7 @@ impl Game<'_> {
[hi.x + 0.25, hi.y, hi.z + 0.25],
);
}
// The cart's own items stay the one source of truth: a menu copy that
// is still around (open, or left by a guest's close) gets the pulled
// items back into `cart.items`, so reopening never restores a stale copy.
let slots = if open {
self.world.block_entities.get(&key).cloned()
} else {
self.world.block_entities.remove(&key)
};
if let Some(BlockEntity::Chest(items)) = slots {
cart.items = items;
}
self.reclaim_items(key, open, &mut cart.items);
}
// ---- dispensers and droppers ----
@@ -1080,6 +1189,7 @@ mod item_column_tests {
id,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+89 -3
View File
@@ -721,6 +721,93 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a
/// single one in survival changes in place, else one is split off).
pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:compass" {
return false;
}
let [x, y, z] = hit.pos;
if self
.res
.facts()
.get(self.world.block(x, y, z))
.name
.as_ref()
!= "minecraft:lodestone"
{
return false;
}
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
self.audio
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
let target = Tag::compound([(
"target",
Tag::compound([
("dimension", Tag::String(self.dimension_id().into())),
("pos", Tag::IntArray(vec![x, y, z])),
]),
)]);
if !self.creative && stack.count == 1 {
if let Some(held) = self.held_mut() {
held.data_mut()
.set_other("minecraft:lodestone_tracker", Some(target));
}
} else {
let mut locked = stack.clone();
locked.count = 1;
locked
.data_mut()
.set_other("minecraft:lodestone_tracker", Some(target));
self.consume_held();
self.give_or_drop_stack(locked);
}
self.stats.add("used", &stack.item, 1);
self.swing_arm();
true
}
/// `CompassItem.inventoryTick` / `LodestoneTracker.tick`: a compass whose
/// lodestone is gone loses its target. A target in an unloaded chunk waits.
pub(super) fn tick_lodestone_compasses(&mut self) {
let dim = self.dimension_id();
let gone = |game: &Self, stack: &ItemStack| -> bool {
let Some(tracker) = stack
.data()
.and_then(|d| d.other("minecraft:lodestone_tracker"))
else {
return false;
};
let (Some(target), true) = (
tracker.get("target"),
horizon_client::save::number(tracker.get("tracked")).is_none_or(|v| v != 0),
) else {
return false;
};
let Some(Tag::IntArray(pos)) = target.get("pos") else {
return false;
};
let [x, y, z] = [pos[0], pos[1], pos[2]];
horizon_client::save::text(target.get("dimension")).as_deref() == Some(dim)
&& game.world.chunks.contains_key(&(x >> 4, z >> 4))
&& game
.res
.facts()
.get(game.world.block(x, y, z))
.name
.as_ref()
!= "minecraft:lodestone"
};
for slot in (0..self.inventory.len()).chain([super::shield::OFFHAND_SLOT]) {
if self.stack_in(slot).is_some_and(|s| gone(self, s))
&& let Some(s) = self.stack_in_mut(slot)
{
s.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(Tag::Compound(Default::default())),
);
}
}
}
fn give_or_drop_stack(&mut self, stack: ItemStack) {
if let Some(rest) = self.give(stack)
&& let Some(eye) = self.eye_position()
@@ -781,9 +868,8 @@ impl Game<'_> {
(d * 2.0 + 0.5) as i8
}
};
let y = f64::from(yaw);
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
// calculateRotation: the arrow spins in the Nether.
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
let sprite = if inside {
Some("map/decorations/player")
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
@@ -5,11 +5,15 @@ use super::Game;
use horizon_client::{
mobs::{
Species,
drops::{self, SLOTS},
finalize::equip_stack,
gear::{self, ARMOR, MAIN},
spawner::equipment_table,
},
raid::{self, RaiderType},
};
use horizon_core::random::RandomSource;
use horizon_world::{loot::EntityLootParams, nbt::Tag};
/// `EnchantmentsByCostWithDifficulty` of `mob_spawn_equipment`.
const MIN_COST: i32 = 5;
@@ -47,6 +51,58 @@ fn slot_item(m: &horizon_client::mobs::Mob, slot: usize) -> Option<std::sync::Ar
}
impl Game<'_> {
/// `Mob.equip(EquipmentTable)` for a spawner's `equipment`: the loot table
/// (the mob as `this`, its random sequence) is rolled and each stack goes
/// in the slot `EquipmentUser.resolveSlot` picks, with the table's drop
/// chance for that slot.
pub(super) fn equip_from_table(&mut self, id: u32, table: &Tag) {
let Some((name, chances)) = equipment_table(table) else {
return;
};
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
else {
return;
};
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
if !loot.has_table(&name) {
return;
}
let params = EntityLootParams {
this: drops::loot_entity(m, None),
..EntityLootParams::default()
};
let seed = self.level.seed;
let stacks = match loot.random_sequence(&name) {
Some(sequence) => {
let random = self.rand_sequences.get(sequence, seed);
loot.roll_entity(&name, random, &params)
}
None => loot.roll_entity(&name, &mut self.world.random, &params),
};
let mut inserted: Vec<usize> = Vec::new();
for stack in stacks.iter().filter(|s| !s.is_empty()) {
// `resolveSlot`: an `equippable` item goes in its own slot, any
// other in the main hand, each slot once.
let slot = match self
.res
.items()
.get(&stack.item)
.and_then(|i| i.equip_slot.as_deref())
{
Some(name) => SLOTS.iter().position(|s| *s == name),
None => Some(0),
};
let Some(slot) = slot.filter(|s| !inserted.contains(s)) else {
continue;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
equip_stack(m, slot, stack, chances[slot]);
}
inserted.push(slot);
}
}
/// `Mob.populateDefaultEquipmentEnchantments`: the weapon one time in
/// four and each piece of armour one time in two, scaled by the local
/// difficulty, enchanted as if at an enchanting table of cost 5 to 22.
@@ -0,0 +1,82 @@
//! The looping sound instances of mobs: a bee's buzz
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
//! attack hum (entity event 21). The sound engine ticks them with what
//! `start_mob_sounds` reads off the mobs.
use super::Game;
use horizon_client::{
mobs::{Species, bee, guardian},
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
};
impl Game<'_> {
/// Start the buzz of each bee that has none (or whose anger changed, which
/// swaps the flying loop for the aggressive one and back), forget the mobs
/// that are gone, and read what the instances follow.
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
let game_time = self.world.game_time;
let mobs: Vec<MobSound> = self
.mobs
.iter()
.filter(|m| {
!m.removed
&& matches!(
m.species(),
Species::Bee | Species::Guardian | Species::ElderGuardian
)
})
.map(|m| MobSound {
id: m.id,
position: [m.position.x, m.position.y, m.position.z],
// Vec3.horizontalDistance.
speed: m.velocity.x.hypot(m.velocity.z),
baby: m.is_baby(),
// NeutralMob.isAngry: the anger end time is ahead.
angry: m.species() == Species::Bee
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
silent: m.silent,
attack_scale: guardian::attack_scale(m),
})
.collect();
self.guardian_sounds
.retain(|id| mobs.iter().any(|m| m.id == *id));
self.bee_sounds
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
for sound in &mobs {
let is_bee = self
.mobs
.iter()
.any(|m| m.id == sound.id && m.species() == Species::Bee);
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
mobs
}
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
/// hum starts (one loop per guardian; the loops of its earlier beams are
/// the same loop, as each one follows the guardian's charge).
pub(super) fn start_guardian_sound(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| {
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
}) else {
return;
};
if !self.guardian_sounds.insert(id) {
return;
}
let sound = MobSound {
id,
position: [m.position.x, m.position.y, m.position.z],
speed: 0.0,
baby: false,
angry: false,
silent: m.silent,
attack_scale: guardian::attack_scale(m),
};
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
+2
View File
@@ -34,11 +34,13 @@ impl Game<'_> {
self.sound_paused = self.pause;
}
let (vehicle, carts) = self.start_cart_sounds();
let mobs = self.start_mob_sounds();
let ctx = TickContext {
player_underwater: self.ambience.was_underwater(),
player_removed: self.player.is_none(),
vehicle,
carts,
mobs,
frozen: !self.clock.runs_normally(),
};
self.audio.tick(self.pause, &ctx);
@@ -297,7 +297,8 @@ impl Game<'_> {
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) {
// In the End the arrival only builds the platform: no spawn search.
if let Err(e) = self.end_arrival(centre, &mut None) {
log::error!("End platform: {e}");
}
return centre;
@@ -662,6 +663,17 @@ impl Game<'_> {
);
}
}
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
/// portal: it stands in the portal for 300 ticks without going through.
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
self.portals.entities.insert(
id,
PortalState {
cooldown: ENTITY_DELAY,
process: None,
},
);
}
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
/// not carrying the player, and not a boss that never travels.
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
+11 -6
View File
@@ -279,11 +279,13 @@ impl Game<'_> {
/// horse is a trap for fifteen minutes; a player within ten blocks springs
/// it.
pub(super) fn tick_skeleton_traps(&mut self) {
let player = self
.player
.as_ref()
.filter(|p| !p.health.dead_or_dying())
.map(|p| p.position);
// `Level.hasNearbyAlivePlayer`: alive, not a spectator, in the level.
let players: Vec<_> = self
.level_players()
.into_iter()
.filter(|p| p.alive && !p.spectator)
.map(|p| p.position)
.collect();
let mut sprung = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::SkeletonHorse || !m.extra.trap || !m.alive() {
@@ -296,7 +298,10 @@ impl Game<'_> {
continue;
}
}
if player.is_some_and(|p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE) {
if players
.iter()
.any(|&p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE)
{
sprung.push(m.id);
}
}
Loaded 100 of 326 files, more files were not shown because too many files have changed in this diff. Show more