Compare commits

...
Author SHA1 Message Date
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 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
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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
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
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 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 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 5bbb61aec2 Angry endermen open their mouth on a server thread 2026-10-27 06:23:23 +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
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
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
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 fba004eb3d Skip leaves in the no-leaves heightmap for firefly bush sounds 2026-10-26 21:22:41 +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 fbdd04d817 Use armor stand, boat, cart and item eye heights for facing entity eyes 2026-10-26 19:58:03 +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 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 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 50fdcad10f A cookie poisons and kills a parrot 2026-10-26 19:40:42 +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 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 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 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 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 0cc3f567fc Play the bubble pop sound when an air bubble bursts 2026-10-26 19:30:29 +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 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 9aabeb39ee Make /tp facing look from the feet, or the eyes under anchored eyes 2026-10-26 19:18:50 +01:00
Kacpherek a44581eaf3 Let a shulker bullet's levitation run out on mobs 2026-10-26 19:14:23 +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 4c8ff02969 Make skeleton arrow spread and damage follow the difficulty 2026-10-26 18:51:03 +01:00
Kacpherek 9d4c984e03 Speed up an enderman while it has a target 2026-10-26 18:49:31 +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
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
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
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
187 changed files with 8834 additions and 1696 deletions

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
# A line with only `paused` stops every reviewer's `/nightly` (Ludovic's still work).
kacpherek # added by Ludovic, 2026-10-27
rdbtCVS # added by Ludovic, 2026-10-27
levzzz5154 # added by Ludovic, 2026-10-27
Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
+8
View File
@@ -95,6 +95,14 @@ jobs:
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
+3 -2
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 |
@@ -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` |
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",
+3 -3
View File
@@ -31,7 +31,7 @@ Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that re
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.
**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:
@@ -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
+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};
@@ -74,6 +74,9 @@ 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,
@@ -570,6 +570,10 @@ fn player_damage(e: &MobEvent) -> f32 {
MobEvent::AttackPlayer(amount, _)
| MobEvent::AttackPlayerWithEffect { amount, .. }
| MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
_ => 0.0,
}
}
@@ -583,6 +587,10 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
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])
@@ -1069,6 +1077,8 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
passenger: false,
}
});
+145 -1
View File
@@ -93,6 +93,14 @@ enum Animate {
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 {
@@ -225,6 +233,11 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
// 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") =>
{
@@ -295,6 +308,10 @@ 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`).
@@ -391,6 +408,11 @@ impl AnimateTables {
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,
})
@@ -416,6 +438,12 @@ 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.
@@ -474,6 +502,12 @@ 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])
}
@@ -532,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(
@@ -688,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,
@@ -763,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) {
@@ -1152,6 +1203,99 @@ 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();
+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;
@@ -2138,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) {
+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,
+46 -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()),
)]),
)]),
)]),
);
@@ -715,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!(
@@ -854,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 {
@@ -1114,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"));
@@ -1121,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);
}
@@ -1151,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);
@@ -1326,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> {
@@ -1337,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),
@@ -2246,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;
+229 -34
View File
@@ -107,6 +107,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
@@ -150,6 +151,7 @@ mod recipe_book;
mod remote;
mod remote_chunks;
mod remote_mob_data;
mod remote_mob_particles;
mod remote_mob_use;
mod remote_objects;
mod remote_projectiles;
@@ -170,9 +172,12 @@ mod server_packs;
mod server_travel;
mod shader_packs;
mod shield;
mod shoulders;
mod sign_edit;
mod sniffers;
mod spawner;
mod spear;
mod spectator;
mod spill;
mod startup_menu;
mod station_control;
@@ -578,6 +583,8 @@ struct GameLevel<'w> {
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// The item frames a comparator reads through a conductor.
item_frames: &'w [horizon_client::entities::ItemFrame],
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// Arrow and thrown trident boxes (wooden buttons).
@@ -839,6 +846,20 @@ impl LevelAccess for GameLevel<'_> {
}
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
}
fn item_frame_analog_output(&self, pos: BlockPos, facing: Direction) -> Option<i32> {
let mut frames = self
.item_frames
.iter()
.filter(|f| f.pos == [pos.x, pos.y, pos.z] && f.facing == facing as u8);
let frame = frames.next().filter(|_| frames.next().is_none())?;
// ItemFrame.getAnalogOutput.
Some(
frame
.item
.as_ref()
.map_or(0, |_| i32::from(frame.rotation % 8) + 1),
)
}
fn block_entity_signal(
&self,
pos: BlockPos,
@@ -1471,6 +1492,8 @@ struct Game<'a> {
/// The shader pack's entity and block entity ids, while one draws entities.
pack_ids: Option<shader_packs::PackIds>,
started: Instant,
/// `SpectatorGui`: the menu a spectator opens with the hotbar keys.
spectator_gui: horizon_client::spectator_menu::SpectatorGui,
previous: Instant,
/// FramerateLimitTracker.latestInputTime.
last_input: Instant,
@@ -1506,6 +1529,10 @@ struct Game<'a> {
cart_sounds: HashSet<u32>,
/// The minecart the riding sounds were started for (`LocalPlayer.startRiding`).
riding_cart_sounds: Option<u32>,
/// Bees whose buzz is playing, and whether it is the aggressive one.
bee_sounds: HashMap<u32, bool>,
/// Guardians whose attack hum is playing.
guardian_sounds: HashSet<u32>,
dropped: Vec<ItemEntity>,
next_entity: u32,
particles: horizon_client::particles::ParticleEngine,
@@ -1616,6 +1643,8 @@ struct Game<'a> {
/// The head of the bed while sleeping; the visual timer also survives waking.
sleeping: Option<[i32; 3]>,
sleep_timer: u32,
/// `SleepStatus`'s active and sleeping players at the last announcement.
sleep_seen: (i64, i64),
dim: Dim,
world_root: PathBuf,
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
@@ -1821,6 +1850,9 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
/// the song's network id is only known once the hub is in hand.
lan_songs: Vec<([i32; 3], Option<String>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
@@ -1828,6 +1860,18 @@ struct Game<'a> {
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Spear stabs that landed (the stabbing guest), queued while the hub is
/// taken: `broadcastEntityEvent(player, 2)`.
lan_kinetic_hits: Vec<Option<u32>>,
/// `LivingEntity.recentKineticEnemies`: when each player (a guest, or the
/// window's own) last stabbed each entity this use.
stabbed: HashMap<(Option<u32>, u32), i64>,
/// The movement a guest's client reported this tick
/// (`ServerPlayer.getKnownMovement`), for their turn.
known_movement: Vec3,
/// `LivingEntity.lastKineticHitFeedbackTime` of the entities that felt a
/// spear's hit (`spear::OWN` is this window's player), in game time.
kinetic_feedback: HashMap<i32, i64>,
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
lan_global_events: Vec<(i32, [i32; 3], i32)>,
/// `ContainerOpenersCounter`: how many players (the host and the guests)
@@ -2471,6 +2515,7 @@ impl Game<'_> {
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: self.next_entity,
stack,
position: p,
@@ -2694,6 +2739,7 @@ impl Game<'_> {
],
);
let feet = [p.position.x, p.position.y, p.position.z];
let own = self.level.ensure_player_uuid();
let mut kept = Vec::with_capacity(items.len());
for mut item in items {
let b = item.bounds();
@@ -2703,7 +2749,8 @@ impl Game<'_> {
&& b.max_position().y > min[1]
&& b.min_position().z < max[2]
&& b.max_position().z > min[2];
if touching && item.pickup_delay == 0 {
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
let before = item.stack.count;
let kind = item.stack.item.clone();
match self.give(item.stack.clone()) {
@@ -2837,6 +2884,7 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
item_frames: &self.item_frames,
cart_signals,
arrow_boxes,
blockers,
@@ -3197,6 +3245,12 @@ impl Game<'_> {
data,
} => self.sculk_charge_event(pos, data),
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
// LevelEventHandler 1010 and 1011: the parrots nearby dance or stop.
LevelEvent::LevelEvent {
id: id @ (1010 | 1011),
pos,
..
} => self.notify_record_playing([pos.x, pos.y, pos.z], id == 1010),
// LevelEventHandler 2019 and 2020: the crack on a face of the
// block being mined, with its hit sound for 2020.
LevelEvent::LevelEvent {
@@ -4270,6 +4324,7 @@ impl Game<'_> {
|| self.use_fishing_rod(&stack)
|| self.use_crossbow(&stack)
|| self.use_trident(&stack)
|| self.use_spear(&stack)
|| self.boost_firework(&stack)
|| self.use_empty_map(&stack)
|| self.use_book(&stack)
@@ -4284,6 +4339,14 @@ impl Game<'_> {
self.use_spyglass();
return Ok(true);
}
// BundleItem.use: startUsingItem, then onUseTick empties it.
if !on_block && info.bundle {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.swing_arm();
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:bow" {
if self.using.is_none()
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
@@ -4547,6 +4610,12 @@ impl Game<'_> {
self.using = Some((slot, ticks + 1));
return;
}
// ItemStack.onUseTick: a spear stabs what its speed allows, until released.
if horizon_client::spear::Kinetic::of(&stack.item).is_some() {
self.spear_tick(&stack, ticks);
self.using = Some((slot, ticks + 1));
return;
}
if &*stack.item == "minecraft:spyglass" {
// SpyglassItem.USE_DURATION, then finishUsingItem.
if ticks + 1 >= 1200 {
@@ -4562,6 +4631,10 @@ impl Game<'_> {
self.tick_brush(slot, ticks);
return;
}
if info.bundle {
self.tick_bundle(slot, ticks);
return;
}
let Some(consumable) = info.consumable.as_ref() else {
self.using = None;
return;
@@ -4584,6 +4657,53 @@ impl Game<'_> {
self.using = Some((slot, ticks));
}
}
/// `BundleItem.onUseTick` and `getUseDuration` (200): the first tick and
/// then every second tick from the 11th on drop one stack out of the
/// bundle in `slot`; the use ends after 200 ticks.
fn tick_bundle(&mut self, slot: usize, ticks: u32) {
const USE_DURATION: u32 = 200;
let remaining = USE_DURATION - ticks.min(USE_DURATION);
if ticks == 0 || (remaining < USE_DURATION - 10 && remaining.is_multiple_of(2)) {
self.drop_bundle_content(slot);
}
self.using = (ticks + 1 < USE_DURATION).then_some((slot, ticks + 1));
}
/// `BundleItem.dropContent`: `removeOneItemFromBundle` and `Player.drop`,
/// then the drop sound and the used statistic.
fn drop_bundle_content(&mut self, slot: usize) {
let items = self.res.items();
let Some(mut bundle) = self.stack_in(slot).cloned() else {
return;
};
let Some(mut contents) = horizon_client::bundle::Contents::of(items, &bundle) else {
return;
};
let Some(removed) = contents.remove_one(items) else {
return;
};
contents.store(&mut bundle);
let item = Arc::clone(&bundle.item);
*self.stack_in_mut(slot) = Some(bundle);
let Some(position) = self.player.as_ref().map(|p| p.position) else {
return;
};
let at = [position.x, position.y, position.z];
// playRemoveOneSound, then Player.drop and playDropContentsSound at
// the player's block.
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio
.play(horizon_client::bundle::REMOVE_ONE, Some(at), 0.8, pitch);
self.player_drop(removed);
let block = BlockPos::containing(position);
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(
horizon_client::bundle::DROP_CONTENTS,
Some([block.x, block.y, block.z].map(|v| f64::from(v) + 0.5)),
0.8,
pitch,
);
self.stats.add("used", &item, 1);
}
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
/// when its hand changed), a spyglass stops scoping.
@@ -4856,6 +4976,7 @@ impl Game<'_> {
self.screen = Screen::Dead;
self.ui.died_at = Some(Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
self.capture(false);
}
self.previous_position = feet;
@@ -5201,6 +5322,7 @@ impl Game<'_> {
self.location_changed_effects(before.2, before.0);
}
self.tick_totem();
self.handle_shoulder_entities();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -5214,6 +5336,9 @@ impl Game<'_> {
let feet = [player.position.x, player.position.y, player.position.z];
let at = [feet[0], feet[1] + 1.32, feet[2]];
let killer = self.combat_killer_kind();
// Player.die: the dimension and block of the death.
self.level.last_death =
Some((self.dim.id().to_owned(), feet.map(|v| v.floor() as i32)));
self.death_message();
// ServerPlayer.die: deaths, and the time-since counters restart.
self.stats.custom("deaths", 1);
@@ -5323,6 +5448,7 @@ impl Game<'_> {
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
horizon_client::spans::time("tick_portal", || self.tick_nether_portal())?;
if running {
horizon_client::spans::time("tick_end", || self.tick_end())?;
@@ -5801,7 +5927,9 @@ impl Game<'_> {
let draws = hud_layout::crosshair(wi, hi, attack, full);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
}
if !spectator {
if spectator {
self.draw_spectator_hotbar(&mut batch);
} else {
let partial = self.clock.fraction() as f32;
let inputs = hud_layout::HotbarInputs {
selected: self.selected as i32,
@@ -5930,6 +6058,19 @@ impl Game<'_> {
.health_hud
.left_height
.max(self.health_hud.right_height);
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
// by this client only.
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
let at = [p.position.x, p.position.y, p.position.z];
let players = horizon_client::settings::SOUND_SOURCES
.iter()
.position(|s| *s == "player")
.unwrap_or(0);
let capture = self.audio.capture.take();
self.audio
.play_on(players, "ui.hud.bubble_pop", Some(at), volume, pitch);
self.audio.capture = capture;
}
}
if contextual == ContextualBar::Locator {
let draws = self.locator_bar_draws(wi, hi);
@@ -7702,7 +7843,9 @@ impl Game<'_> {
self.yaw
};
for m in &self.mobs {
if m.fire_ticks > 0 && !m.removed {
// Blaze.isOnFire: a charged blaze burns.
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
if (m.fire_ticks > 0 || blazing) && !m.removed {
let at = m.previous.lerp(m.position, alpha);
horizon_client::fire_render::flames(
[at.x, at.y, at.z],
@@ -8253,8 +8396,7 @@ impl Game<'_> {
}
/// `Player.getAttackStrengthScale(0.5)`.
fn attack_strength_scale(&self) -> f32 {
let speed = self.attack_item_info().map_or(0.0, |i| i.attack_speed);
let delay = 20.0 / (4.0 + speed).max(0.1);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0)
}
/// `Player.isSweepAttack`: a charged, grounded sword hit below the movement limit.
@@ -8291,18 +8433,9 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
/// The damage and knockback part of `Player.doSweepAttack` after a sweep
/// hit on the mob at `target`: every other living mob in its box grown by
/// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative
/// too), but the player's allies
/// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the
/// weapon's enchantments, scaled by the attack strength, and is pushed 0.4
/// along the player's yaw.
pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) {
let Some(p) = self.player.as_ref() else {
return;
};
let from = [p.position.x, p.position.y, p.position.z];
/// What `Player.doSweepAttack` deals before the target's enchantment
/// bonus and the attack strength: `1 + sweeping_damage_ratio * baseDamage`.
pub(super) fn sweep_base_damage(&self, scale: f32) -> f32 {
// `baseDamage` of `Player.attack` before a critical hit (a sweep has
// none) and the enchantments: the attack damage attribute with the
// strength and weakness effects, scaled by the attack strength.
@@ -8318,7 +8451,21 @@ impl Game<'_> {
"minecraft:sweeping_damage_ratio",
)
.clamp(0.0, 1.0) as f32;
let sweep_damage = 1.0 + ratio * base_damage;
1.0 + ratio * base_damage
}
/// The damage and knockback part of `Player.doSweepAttack` after a sweep
/// hit on the mob at `target`: every other living mob in its box grown by
/// (1, 0.25, 1) and closer than 3 blocks (`distanceToSqr < 9`, in creative
/// too), but the player's allies
/// (`isAlliedTo`), takes `1 + sweeping_damage_ratio * baseDamage` with the
/// weapon's enchantments, scaled by the attack strength, and is pushed 0.4
/// along the player's yaw.
pub(super) fn sweep_damage(&mut self, target: usize, scale: f32) {
let Some(p) = self.player.as_ref() else {
return;
};
let from = [p.position.x, p.position.y, p.position.z];
let sweep_damage = self.sweep_base_damage(scale);
let hit_box = self.mobs[target]
.bounds()
.inflate(horizon_core::geometry::Vec3::new(1.0, 0.25, 1.0));
@@ -8353,6 +8500,10 @@ impl Game<'_> {
.insert(self.mobs[i].id, self.lan_acting);
}
}
// `getEntitiesOfClass(LivingEntity.class)` takes the other players too.
let mut sweep = self.player_sweep(sweep_damage, scale);
sweep.hit_box = hit_box;
self.sweep_other_players(sweep);
}
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
@@ -10390,7 +10541,17 @@ impl Game<'_> {
let ticks_used =
owner.use_duration as f32 - (owner.use_item_remaining_ticks as f32 - a + 1.0);
use horizon_client::first_person::ItemPose;
// ItemUseAnimation.SPEAR.
let spear = using
.then(|| horizon_client::spear::Kinetic::of(&stack.item))
.flatten()
.map(|kinetic| ItemPose::Spear {
kinetic,
time_held: ticks_used,
ticks_since_hit: self.kinetic_hit_ticks(a),
});
let pose = match &*stack.item {
_ if let Some(pose) = spear => pose,
"minecraft:bow" if using => ItemPose::Bow { ticks_used },
"minecraft:crossbow" => {
let charged = stack.data().is_some_and(|d| !d.charged.is_empty());
@@ -10512,19 +10673,13 @@ impl Game<'_> {
}
}
/// Whether precipitation at a column is snow (Biome.coldEnoughToSnow:
/// base temperature lowered above y 80) or none (no precipitation).
/// height adjusted temperature) or none (no precipitation).
fn precipitation(&self, x: i32, y: i32, z: i32) -> Option<bool> {
let chunk = self.world.chunks.get(&(x >> 4, z >> 4))?;
let b = self.res.biomes().get(chunk.biome(x, y, z));
if !b.precipitation {
return None;
}
let t = if y > 80 {
b.temperature - (y - 80) as f32 * 0.05 / 40.0
} else {
b.temperature
};
Some(t < 0.15)
self.res
.biomes()
.get(chunk.biome(x, y, z))
.snows_at(x, y, z)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
@@ -10826,6 +10981,8 @@ impl Game<'_> {
self.wake_up();
}
self.wake_guests();
// SleepStatus.removeAllSleepers: waking everyone is not announced.
self.sleep_seen.1 = 0;
}
}
fn creative_origin(batch: &HudBatch) -> (f32, f32) {
@@ -11976,11 +12133,16 @@ impl Game<'_> {
KeyCode::Digit8 => 7,
_ => 8,
};
// Minecraft.handleKeybinds: a creative player holding the load
// or save toolbar key loads or saves a toolbar instead (load
// first when both are held).
// Minecraft.handleKeybinds: a spectator's number keys drive
// the spectator menu, not the hotbar; a creative player
// holding the load or save toolbar key loads or saves a
// toolbar instead (load first when both are held).
let load = self.mapped_key_down("key.loadToolbarActivator");
if self.creative && (load || self.mapped_key_down("key.saveToolbarActivator")) {
if self.spectator() {
self.spectator_hotbar_key(slot as i32);
} else if self.creative
&& (load || self.mapped_key_down("key.saveToolbarActivator"))
{
self.hotbar_load_or_save(slot, load);
} else {
self.selected = slot;
@@ -11999,6 +12161,14 @@ impl Game<'_> {
if self.sleeping.is_some() {
return Ok(());
}
// key.spectatorHotbar (the middle button) opens the spectator menu
// and uses its selected item.
if button == MouseButton::Middle && self.spectator() {
if pressed {
self.spectator_action_key();
}
return Ok(());
}
if self.remote.is_some() {
return self.remote_mouse(button, pressed);
}
@@ -12563,7 +12733,9 @@ impl ApplicationHandler for Game<'_> {
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
{
if self.spectator() {
if self.spectator() && self.spectator_gui.is_menu_active() {
self.spectator_scroll(-wheel);
} else if self.spectator() {
if let Some(player) = &mut self.player {
player.flying_speed = horizon_client::mouse::spectator_flying_speed(
player.flying_speed,
@@ -12844,6 +13016,20 @@ impl ApplicationHandler for Game<'_> {
_ => {}
}
}
// ConfirmScreen.keyPressed: Escape answers no, once its delay is over.
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Dead && self.ui.death_confirm.is_some() =>
{
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
&& self
.ui
.death_confirm
.is_some_and(|t| t.elapsed().as_millis() >= 1000)
{
self.death_confirm_answer(false)?;
}
}
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
if event.state == ElementState::Pressed
@@ -13466,6 +13652,7 @@ impl<'a> Game<'a> {
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
started: now,
spectator_gui: Default::default(),
previous: now,
last_input: now,
pad: Default::default(),
@@ -13494,6 +13681,8 @@ impl<'a> Game<'a> {
sound_paused: false,
cart_sounds: HashSet::new(),
riding_cart_sounds: None,
bee_sounds: HashMap::new(),
guardian_sounds: HashSet::new(),
dropped: Vec::new(),
next_entity: 0,
particles,
@@ -13559,6 +13748,7 @@ impl<'a> Game<'a> {
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
sleep_seen: (0, 0),
dim: Dim::Overworld,
world_root: PathBuf::new(),
world_flat: false,
@@ -13686,10 +13876,15 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_songs: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_kinetic_hits: Vec::new(),
stabbed: HashMap::new(),
known_movement: Vec3::ZERO,
kinetic_feedback: HashMap::new(),
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
+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 {
@@ -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);
+66 -33
View File
@@ -104,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,
}
@@ -750,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);
@@ -769,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 => {}
}
@@ -837,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;
@@ -891,17 +924,39 @@ impl Game<'_> {
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// 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);
// 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) {
@@ -1046,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;
}
@@ -1154,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()
}
+42 -9
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;
}
@@ -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,
@@ -1220,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;
};
+333 -57
View File
@@ -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
@@ -221,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(),
})
}
@@ -451,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]),
),
}
@@ -508,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()
@@ -938,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) {
@@ -1017,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
@@ -1155,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),
@@ -1232,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)
@@ -1291,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)?,
@@ -1353,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);
@@ -3040,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);
}
@@ -3204,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();
@@ -3239,13 +3372,26 @@ impl Game<'_> {
.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;
@@ -3330,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 => {
@@ -3344,6 +3507,36 @@ impl Game<'_> {
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)
@@ -3463,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;
@@ -3509,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 => {
@@ -3520,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 });
@@ -3640,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 = [
@@ -3662,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 {
@@ -4010,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);
@@ -4244,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",
@@ -4321,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;
}
@@ -673,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!(
@@ -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!(
+9 -15
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()
@@ -3029,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(
@@ -73,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 {
@@ -106,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),
@@ -148,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 };
@@ -215,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) {
+99 -4
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::{
@@ -47,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;
@@ -168,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
@@ -476,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;
@@ -501,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| {
@@ -514,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()
@@ -528,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()
@@ -602,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;
}
}
+31 -7
View File
@@ -352,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> {
@@ -394,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()
@@ -461,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()
}
+12 -1
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)
@@ -250,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]);
+245 -15
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))));
}
@@ -1078,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();
@@ -1300,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);
@@ -1569,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();
}
@@ -1709,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
@@ -1737,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
@@ -1771,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);
}
@@ -1815,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);
@@ -1836,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)
@@ -1900,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 {
@@ -1927,6 +2113,29 @@ 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()
@@ -1951,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());
}
}
@@ -2001,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);
+258 -28
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()
@@ -1511,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;
@@ -1623,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;
@@ -1663,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);
@@ -2016,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;
@@ -2039,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 {
@@ -2138,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,
@@ -2219,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;
@@ -2247,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() {
@@ -2307,11 +2517,13 @@ 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);
@@ -2332,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.
@@ -2421,6 +2639,7 @@ impl Game<'_> {
// 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);
};
@@ -2459,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),
@@ -2763,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;
@@ -2776,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
@@ -3018,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> {
+23 -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
{
@@ -83,6 +97,10 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::DamagePlayer { .. }
| MobEvent::SonicBoom {
target: Who::Player,
..
}
| MobEvent::Push {
target: Who::Player,
..
@@ -101,6 +119,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
| MobEvent::ParrotToShoulder { .. }
)
}
@@ -394,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
+136 -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),
@@ -49,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 {
@@ -127,6 +148,10 @@ 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;
@@ -151,6 +176,7 @@ impl Game<'_> {
knockback: (strength, xd, zd),
knockback_attack,
team: self.player_team().cloned(),
sweep_hit,
})
}
@@ -344,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);
}
}
}
}
}
+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,
@@ -1189,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,
@@ -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);
+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);
}
}
+31 -8
View File
@@ -381,7 +381,15 @@ impl Game<'_> {
m.head_pitch = 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;
// 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(yaw) - walk).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
if swinging {
target = yaw;
@@ -463,7 +471,14 @@ impl Game<'_> {
&self.player_armor_layers(),
out,
);
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.player_elytra_geometry(
m,
self.armor[1].as_ref().map(|s| &*s.item),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
@@ -528,7 +543,14 @@ impl Game<'_> {
&self.player_armor_layers(),
&mut verts,
);
self.player_elytra_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
self.player_elytra_geometry(
&m,
self.armor[1].as_ref().map(|s| &*s.item),
camera,
1.0,
240 | 240 << 8,
&mut verts,
);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
// PictureInPictureRenderer: the box centre, (size, size, -size);
@@ -567,17 +589,18 @@ impl Game<'_> {
}
impl Game<'_> {
fn player_elytra_geometry(
/// `WingsLayer` for a player wearing `chest`.
pub(super) fn player_elytra_geometry(
&self,
m: &Mob,
chest: Option<&str>,
camera: glam::DVec3,
partial: f32,
light: u32,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(asset) = self.armor[1]
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
let Some(asset) = chest
.and_then(|item| self.res.items().get(item))
.and_then(|i| i.equip_asset.as_ref())
else {
return;
@@ -617,7 +640,7 @@ impl Game<'_> {
}
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
/// player (`SpinAttackEffectModel.setupAnim`).
fn spin_attack_geometry(
pub(super) fn spin_attack_geometry(
&self,
m: &Mob,
camera: glam::DVec3,
+160 -14
View File
@@ -23,6 +23,7 @@ use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct Raiding {
patrol: PatrolSpawner,
siege: Siege,
/// The raids of the dimension the player is in, and that dimension.
raids: Raids,
loaded: Option<Dim>,
@@ -38,6 +39,17 @@ pub(super) struct Raiding {
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
}
/// `VillageSiege`: whether tonight is a siege, whether the spot is chosen,
/// and the zombies still to spawn there.
#[derive(Default)]
struct Siege {
tonight: bool,
set_up: bool,
zombies_to_spawn: i32,
next_spawn_time: i32,
spot: [i32; 3],
}
impl Raiding {
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
/// level's (game/levels.rs).
@@ -102,11 +114,12 @@ impl RaidLevel for Lvl<'_, '_> {
&& g.res.facts().is_air(g.world.block(x, y, z))
}
fn player(&self) -> Option<RaidPlayer> {
let p = self.game.player.as_ref()?;
// The first player in the level: a server has no player of its own.
let p = self.game.level_players().into_iter().next()?;
Some(RaidPlayer {
position: [p.position.x, p.position.y, p.position.z],
block: block_of(p.position),
alive: !p.health.dead_or_dying(),
alive: p.alive,
})
}
}
@@ -174,12 +187,13 @@ impl PatrolLevel for PatrolCx<'_, '_, '_> {
fn bright_outside(&self) -> bool {
self.level.game.dim == Dim::Overworld && self.level.game.sky_darken() < 4
}
/// `PatrolSpawner.tick` picks from `level.players()`: the local player
/// and every guest in the level.
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
let g = self.level.game;
g.player
.as_ref()
.map(|p| (block_of(p.position), g.spectator()))
.into_iter()
self.level
.players
.iter()
.map(|p| (block_of(p.position), p.spectator))
.collect()
}
fn sections_to_village(&self, pos: [i32; 3]) -> i32 {
@@ -633,7 +647,7 @@ impl Game<'_> {
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
/// wave, for players within 64 blocks or in the raid.
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
let Some(p) = self.player.as_ref().map(|p| p.position) else {
let Some(p) = self.level_players().first().map(|p| p.position) else {
return;
};
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
@@ -791,7 +805,7 @@ impl Game<'_> {
let has_rider = ridden.contains(&m.id);
let target_golem = matches!(m.ai.target, Some(horizon_client::mobs::Who::Mob(t)) if golems.contains(&t));
let target_player = m.ai.target == Some(horizon_client::mobs::Who::Player);
let (health, age, range) = (m.health, m.age, m.attrs.follow_range);
let (health, range) = (m.health, m.attrs.follow_range);
let raids = &mut self.raiding.raids;
let s = m.extra.raider.get_or_insert_with(Box::default);
s.rider = has_rider;
@@ -836,11 +850,9 @@ impl Game<'_> {
{
joins.push((m.id, id, health));
}
// PathfindToRaidGoal.recruitNearby.
if s.active_raid
&& !s.raid_over
&& !s.in_village
&& age % 20 == 0
// PathfindToRaidGoal.tick: recruitNearby, only while the goal runs.
if std::mem::take(&mut s.recruit_due)
&& s.active_raid
&& let Some(id) = s.raid_id
{
recruit.push((id, position));
@@ -1218,6 +1230,140 @@ impl Game<'_> {
self.spawn_event_mob("minecraft:pillager", at, m.leader, raider::Reason::Patrol);
}
}
/// `VillageSiege.tick`: at midnight one night in ten, 20 zombies spawn
/// two ticks apart near a village 32 blocks from a player standing in it.
pub(super) fn tick_village_siege(&mut self) {
if self.dim != Dim::Overworld {
return;
}
let enemies = self.ui.rules.bool("spawn_mobs") && self.ui.rules.bool("spawn_monsters");
if self.sky_darken() < 4 || !enemies {
self.raiding.siege.tonight = false;
self.raiding.siege.set_up = false;
return;
}
// ClockTimeMarkers.ROLL_VILLAGE_SIEGE: tick 18000 of the 24000 tick day.
if self.world.day_time.rem_euclid(24000) == 18000 {
self.raiding.siege.tonight = self.world.random.next_int_bound(10).unwrap_or(1) == 0;
}
if !self.raiding.siege.tonight {
return;
}
if !self.raiding.siege.set_up {
if !self.try_to_setup_siege() {
return;
}
self.raiding.siege.set_up = true;
}
if self.raiding.siege.next_spawn_time > 0 {
self.raiding.siege.next_spawn_time -= 1;
return;
}
self.raiding.siege.next_spawn_time = 2;
if self.raiding.siege.zombies_to_spawn > 0 {
self.try_spawn_siege_zombie();
self.raiding.siege.zombies_to_spawn -= 1;
} else {
self.raiding.siege.tonight = false;
}
}
/// `ServerLevel.isVillage`: within one section of a village.
fn is_village(centers: &HashSet<(i32, i32, i32)>, pos: [i32; 3]) -> bool {
sections_to_village(centers, section(pos)) <= 1
}
/// `VillageSiege.tryToSetupSiege`: the first player standing in a village
/// picks a spot 32 blocks away (ten tries); true when one stood in a
/// village, even if no spot was found.
fn try_to_setup_siege(&mut self) -> bool {
let centers = self.village_centers();
for player in self.level_players() {
if player.spectator {
continue;
}
let center = block_of(player.position);
if !Self::is_village(&centers, center)
|| self.natural_level().biome_tag(
"minecraft:without_zombie_sieges",
center[0],
center[1],
center[2],
)
{
continue;
}
for _ in 0..10 {
let angle = self.world.random.next_float() * std::f32::consts::TAU;
let spot = [
center[0] + (angle.cos() * 32.0).floor() as i32,
center[1],
center[2] + (angle.sin() * 32.0).floor() as i32,
];
if self.find_siege_spawn_pos(&centers, spot).is_some() {
self.raiding.siege.spot = spot;
self.raiding.siege.next_spawn_time = 0;
self.raiding.siege.zombies_to_spawn = 20;
break;
}
}
return true;
}
false
}
/// `VillageSiege.trySpawn`: one zombie, in a random yaw.
fn try_spawn_siege_zombie(&mut self) {
let centers = self.village_centers();
let spot = self.raiding.siege.spot;
let Some(at) = self.find_siege_spawn_pos(&centers, spot) else {
return;
};
let Some(id) = self.spawn_event_mob("minecraft:zombie", at, false, raider::Reason::Event)
else {
return;
};
let yaw = self.world.random.next_float() * 360.0;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
(m.yaw, m.previous_yaw, m.body_yaw, m.previous_body_yaw) = (yaw, yaw, yaw, yaw);
(m.head_yaw, m.previous_head_yaw) = (yaw, yaw);
}
self.spawn_jockeys(id, false);
}
/// `VillageSiege.findRandomSpawnPos`: ten tries within 8 blocks of the
/// spot for a village column where a zombie may spawn.
fn find_siege_spawn_pos(
&mut self,
centers: &HashSet<(i32, i32, i32)>,
spot: [i32; 3],
) -> Option<[f64; 3]> {
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
let mut found = None;
for _ in 0..10 {
let x = spot[0] + random.next_int_bound(16).unwrap_or(0) - 8;
let z = spot[2] + random.next_int_bound(16).unwrap_or(0) - 8;
if !self.world.chunks.contains_key(&(x >> 4, z >> 4)) {
continue;
}
let y = self.surface_height(x, z);
if Self::is_village(centers, [x, y, z])
&& spawn_rules::check_spawn_rules(
Species::Zombie,
&self.natural_level(),
spawn_rules::Reason::Event,
&mut random,
x,
y,
z,
)
{
found = Some([f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
break;
}
}
self.world.random = random;
found
}
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
/// patrol captain's ominous bottle (`entities/pillager`).
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
+254 -32
View File
@@ -160,6 +160,9 @@ pub(super) struct Remote {
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
@@ -206,7 +209,7 @@ pub(super) struct Remote {
placing: Option<([i32; 3], u64)>,
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
pub(super) flat: bool,
death_message: Option<String>,
death_message: Option<horizon_client::text::Text>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
@@ -478,6 +481,7 @@ impl Game<'_> {
lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
@@ -696,10 +700,14 @@ impl Game<'_> {
}
let text = content.plain();
log::info!("[chat] {text}");
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
None,
indicator,
source,
self.ticks,
self.screen == Screen::Chat,
@@ -1031,6 +1039,12 @@ impl Game<'_> {
r.predictions.clear();
self.set_mode_id(spawn.game_type);
self.dim = Dim::from_id(&spawn.dimension);
// ClientPacketListener.handleLogin / handleRespawn: the server's
// Player.getLastDeathLocation, for the recovery compass.
self.level.last_death = spawn
.last_death
.as_ref()
.map(|d| (d.dimension.clone(), d.pos));
if !same {
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
@@ -2083,6 +2097,13 @@ impl Game<'_> {
m.extra.ints[1] = code;
}
}
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
@@ -2172,7 +2193,23 @@ impl Game<'_> {
)
})
.collect();
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
s.as_ref().is_some_and(|s| {
matches!(
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
Some(horizon_protocol::item::Component::Stacks(list))
if !list.is_empty()
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
m.extra.charged = charged;
}
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
@@ -2185,7 +2222,11 @@ impl Game<'_> {
3 => e.legs = item,
4 => e.chest = item,
5 if !body => e.head = item,
6 if body => e.head = item,
6 if body => {
// A wolf's armor shows its cracks.
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
e.head = item;
}
7 => m.set_saddle(stack),
_ => {}
}
@@ -2272,6 +2313,10 @@ impl Game<'_> {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
if p.event == 2 {
self.remote_kinetic_hit(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -2279,6 +2324,14 @@ impl Game<'_> {
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
// Ravager.handleEntityEvent 69: the roar's puff and push.
if p.event == 69 {
self.remote_ravager_roar(p.id);
}
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
@@ -2379,7 +2432,7 @@ impl Game<'_> {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing();
m.swing_hand_modified(hand == Hand::Off, animation.duration);
}
}
// The player's own instances: its maximum health (the hearts the
@@ -2423,6 +2476,7 @@ impl Game<'_> {
{
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
}
}
@@ -2430,7 +2484,7 @@ impl Game<'_> {
// The component resolves in the client's current language, as
// the chat line does, not in the language the session joined
// with (`Remote.lang` is the vanilla server-language table).
let message = remote_text(&p.message).plain();
let message = remote_text(&p.message);
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
@@ -2442,6 +2496,7 @@ impl Game<'_> {
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
} else {
self.respawn_player()?;
@@ -3269,6 +3324,7 @@ impl Game<'_> {
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
@@ -3331,6 +3387,37 @@ impl Game<'_> {
p.health.food.saturation = saturation;
}
}
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
/// simulates the ghast it rides, so its copy is where the server has it;
/// this window's copy trails the server's by the stepped interpolation and
/// would rise into the rider's feet, which lets the rider fall through.
/// Put the copy under the landing spot.
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
else {
return;
};
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
// Only a teleport that is that dismount: right above the ghast.
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|| (at.y - m.position.y).abs() > 5.0
{
return;
}
m.position = at;
m.previous = at;
if let Some(handler) = self
.remote
.as_mut()
.and_then(|r| r.stepped.get_mut(&(id as i32)))
{
handler.cancel();
}
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
@@ -3390,6 +3477,9 @@ impl Game<'_> {
self.previous_position = position;
self.yaw = yaw;
self.pitch = pitch;
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
self.place_ghast_under(id, position);
}
if let Some(r) = self.remote.as_mut() {
// handleMovePlayer does not change LocalPlayer.sendPosition's
// baseline: the next tick must still report the corrected state.
@@ -3849,6 +3939,34 @@ impl Game<'_> {
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
.mobs
.iter()
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
{
let own = self
.remote
.as_ref()
.zip(self.eye_position())
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
self.mobs
.iter()
.map(|m| {
(
m.id as i32,
[m.position.x, m.position.y, m.position.z],
m.eye_y(),
)
})
.chain(own)
.collect()
} else {
Vec::new()
};
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3857,15 +3975,12 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut wither_heads = Vec::new();
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
if m.species() == horizon_client::mobs::Species::Wither {
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -3900,6 +4015,17 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
match m.species() {
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
_ => {}
}
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3927,6 +4053,11 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Fox {
horizon_client::mobs::fox::client_tick(m);
}
// Cat.tick: the lying and relaxing amounts follow the synched flags.
if m.species() == horizon_client::mobs::Species::Cat {
m.anim.vo = m.anim.v;
horizon_client::mobs::tame::cat_amounts(m);
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
@@ -3934,6 +4065,20 @@ impl Game<'_> {
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
if player {
// AvatarRenderer.getArmPose: the used item's BLOCK animation
// (an item with `blocks_attacks`) raises the arm.
let used = if m.extra.using_off_hand {
&m.extra.off_hand
} else {
&m.extra.main_hand
};
m.extra.blocking = m.extra.using_item > 0
&& used
.as_ref()
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
@@ -3960,6 +4105,8 @@ impl Game<'_> {
// a player's body following its walk and look.
if player {
remote_player_head_turn(m);
// LivingEntity.tick: the glide count, wings and swim amount.
m.tick_avatar_pose(m.fluids.in_water);
} else if m.alive() {
m.body_rotation_tick();
}
@@ -3978,8 +4125,26 @@ impl Game<'_> {
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
if m.species() == horizon_client::mobs::Species::Panda {
horizon_client::mobs::panda::client_tick(m, thundering);
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
horizon_client::mobs::wither::client_tick(m, |id| {
wither_targets
.iter()
.find(|t| t.0 == id)
.map(|t| (t.1, t.2))
});
}
// Hoglin and piglin `isConverting` outside the Nether.
horizon_client::mobs::Species::Hoglin
| horizon_client::mobs::Species::Piglin
| horizon_client::mobs::Species::PiglinBrute => {
horizon_client::mobs::piglin::client_converting(m, nether);
}
horizon_client::mobs::Species::Panda => {
horizon_client::mobs::panda::client_tick(m, thundering);
}
_ => {}
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
@@ -4005,19 +4170,7 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
// WitherBoss.aiStep runs on a client too: smoke around each head.
let smoke = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Smoke,
);
for h in wither_heads {
let r = self.particles.random();
let at = [
h[0] + r.next_gaussian() * 0.3,
h[1] + r.next_gaussian() * 0.3,
h[2] + r.next_gaussian() * 0.3,
];
self.particles.add(&smoke, at, [0.0; 3]);
}
let _ = self.handle_mob_events(mob_fx);
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
@@ -4464,6 +4617,11 @@ impl Game<'_> {
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
self.with_hand(hand, Game::swing_arm);
return true;
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
@@ -4650,7 +4808,7 @@ impl Game<'_> {
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
@@ -4752,7 +4910,10 @@ impl Game<'_> {
if self.using.is_some() {
return;
}
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
let start = match &*held.item {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
@@ -4768,6 +4929,8 @@ impl Game<'_> {
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// Item.use: a kinetic weapon starts being used.
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
_ => {
self.raise_shield(self.held_slot());
@@ -4784,7 +4947,8 @@ impl Game<'_> {
matches!(
&*stack.item,
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
) || self.blocks_attacks(stack).is_some()
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|| self.blocks_attacks(stack).is_some()
}
/// The rest of Minecraft.startUseItem once the main hand's target passed:
@@ -4852,6 +5016,14 @@ impl Game<'_> {
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
return;
}
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
if stack
.as_ref()
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
{
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
return;
}
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
@@ -4993,7 +5165,9 @@ impl Game<'_> {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
let all = self.keys.contains(&KeyCode::ControlLeft);
// Minecraft.hasControlDown: the physical keys, so a toggled
// sprint on Left Control does not make Q drop the stack.
let all = self.has_control_down();
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
@@ -5022,6 +5196,28 @@ impl Game<'_> {
if self.spectator() {
return true;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
// stabs, this client swings, plays the sound and starts its charge
// again.
if let Some(kinetic) = self
.held()
.and_then(|s| horizon_client::spear::Kinetic::of(&s.item))
{
if self.cannot_attack_with_item(self.hand_strength.ticker, 0) {
return false;
}
// ServerboundPlayerActionPacket.Action.STAB.
self.send(S::PlayerAction(play::PlayerAction {
action: 8,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
self.hand_strength.reset_attack();
self.swing_hand(Hand::Main, kinetic.attack_animation());
self.play_at_feet(kinetic.attack_sound);
return true;
}
let mut instant = false;
// A ghast's fireball or a wind charge nearer than any mob: the
// server turns it (game/deflect.rs).
@@ -5072,7 +5268,13 @@ impl Game<'_> {
if !down {
self.clicks.miss_time = 0;
}
if self.clicks.miss_time > 0 || self.using.is_some() {
// A piercing weapon neither breaks blocks nor swings on its own.
if self.clicks.miss_time > 0
|| self.using.is_some()
|| self
.held()
.is_some_and(|s| horizon_client::spear::Kinetic::of(&s.item).is_some())
{
return;
}
let entity = self.entity_targeted();
@@ -5688,8 +5890,16 @@ impl Game<'_> {
if self.screen == Screen::Dead
&& let Some(message) = &r.death_message
{
let tw = HudBatch::text_width(atlas, message);
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
// DeathScreen draws the styled cause component.
let tw = atlas.font.width_text(message);
batch.text_styled(
atlas,
message,
((w - tw) / 2.0).floor(),
85.0,
u32::MAX,
true,
);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
@@ -5984,7 +6194,8 @@ fn hand_id(hand: Hand) -> i32 {
/// server decides; this is the client's guess of whether to go on.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
info.consumable.is_some()
id.ends_with("_spear")
|| info.consumable.is_some()
|| info.equip_slot.is_some()
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
@@ -6054,7 +6265,18 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
direction: 0,
sequence: 0,
}));
self.0.using = None;
// MultiPlayerGameMode.releaseUsingItem also runs `Item.releaseUsing` here: the
// player moves by itself, so a Riptide launch happens on the client.
let hand = self.0.used_hand();
if let Some((slot, ticks)) = self.0.using.take()
&& let Some(hand) = hand
&& self
.0
.stack_in(slot)
.is_some_and(|s| &*s.item == "minecraft:trident")
{
self.0.with_hand(hand, |g| g.release_trident(ticks));
}
}
fn start_attack(&mut self) -> bool {
self.0.remote_start_attack()
@@ -5,11 +5,13 @@
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{
Mob, Species, Who, armadillo, bat, bee, camel, creaking, finalize, panda, sniffer, sync, tame,
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
sniffer, sync, tame, zombie_villager,
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::{Builtin, Dynamic};
use horizon_world::registry::BlockStateId;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
@@ -17,6 +19,9 @@ use horizon_protocol::registry::{Builtin, Dynamic};
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
if &*m.kind.id == "minecraft:player" {
player_poses(m, values);
}
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
@@ -28,7 +33,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
(0, Value::Byte(flags)) => {
m.fire_ticks = i32::from(flags & 1);
// Drowned.setSwimming: the shared flag of swimming.
if species == Species::Drowned {
m.extra.flag = flags & 16 != 0;
}
// The shared flag of invisibility: Entity.isInvisible hides
// the model (an illusioner shows its mirror images).
let invisible = flags & 32 != 0;
if species == Species::Illusioner {
illusioner::set_invisible(m, invisible);
} else if &*m.kind.id != "minecraft:player" {
m.invisible = invisible;
}
}
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
@@ -109,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.sitting = flags & 1 != 0;
m.extra.tame = flags & 4 != 0;
}
// Cat.IS_LYING and RELAX_STATE_ONE.
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
// Wolf.DATA_COLLAR_COLOR and Cat's.
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
m.extra.color = (*c & 15) as u8;
@@ -117,6 +139,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
// and raised head of an angry enderman.
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
(Species::Warden, 16, Value::Int(anger)) => {
if let Some(st) = m.extra.warden.as_mut() {
@@ -209,9 +234,49 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::CopperGolem, 16, Value::Id(state)) => {
m.extra.variant = (*state).clamp(0, 3) as u8;
}
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
// graze and the rear), which `sync::client_tick` animates.
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
(Species::Enderman, 16, Value::Id(state)) => {
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
m.extra.counter2 = i32::from(*converting);
}
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
zombie_villager::set_converting(m, *converting);
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
// the head shake of `no`, which the server counts down.
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
m.extra.ints[0] = *ticks;
}
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's: `sync`'s conversion shake reads it.
(Species::Hoglin, 18, Value::Bool(immune))
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
m.extra.flag2 = *immune;
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
// entities the side heads turn toward in `wither::client_tick`.
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER):
// `isOnStillTimeout` on a client, which keeps the ghast solid to
// the player (`canBeCollidedWith`).
(Species::HappyGhast, 19, Value::Bool(still)) => m.extra.counter = i32::from(*still),
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
m.extra.variant2 = (*spell).max(0) as u8;
@@ -280,6 +345,21 @@ fn apply_registry_data(
}
}
/// What another player's pose draws from: gliding (Entity.isFallFlying, flag
/// 0x80 of the shared flags), the pose (swimming), Riptide's spin
/// (LivingEntity.isAutoSpinAttack, flag 4) and a loaded crossbow in the
/// hand (the held stack comes with SetEquipment).
fn player_poses(m: &mut Mob, values: &[(u8, i32, Value)]) {
for (index, _, value) in values {
match (*index, value) {
(0, Value::Byte(flags)) => m.extra.fall_flying = flags & -128 != 0,
(6, Value::Id(pose)) => m.extra.synced_pose = (*pose).clamp(0, 17) as u8,
(8, Value::Byte(flags)) => m.extra.auto_spin_attack = flags & 4 != 0,
_ => {}
}
}
}
/// The options of a synched particle list: an entity effect carries its ARGB
/// colour, the omens none.
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
@@ -332,9 +412,20 @@ impl Game<'_> {
let Some(r) = &self.remote else {
return;
};
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
// in its bed the way the bed faces, or gets up.
let bed = values.iter().find_map(|v| match v {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
&& m.species() == Species::Villager
{
m.extra.ints[1] = code;
}
}
}
}
@@ -0,0 +1,50 @@
//! Particles a client of a server draws for a mob's entity event, which the
//! server does not send as a particle packet.
use super::Game;
use horizon_client::{
mobs::Species,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
/// `Ravager.handleEntityEvent(69)`: forty puffs of smoke from the middle
/// of its box, then `applyRoarKnockbackClient`: this client's own player
/// within 4 blocks is thrown away from it.
pub(super) fn remote_ravager_roar(&mut self, id: i32) {
let Some(m) = self
.mobs
.iter()
.find(|m| m.id == id as u32 && m.species() == Species::Ravager)
else {
return;
};
let (center, at, area) = (
m.center(),
m.position,
m.bounds().inflate(Vec3::new(4.0, 4.0, 4.0)),
);
let options = ParticleOptions::simple(ParticleType::Poof);
for _ in 0..40 {
let r = self.particles.random();
let v = [
r.next_gaussian() * 0.2,
r.next_gaussian() * 0.2,
r.next_gaussian() * 0.2,
];
self.particles.add(&options, center, v);
}
if let Some(view) = self.player_view()
&& view.alive
&& view.bounds.intersects(area)
&& let Some(p) = self.player.as_mut()
{
// strongKnockback.
let (xd, zd) = (p.position.x - at.x, p.position.z - at.z);
let dd = (xd * xd + zd * zd).max(0.001);
p.velocity = p
.velocity
.add_vector(Vec3::new(xd / dd * 4.0, 0.2, zd / dd * 4.0));
}
}
}
@@ -61,6 +61,8 @@ impl Game<'_> {
) && !baby
}
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
// SnowGolem.mobInteract: shears take the pumpkin off.
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
// Bucketable.bucketMobPickup.
Species::Cod
| Species::Salmon
@@ -128,6 +128,17 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
// HappyGhast.removePassenger: a still timeout of 10 (the server's
// STAYS_STILL confirms it), and the rider lands on top of the ghast
// (`place_ghast_under`).
if let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == vehicle_id && m.species() == horizon_client::mobs::Species::HappyGhast
}) {
m.extra.counter = 10;
if let Some(r) = self.remote.as_mut() {
r.ghast_dismount = Some(vehicle_id);
}
}
}
}
+29 -11
View File
@@ -19,10 +19,29 @@ use horizon_core::random::{LegacyRandom, RandomSource};
/// The first motion-blocking block at or below `from` (Heightmap MOTION_BLOCKING), plus one.
fn motion_blocking(world: &World, facts: &BlockFacts, x: i32, z: i32, from: i32) -> i32 {
motion_blocking_leaves(world, facts, x, z, from, true)
}
/// `motion_blocking` where leaves count only for MOTION_BLOCKING, not for
/// MOTION_BLOCKING_NO_LEAVES.
fn motion_blocking_leaves(
world: &World,
facts: &BlockFacts,
x: i32,
z: i32,
from: i32,
leaves: bool,
) -> i32 {
let mut y = from.min(MIN_Y + HEIGHT - 1);
while y > MIN_Y {
let f = facts.get(world.block(x, y, z));
if f.full_collision || f.fluid != Fluid::None || f.name.contains("leaves") {
// #minecraft:leaves block motion, but MOTION_BLOCKING_NO_LEAVES skips them
// (their water still counts).
let blocks = if f.name.ends_with("_leaves") {
leaves
} else {
f.full_collision
};
if blocks || f.fluid != Fluid::None {
return y + 1;
}
y -= 1;
@@ -34,16 +53,7 @@ fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
let Some(chunk) = world.chunks.get(&(x >> 4, z >> 4)) else {
return false;
};
let b = biomes.get(chunk.biome(x, y, z));
if !b.precipitation {
return false;
}
let t = if y > 80 {
b.temperature - (y - 80) as f32 * 0.05 / 40.0
} else {
b.temperature
};
t >= 0.15
biomes.get(chunk.biome(x, y, z)).snows_at(x, y, z) == Some(false)
}
impl Game<'_> {
@@ -293,6 +303,11 @@ impl Game<'_> {
&& motion_blocking(world, facts, p[0], p[2], p[1] + 64) <= p[1]
&& rains_at(world, biomes, p[0], p[1], p[2])
};
let height_no_leaves =
|p: [i32; 3]| motion_blocking_leaves(world, facts, p[0], p[2], i32::MAX, false);
// The day timeline: firefly bush sounds from 12600 to 23400.
let firefly_bush_sounds = self.dim == Dim::Overworld
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
let gateway_particles = |state, p| {
@@ -310,6 +325,9 @@ impl Game<'_> {
biomes,
registry: self.pack.assets.registry,
raining_at: &raining_at,
height_no_leaves: &height_no_leaves,
firefly_bush_sounds,
sky_darken: self.sky_darken(),
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
model_offset: &model_offset,
gateway_particles: &gateway_particles,
+61
View File
@@ -3,6 +3,7 @@ use super::{Game, Screen};
use horizon_client::{
Result,
command::args::GameMode,
confirm_screen::Confirm,
hud::{HudBatch, rgba},
text::{self, Text},
ui,
@@ -103,6 +104,14 @@ fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
]
}
/// DeathScreen.init: a hardcore player can only spectate.
/// `DeathScreen.TitleConfirmScreen`: its title and the yes and no labels.
fn death_confirm_texts() -> (Text, [Text; 2]) {
let t = |key: &str| Text::translate(key, &[]);
(
t("deathScreen.quit.confirm"),
[t("deathScreen.titleScreen"), t("deathScreen.respawn")],
)
}
fn death_buttons(w: f32, h: f32, hardcore: bool) -> [(f32, f32, &'static str, u8); 2] {
let respawn = if hardcore {
"deathScreen.spectate"
@@ -267,6 +276,20 @@ impl Game<'_> {
),
);
}
// DeathScreen.handleExitToTitleScreen: the TitleConfirmScreen takes the
// screen over, on the same background.
if let Some(opened) = self.ui.death_confirm {
let (title, labels) = death_confirm_texts();
let size = self.gui_size();
Confirm::new(&atlas.font, size, &title, &Text::literal("")).draw_active(
batch,
atlas,
self.cursor,
[&labels[0], &labels[1]],
opened.elapsed().as_millis() >= 1000,
);
return;
}
let title = if self.level.hardcore {
"deathScreen.title.hardcore"
} else {
@@ -382,6 +405,23 @@ impl Game<'_> {
{
return Ok(());
}
if let Some(opened) = self.ui.death_confirm {
// ConfirmScreen.setDelay: both buttons wait 20 ticks.
if opened.elapsed().as_millis() < 1000 {
return Ok(());
}
let (title, _) = death_confirm_texts();
let confirm = Confirm::new(
&self.gui.atlas.font,
self.gui_size(),
&title,
&Text::literal(""),
);
if let Some(yes) = confirm.answer_at(self.cursor) {
self.death_confirm_answer(yes)?;
}
return Ok(());
}
let size = self
.renderer
.as_ref()
@@ -391,6 +431,27 @@ impl Game<'_> {
.into_iter()
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
.map(|d| d.3);
// handleExitToTitleScreen: outside hardcore, Title Screen asks first.
if hit == Some(1) && !self.level.hardcore {
self.ui.death_confirm = Some(std::time::Instant::now());
self.audio.play_button_click();
return Ok(());
}
self.death_action(hit)
}
/// The confirm screen's answer: yes leaves the world, no respawns
/// (`DeathScreen.handleExitToTitleScreen`'s callback).
pub(super) fn death_confirm_answer(&mut self, yes: bool) -> Result<()> {
self.ui.death_confirm = None;
if yes {
self.audio.play_button_click();
self.close_world()
} else {
self.death_action(Some(0))
}
}
/// A death screen button: 0 respawns (or spectates in hardcore), 1 leaves.
fn death_action(&mut self, hit: Option<u8>) -> Result<()> {
match hit {
Some(0) => self.respawn_player()?,
Some(1) => self.close_world()?,
+18 -22
View File
@@ -876,17 +876,26 @@ impl Game<'_> {
/// `SculkSensorBlock.stepOn` and `SculkShriekerBlock.stepOn`: standing on
/// them is heard (or seen) every tick.
fn sculk_step_on(&mut self) {
self.sculk_step_on_player();
let mut standing: Vec<(Who, [f64; 3])> = Vec::new();
if let Some(p) = self.player.as_ref()
&& p.ground.on_ground
{
standing.push((Who::Player, [p.position.x, p.position.y, p.position.z]));
}
for m in &self.mobs {
if m.alive() && m.on_ground {
standing.push((Who::Mob(m.id), [m.position.x, m.position.y, m.position.z]));
}
}
self.sculk_stand_on(standing);
}
/// `stepOn` for the player in place: the host, or a guest swapped in
/// (`guest_server_tick`), as a server has no player of its own.
pub(super) fn sculk_step_on_player(&mut self) {
if let Some(p) = self.player.as_ref()
&& p.ground.on_ground
{
let at = [p.position.x, p.position.y, p.position.z];
self.sculk_stand_on(vec![(Who::Player, at)]);
}
}
fn sculk_stand_on(&mut self, standing: Vec<(Who, [f64; 3])>) {
for (who, at) in standing {
// getOnPosLegacy: 0.2 below the feet.
let key = (
@@ -1108,23 +1117,10 @@ impl Game<'_> {
/// in the way (`isUnobstructed`), and no block collision.
fn warden_fits(&self, mob: &Mob) -> bool {
let bounds = mob.bounds();
let player_blocks = !self.spectator()
&& self.player.as_ref().is_some_and(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
let b = horizon_core::geometry::Aabb::new(
horizon_core::geometry::Vec3::new(
p.position.x - w,
p.position.y,
p.position.z - w,
),
horizon_core::geometry::Vec3::new(
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
),
);
!p.health.dead_or_dying() && b.intersects(bounds)
});
let player_blocks = self
.level_players()
.iter()
.any(|p| p.alive && !p.spectator && p.bounds.intersects(bounds));
if player_blocks
|| self
.mobs
+124
View File
@@ -0,0 +1,124 @@
//! Parrots on a player's shoulder: a tame parrot lands on its owner
//! (`LandOnOwnersShoulderGoal`), is kept as the player's `ShoulderEntityLeft`
//! or `ShoulderEntityRight`, and flies off again when the player is hurt,
//! falls, swims, flies or sleeps (`ServerPlayer.handleShoulderEntities`).
use super::Game;
use horizon_client::mobs::Species;
use horizon_world::nbt::Tag;
const KEYS: [&str; 2] = ["ShoulderEntityLeft", "ShoulderEntityRight"];
/// A shoulder slot holds a parrot when its compound is not empty.
fn occupied(extra: &Tag, key: &str) -> bool {
matches!(extra.get(key), Some(Tag::Compound(c)) if !c.is_empty())
}
impl Game<'_> {
/// `ServerPlayer.setEntityOnShoulder` for a parrot that touched its
/// owner: stored in the first free slot, the parrot gone from the world.
pub(super) fn parrot_to_shoulder(&mut self, mob: u32) {
let Some(i) = self
.mobs
.iter()
.position(|m| m.id == mob && m.alive() && !m.removed && m.species() == Species::Parrot)
else {
return;
};
let Some(p) = self.player.as_ref() else {
return;
};
// The acting player must be the saved owner (`getOwner`).
if !horizon_client::mobs::tame::owned_by(self.mobs[i].extra.owner, self.acting_uuid()) {
return;
}
if self.riding.is_some()
|| !p.ground.on_ground
|| p.fluids.was_touching_water
|| p.in_powder_snow
|| p.flying
{
return;
}
let extra = self
.level
.player_extra
.get_or_insert_with(|| Tag::Compound(Default::default()));
let Some(key) = KEYS.into_iter().find(|k| !occupied(extra, k)) else {
return;
};
let tag = self.mob_tree_nbt(i);
if let Some(Tag::Compound(map)) = self.level.player_extra.as_mut() {
map.insert(key.into(), tag);
}
let (uuid, now) = (self.acting_uuid(), self.world.game_time);
self.creatures.shoulder_time.insert(uuid, now);
self.mobs[i].removed = true;
}
/// `ServerPlayer.handleShoulderEntities`: the parrots leave the shoulder
/// of a player who falls, swims, flies, sleeps, is hurt, dying, a
/// spectator or spinning with a riptide.
pub(super) fn handle_shoulder_entities(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let any = self
.level
.player_extra
.as_ref()
.is_some_and(|e| KEYS.iter().any(|k| occupied(e, k)));
if any
&& (p.fall_distance > 0.5
|| p.fluids.was_touching_water
|| p.flying
|| p.spectator
|| p.in_powder_snow
|| p.auto_spin_attack_ticks > 0
|| p.health.hurt.hurt_time > 0
|| p.health.dead_or_dying()
|| self.sleeping.is_some())
{
self.remove_entities_on_shoulder();
}
}
/// `ServerPlayer.removeEntitiesOnShoulder` (not within a second of the
/// landing) and `respawnEntityOnShoulder`: the parrots come back at the
/// player, still owned by them.
fn remove_entities_on_shoulder(&mut self) {
let (uuid, now) = (self.acting_uuid(), self.world.game_time);
if self
.creatures
.shoulder_time
.get(&uuid)
.is_some_and(|t| t + 20 >= now)
{
return;
}
let Some(p) = self.player.as_ref().map(|p| p.position) else {
return;
};
let Some(Tag::Compound(map)) = self.level.player_extra.as_mut() else {
return;
};
let tags: Vec<Tag> = KEYS
.iter()
.filter_map(|k| map.remove(&(*k).into()))
.collect();
let pos = [p.x, p.y + f64::from(0.7_f32), p.z];
for mut tag in tags {
let Tag::Compound(c) = &mut tag else {
continue;
};
if c.is_empty() {
continue;
}
c.insert(
"Pos".into(),
Tag::List(pos.iter().map(|v| Tag::Double(v.to_bits())).collect()),
);
c.insert("Owner".into(), Tag::IntArray(uuid.to_vec()));
self.load_mob(tag, None);
}
}
}
+17 -13
View File
@@ -5,9 +5,8 @@ use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Mob, Species, sniffer},
particles::Particle,
};
use horizon_core::random::RandomSource;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
/// The egg item at the parent (`Sniffer.spawnChildFromBreeding`; the
@@ -46,15 +45,20 @@ impl Game<'_> {
/// block's hit sound between the 1.7th and the 6th second of the
/// digging animation) and `playSearchingSound`.
pub(super) fn tick_sniffer_effects(&mut self) {
let Some(camera) = self.player.as_ref().map(|p| p.position) else {
// Vanilla runs these on each client: a sniffer within 32 blocks of
// any player in the level (the host and the guests) shows them.
let players = self.level_players();
if players.is_empty() {
return;
};
}
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Sniffer
|| !m.alive()
|| m.silent
|| m.position.distance_squared(camera) > 32.0 * 32.0
|| !players
.iter()
.any(|p| m.position.distance_squared(p.position) <= 32.0 * 32.0)
{
continue;
}
@@ -79,20 +83,20 @@ impl Game<'_> {
}
let head = sniffer::head_block(m);
let below = self.world.block(head[0], head[1] - 1, head[2]);
let Some((sprite, tint)) = self.particle_sprite(below, [head[0], head[1] - 1, head[2]])
else {
if self
.particle_sprite(below, [head[0], head[1] - 1, head[2]])
.is_none()
{
continue;
};
}
let dust = [
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.5 - f64::from(0.65_f32),
f64::from(head[2]) + 0.5,
];
for _ in 0..30 {
let particle =
Particle::terrain(&mut self.world.random, dust, [0.0; 3], sprite.clone(), tint);
self.particles.push(particle);
}
// Drawn in this window when it has one, and sent to the players
// within 32 blocks (the server thread's window is one of them).
self.landing_dust(None, below, Vec3::new(dust[0], dust[1], dust[2]), 30, 0.0);
if self.mobs[i].age.is_multiple_of(10) {
let hit = self.res.facts().get(below).sounds.hit.clone();
self.audio.play(&hit, Some(at), 0.5, 0.5);
+17 -10
View File
@@ -21,10 +21,11 @@ type Pos = (i32, i32, i32);
/// What a spawner tick decided to add, created once the level is free.
pub(super) enum Planned {
/// `finalizeSpawn` ran on a fresh mob.
Finalized(Mob),
/// A mob loaded from the spawn data (with its passengers).
Loaded(Tag),
/// `finalizeSpawn` ran on a fresh mob; the spawn data's `equipment`.
Finalized(Mob, Option<Tag>),
/// A mob loaded from the spawn data (with its passengers) and its
/// `equipment`.
Loaded(Tag, Option<Tag>),
}
/// What the spawner code keeps between ticks.
@@ -120,18 +121,24 @@ impl Game<'_> {
for (i, plan) in planned.into_iter().enumerate() {
let provisional = u32::MAX - i as u32;
let id = match plan {
Planned::Finalized(mut mob) => {
Planned::Finalized(mut mob, equipment) => {
self.next_entity += 1;
mob.id = self.next_entity;
let id = mob.id;
self.mobs.push(mob);
self.enchant_spawn_equipment(id);
self.spawn_jockeys(id, true);
Some(id)
Some((id, equipment))
}
Planned::Loaded(tag, equipment) => {
self.load_mob(tag, None).map(|id| (id, equipment))
}
Planned::Loaded(tag) => self.load_mob(tag, None),
};
if let Some(id) = id {
if let Some((id, equipment)) = id {
// `nextSpawnData.getEquipment().ifPresent(mob::equip)`.
if let Some(table) = equipment {
self.equip_from_table(id, &table);
}
ids.insert(provisional, id);
}
}
@@ -169,7 +176,7 @@ impl Game<'_> {
mob.previous_head_yaw = 0.0;
}
let bounds = mob.bounds();
planned.push(Planned::Finalized(mob));
planned.push(Planned::Finalized(mob, r.equipment.cloned()));
bounds
} else {
// A UUID already in the level is refused.
@@ -197,7 +204,7 @@ impl Game<'_> {
let tag = Tag::Compound(tag);
let mob = horizon_client::entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), 0)?;
let bounds = mob.bounds();
planned.push(Planned::Loaded(tag));
planned.push(Planned::Loaded(tag, r.equipment.cloned()));
bounds
};
l.pending.borrow_mut().push(bounds);
+400
View File
@@ -0,0 +1,400 @@
//! Spears (`KineticWeapon`): holding the use button charges the spear, and
//! from `delay_ticks` on every living entity along the view that the
//! player's speed allows is stabbed: the damage grows with the speed
//! relative to the target, the knockback and the dismount need the
//! attacker's own speed, and each condition runs out after its time
//! (`Item.use`, `ItemStack.onUseTick`, `KineticWeapon.damageEntities`,
//! `Player.stabAttack`). The hit's feedback (`onKineticHit`) reaches the
//! players around as entity event 2. A left click jabs instead
//! (`PiercingWeapon.attack`), which Lunge turns into a dash.
use super::{Game, view_vector};
use horizon_client::{
attack_strength::delay,
collisions::SceneCollisions,
first_person::Hand,
items::ItemStack,
mobs::Damage,
spear::{HIT_FEEDBACK_TICKS, Kinetic, REACH},
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
position::BlockPos,
};
/// The key of this window's own player in `Game::kinetic_feedback`.
pub(super) const OWN: i32 = i32::MIN;
impl Game<'_> {
/// `Item.use` of a kinetic weapon: `startUsingItem` with a new
/// `recentKineticEnemies`, and `KineticWeapon.makeSound` at the user.
pub(super) fn use_spear(&mut self, stack: &ItemStack) -> bool {
let Some(kinetic) = Kinetic::of(&stack.item) else {
return false;
};
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
let actor = self.lan_acting;
self.stabbed.retain(|(who, _), _| *who != actor);
if let Some(p) = &self.player {
let at = [p.position.x, p.position.y, p.position.z];
self.audio.play(kinetic.sound, Some(at), 1.0, 1.0);
}
}
true
}
/// `ServerPlayer.getKnownMovement` of the acting player: what they moved
/// in the last tick. A guest's comes with their moves.
fn known_player_movement(&self) -> Vec3 {
if self.lan_acting.is_some() {
return self.known_movement;
}
self.player
.as_ref()
.map_or(Vec3::ZERO, |p| p.position.subtract(self.previous_position))
}
/// `Entity.getKnownSpeed` of the mob at `index`, per second: its root
/// vehicle's (`KineticWeapon.getMotion`).
fn mob_speed(&self, index: usize) -> Vec3 {
let mut mob = &self.mobs[index];
while let Some(vehicle) = mob
.extra
.mount
.and_then(|v| self.mobs.iter().find(|m| m.id == v))
{
mob = vehicle;
}
mob.position.subtract(mob.previous).scale(20.0)
}
/// `Level.clip` with COLLIDER shapes and no fluids: where the segment
/// first meets a block, if it does (tall grass lets a stab through).
fn clip_collider(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(from, to, 512, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p).map(|hit| hit.location)
})
.ok()
.flatten()
}
/// `ProjectileUtil.getHitEntitiesAlong` for the mobs: those whose box the
/// segment `from`..`to` meets, or comes within the margin of with no
/// block between.
fn mobs_along(&self, from: Vec3, to: Vec3, margin: f64) -> Vec<usize> {
let grow = Vec3::new(margin, margin, margin);
let mut hits = Vec::new();
for (i, m) in self.mobs.iter().enumerate() {
// PiercingWeapon.canHitEntity: alive, and not the vehicle ridden.
if !m.alive() || m.removed || self.riding == Some(m.id) {
continue;
}
let bounds: Aabb = m.bounds();
let hit = bounds.contains(from)
|| bounds.clip(from, to).is_some()
|| (margin > 0.0
&& bounds.inflate(grow).clip(from, to).is_some_and(|outside| {
let centre = bounds.center();
let toward = self.clip_collider(outside, centre).unwrap_or(centre);
bounds.clip(outside, toward).is_some()
}));
if hit {
hits.push(i);
}
}
hits
}
/// `ProjectileUtil.getHitEntitiesAlong` with the spear's `AttackRange`: the
/// mobs along the view from the minimum reach to the maximum plus the
/// attacker's own movement, none when a block is closer than the minimum.
fn stab_targets(&self, eye: Vec3, look: Vec3, movement: Vec3) -> Vec<usize> {
// AttackRange.effectiveMinRange and effectiveMaxRange for a player.
let (min, max) = if self.creative {
(REACH.min_creative, REACH.max_creative)
} else {
(REACH.min, REACH.max)
};
let from = eye.add_vector(look.scale(f64::from(min)));
let mut to = eye.add_vector(look.scale(f64::from(max) + movement.dot(look).max(0.0)));
// A block closer than the minimum reach takes the whole stab.
if let Some(block) = self.clip_collider(eye, to) {
to = block;
if eye.distance_squared(to) < eye.distance_squared(from) {
return Vec::new();
}
}
self.mobs_along(from, to, f64::from(REACH.hitbox_margin))
}
/// `KineticWeapon.damageEntities`: one tick of the spear in use, `ticks`
/// ticks into the use.
pub(super) fn spear_tick(&mut self, stack: &ItemStack, ticks: u32) {
let Some(kinetic) = Kinetic::of(&stack.item) else {
return;
};
let used = ticks as i32 - kinetic.delay_ticks;
let (Some(eye), Some(feet)) = (
self.eye_position(),
self.player.as_ref().map(|p| p.position),
) else {
return;
};
if used < 0 || self.spectator() {
return;
}
let eye = Vec3::new(eye[0], eye[1], eye[2]);
let look = view_vector(self.yaw, self.pitch);
let look = Vec3::new(look[0], look[1], look[2]);
let movement = self.known_player_movement();
let attacker_speed = look.dot(movement.scale(20.0));
let now = self.world.game_time;
let actor = self.lan_acting;
let mut affected = false;
for i in self.stab_targets(eye, look, movement) {
let key = (actor, self.mobs[i].id);
let recent = self
.stabbed
.get(&key)
.is_some_and(|&t| now - t < i64::from(kinetic.contact_cooldown_ticks));
if recent {
continue;
}
self.stabbed.insert(key, now);
let target_speed = look.dot(self.mob_speed(i));
let relative = (attacker_speed - target_speed).max(0.0);
// An entity factor of 1 for a player.
let dismounts = kinetic.dismount.test(used, attacker_speed, relative, 1.0);
let pushes = kinetic.knockback.test(used, attacker_speed, relative, 1.0);
let damages = kinetic.damage.test(used, attacker_speed, relative, 1.0);
if dismounts || pushes || damages {
// The attack damage attribute's base value (1) and the speed.
let amount = 1.0 + (relative * f64::from(kinetic.damage_multiplier)).floor() as f32;
affected |= self.stab_mob(i, amount, damages, pushes, dismounts);
}
}
if affected {
// Level.broadcastEntityEvent(player, 2), felt by the player too.
if self.audio.capture.is_some() {
self.lan_kinetic_hits.push(actor);
}
let who = actor.map_or(OWN, |conn| conn as i32);
self.kinetic_hit_feedback(who, &kinetic, [feet.x, feet.y, feet.z]);
}
}
/// `Player.stabAttack` on the mob at `index`: whether it was affected.
fn stab_mob(
&mut self,
index: usize,
base_damage: f32,
deals_damage: bool,
deals_knockback: bool,
dismounts: bool,
) -> bool {
let kind = format!(
"minecraft:{}",
self.mobs[index].kind.id.trim_start_matches("minecraft:")
);
let id = self.mobs[index].id;
let Some(p) = self.player.as_ref() else {
return false;
};
let from = [p.position.x, p.position.y, p.position.z];
// The enchantments' share, scaled by the attack strength unless the
// weapon is in use.
let magic_boost = self.enchant_damage_bonus(&kind);
let (base_damage, magic_boost) = if self.using.is_none() {
let scale = self.attack_strength_scale();
(
base_damage * (0.2 + scale * scale * 0.8),
magic_boost * scale,
)
} else {
(base_damage, magic_boost)
};
let total = if deals_damage {
base_damage + magic_boost
} else {
0.0
};
let old_health = self.mobs[index].health;
let hurt = deals_damage && self.hurt_by_player(index, total, Damage::spear(from));
// causeExtraKnockback: a push of 0.4, then the weapon's Knockback.
if deals_knockback {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback(0.4, xd, zd);
let (strength, _) = self.extra_knockback(&kind, 0.0);
self.mobs[index].knockback(f64::from(strength), xd, zd);
}
let dismounted = dismounts && self.mobs[index].extra.mount.is_some();
if dismounted {
self.dismount_mob(id);
}
if !hurt && !deals_knockback && !dismounted {
return false;
}
let mob = &self.mobs[index];
let (feet, width, height) = (
[mob.position.x, mob.position.y, mob.position.z],
mob.kind.width,
mob.kind.height,
);
// attackVisualEffects: no attack sound, the enchantments' magic crit.
if magic_boost > 0.0 {
self.critical_hit(self.lan_acting, id as i32, feet, width, height, true);
}
self.combat.last_hurt_mob = Some((id, self.world.game_time));
// itemAttackInteraction: hurtEnemy (statistic, wear), and the
// post-attack enchantments when the target was hurt.
if let Some(stack) = self.held().cloned() {
self.stats.add("used", &stack.item, 1);
if hurt {
self.post_attack(index);
}
self.weapon_wear(&stack);
}
// damageStatsAndHearts.
let actual = old_health - self.mobs[index].health;
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
if actual > 2.0 {
let indicator = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DamageIndicator,
);
self.particles.send_particles(
&indicator,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
);
}
if hurt {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
true
}
/// `Player.cannotAttackWithItem` for the held item: a spear needs its
/// `minimum_attack_charge` of 1, counting `tolerance` ticks more than the
/// `ticker` ticks charged.
pub(super) fn cannot_attack_with_item(&self, ticker: i32, tolerance: i32) -> bool {
self.held().is_some_and(|s| Kinetic::of(&s.item).is_some())
&& (ticker + tolerance) as f32 / delay(self.main_hand_attack_speed()) < 1.0
}
/// `PiercingWeapon.attack` of the spear in the main hand, for a left
/// click: every mob along the view is stabbed, then `postPiercingAttack`
/// (Lunge), the hit sound and the swing. Gives the sound of the jab
/// (`makeSound`), which everyone but the user hears from the server.
pub(super) fn jab(&mut self) -> Option<&'static str> {
let kinetic = self.held().and_then(|s| Kinetic::of(&s.item))?;
let eye = self.eye_position()?;
let eye = Vec3::new(eye[0], eye[1], eye[2]);
let look = view_vector(self.yaw, self.pitch);
let look = Vec3::new(look[0], look[1], look[2]);
// The attack damage attribute.
let damage = 1.0
+ self.attack_item_info().map_or(0.0, |i| i.attack_damage)
+ self.effect_attack_bonus();
let movement = self.known_player_movement();
let mut hit = false;
for i in self.stab_targets(eye, look, movement) {
hit |= self.stab_mob(i, damage, true, true, false);
}
// Player.onAttack, then postPiercingAttack.
self.attack_ticks = 0;
self.post_piercing_attack();
if hit {
self.play_at_feet(kinetic.hit_sound);
}
// swingAndResetAttackStrength (ServerPlayer): a swing that starts
// resets the attack strength.
let swings = self.swings_started;
self.swing_hand(Hand::Main, kinetic.attack_animation());
if self.swings_started != swings {
self.reset_attack_strength();
}
Some(kinetic.attack_sound)
}
/// A sound at the player's feet.
pub(super) fn play_at_feet(&mut self, sound: &str) {
if let Some(p) = &self.player {
let at = [p.position.x, p.position.y, p.position.z];
self.audio.play(sound, Some(at), 1.0, 1.0);
}
}
/// `LivingEntity.onKineticHit` for `entity`: the hit feels (the spear
/// moves back, `kinetic_hit_ticks`) and sounds, at most once every ten
/// ticks.
pub(super) fn kinetic_hit_feedback(&mut self, entity: i32, kinetic: &Kinetic, at: [f64; 3]) {
let now = self.world.game_time;
if self
.kinetic_feedback
.get(&entity)
.is_none_or(|&t| now - t > HIT_FEEDBACK_TICKS)
{
self.kinetic_feedback.insert(entity, now);
self.audio
.play_unshared(kinetic.hit_sound, Some(at), 1.0, 1.0);
}
}
/// `getTicksSinceLastKineticHitFeedback` of this window's player.
pub(super) fn kinetic_hit_ticks(&self, partial: f32) -> f32 {
self.kinetic_feedback
.get(&OWN)
.map_or(0.0, |&t| (self.world.game_time - t) as f32 + partial)
}
/// `LivingEntity.handleEntityEvent(2)` on a server's client: the
/// player `id` (this one, or another) was hit by the spear it uses.
pub(super) fn remote_kinetic_hit(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let (item, at) = if own {
let item = self
.using
.and_then(|(slot, _)| self.stack_in(slot))
.map(|s| s.item.clone());
let at = self.player.as_ref().map(|p| p.position);
(item, at)
} else {
let mob = self
.mobs
.iter()
.find(|m| m.id == id as u32 && m.extra.using_item > 0);
let item = mob.and_then(|m| {
if m.extra.using_off_hand {
m.extra.off_hand.clone()
} else {
m.extra.main_hand.clone()
}
});
(item, mob.map(|m| m.position))
};
if let (Some(kinetic), Some(at)) = (item.as_deref().and_then(Kinetic::of), at) {
self.kinetic_hit_feedback(if own { OWN } else { id }, &kinetic, [at.x, at.y, at.z]);
}
}
}
+112
View File
@@ -0,0 +1,112 @@
//! The spectator menu (`SpectatorGui`): its players and teams from the
//! server's player list and teams, and the teleport request.
use super::Game;
use horizon_client::{
hud::HudBatch,
hud_layout, keybinds,
scoreboard::{comp_text, team_color_rgb},
spectator_menu::{Member, Outcome, SpectatorMenu, Team},
tab_list::{SPECTATOR, TabPlayer},
text::Text,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
impl Game<'_> {
/// `Util.getMillis` for the menu's fade.
fn spectator_now(&self) -> i64 {
self.started.elapsed().as_millis() as i64
}
/// `new SpectatorMenu`: the listed players, and the teams with a player
/// to go to (`TeleportToTeamMenuCategory.createTeamEntries`).
fn new_spectator_menu(&mut self) -> SpectatorMenu {
let tab: Vec<TabPlayer> = self
.remote_tab_players()
.map_or_else(Vec::new, |(players, _, _)| players);
let member = |p: &TabPlayer| Member {
uuid: p.uuid,
name: p.name.clone(),
skin: p.skin.clone(),
enabled: p.game_mode != SPECTATOR,
};
let players: Vec<Member> = tab.iter().map(member).collect();
let teams = self
.score
.board
.teams
.iter()
.filter_map(|team| {
let members: Vec<Member> = team
.players
.iter()
.filter_map(|name| tab.iter().find(|p| &p.name == name))
.filter(|p| p.game_mode != SPECTATOR)
.map(member)
.collect();
if members.is_empty() {
return None;
}
// A random member's face is the team's icon.
let pick = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos() as usize)
% members.len();
Some(Team {
name: comp_text(&team.display_name),
color: team.color.as_deref().and_then(team_color_rgb),
icon: members[pick].skin.clone(),
players: members,
})
})
.collect();
SpectatorMenu::new(players, teams)
}
fn spectator_outcome(&mut self, outcome: Outcome) {
if let Outcome::Teleport(uuid) = outcome {
// PlayerMenuItem.selectItem.
self.send(S::TeleportToEntity(play::TeleportToEntity { uuid }));
}
}
/// A hotbar key as a spectator (`SpectatorGui.onHotbarSelected`).
pub(super) fn spectator_hotbar_key(&mut self, slot: i32) {
let now = self.spectator_now();
let menu = self.spectator_menu_if_needed();
let outcome = self.spectator_gui.on_hotbar_selected(slot, now, menu);
self.spectator_outcome(outcome);
}
/// `key.spectatorHotbar`: opens the menu, or uses the selected item.
pub(super) fn spectator_action_key(&mut self) {
let now = self.spectator_now();
let menu = self.spectator_menu_if_needed();
let outcome = self.spectator_gui.on_action_key(now, menu);
self.spectator_outcome(outcome);
}
/// The menu a press would open, built only when none is up.
fn spectator_menu_if_needed(&mut self) -> Option<SpectatorMenu> {
(!self.spectator_gui.is_menu_active()).then(|| self.new_spectator_menu())
}
/// `MouseHandler.onScroll` in spectator mode with the menu up.
pub(super) fn spectator_scroll(&mut self, wheel: i32) {
let now = self.spectator_now();
self.spectator_gui.on_mouse_scrolled(wheel, now);
}
/// `Hud.extractHotbar` for a spectator, and `extractAction`'s prompt.
pub(super) fn draw_spectator_hotbar(&mut self, batch: &mut HudBatch) {
let (w, h) = (batch.width as i32, batch.height as i32);
let now = self.spectator_now();
let labels: Vec<Text> = (1..=9)
.map(|n| keybinds::display_name(self.settings.keys.get(&format!("key.hotbar.{n}"))))
.collect();
let font = &self.gui.atlas.font;
let mut draws = self
.spectator_gui
.hotbar(w, h, now, &|slot| labels[slot].clone(), font);
draws.extend(self.spectator_gui.action(
w,
h,
now,
font,
self.settings.text_background(0.0),
));
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
}
@@ -163,7 +163,7 @@ impl Game<'_> {
let mut spawn = |l: &GameLevel, r: &SpawnerSpawn| {
let added = l.game.plan_spawner_mob(l, r, &mut planned)?;
let uuid = match planned.last_mut() {
Some(Planned::Loaded(Tag::Compound(tag))) => {
Some(Planned::Loaded(Tag::Compound(tag), _)) => {
let tag_uuid =
horizon_client::entity_nbt::uuid(&Tag::Compound(tag.clone()));
tag_uuid.unwrap_or_else(|| {
+14 -3
View File
@@ -306,7 +306,12 @@ impl Game<'_> {
if !self.in_water_or_rain() || self.riding.is_some() {
return;
}
self.hurt_held_without_breaking(1);
// `hurtWithoutBreaking` and the sound belong to the server; a server's
// client only launches.
let server_client = self.remote.is_some();
if !server_client {
self.hurt_held_without_breaking(1);
}
// trident_spin_attack_strength: 1.5 + 0.75 per level above the first.
let strength = 1.5 + 0.75 * (riptide as f32 - 1.0);
let d = view_vector(self.yaw, self.pitch);
@@ -339,8 +344,14 @@ impl Game<'_> {
p.position.y += moved.y;
}
}
let sound = format!("minecraft:item.trident.riptide_{}", riptide.min(3));
self.audio.play(&sound, at, 1.0, 1.0);
if !server_client {
let sound = format!("minecraft:item.trident.riptide_{}", riptide.min(3));
self.audio.play(&sound, at, 1.0, 1.0);
}
return;
}
// The server throws the trident.
if self.remote.is_some() {
return;
}
// hurtWithoutBreaking (Unbreaking applies), then the thrown copy.
+2 -1
View File
@@ -71,7 +71,8 @@ impl Game<'_> {
/// After a wither skeleton skull is placed: the T of soul sand (or soul
/// soil) under three skulls, upright along X or Z, becomes a wither.
pub(super) fn check_wither_spawn(&mut self, placed: [i32; 3]) -> horizon_client::Result<()> {
if !self.is_wither_skull(placed) {
// Nothing is built in Peaceful (`level.getDifficulty() != PEACEFUL`).
if self.ui.difficulty == 0 || !self.is_wither_skull(placed) {
return Ok(());
}
// Pattern rows top to bottom: "^^^", "###", "~#~" ('~' air).
+15
View File
@@ -490,6 +490,11 @@ pub struct HealthHud {
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
/// asked for this frame; the caller plays it and clears it.
pub bubble_pop_sound: Option<(f32, f32)>,
}
pub struct HealthInputs {
@@ -761,12 +766,22 @@ impl HealthHud {
let popping = bubble(0);
let delay = i32::from(air != 0 && i.under_water);
let empty = 10 - bubble(delay);
if !i.under_water {
self.last_bubble_pop_sound = 0;
}
for b in 1..=10 {
let x = x_right - (b - 1) * 8 - 9;
if b <= full {
out.push(sprite("hud/air", x, y, 9, 9));
} else if full != popping && b == popping && i.under_water {
out.push(sprite("hud/air_bursting", x, y, 9, 9));
// Hud.playAirBubblePoppedSound.
if self.last_bubble_pop_sound != b {
let volume = 0.5 + 0.1 * (empty - 3 + 1).max(0) as f32;
let pitch = 1.0 + 0.1 * (empty - 5 + 1).max(0) as f32;
self.bubble_pop_sound = Some((volume, pitch));
self.last_bubble_pop_sound = b;
}
} else if b > 10 - empty {
let wobble = if empty == 10 && i.tick % 2 == 0 {
next(2)
@@ -688,4 +688,51 @@ mod tests {
);
}
}
/// `DecoratedPotRenderer` draws every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces: the pot's flat `top` and `bottom`
/// planes would otherwise show both sides and z-fight in the hand too.
#[test]
fn the_decorated_pot_item_culls_its_back_faces() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-models.json").exists() {
return;
}
let renderer = ItemRenderer::load(&root).unwrap();
let sprite = Rect {
u0: 0.25,
v0: 0.125,
u1: 0.375,
v1: 0.25,
w: 16,
h: 16,
};
let sprites: HashMap<String, Rect> = [
"entity/decorated_pot/decorated_pot_base",
"entity/decorated_pot/decorated_pot_side",
]
.into_iter()
.map(|name| (name.to_owned(), sprite))
.collect();
let model = SpecialModel {
kind: SpecialKind::DecoratedPot,
};
let mut quads: Vec<ItemQuad> = Vec::new();
submit(
&renderer,
&model,
&Value::Null,
false,
Mat4::IDENTITY,
&sprites,
&super::super::NoHost,
&mut quads,
);
assert!(!quads.is_empty(), "the pot item draws its parts");
assert!(
quads.iter().all(|q| q.cull_back),
"entitySolid culls the pot's back faces"
);
}
}
+1 -1
View File
@@ -215,7 +215,7 @@ pub struct Guest {
/// last sent about itself.
pub sent_shared: i8,
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
pub last_death: Option<[i32; 3]>,
pub last_death: Option<(String, [i32; 3])>,
/// The last action sequence to acknowledge at the next tick (-1: none).
pub ack_up_to: std::cell::Cell<i32>,
pub sent_selected: usize,
+26 -15
View File
@@ -199,6 +199,9 @@ pub struct Hub {
pub ops: std::collections::HashMap<u128, u8>,
/// `entity-broadcast-range-percentage` (dedicated servers).
pub entity_range_percent: i32,
/// `spawn-protection` (dedicated servers): the radius around the world
/// spawn that only operators may change.
pub spawn_protection: i32,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -369,6 +372,7 @@ impl Hub {
stepping: 0,
ops: Default::default(),
entity_range_percent: 100,
spawn_protection: 0,
weather: (false, 0.0, 0.0),
level_sync: Default::default(),
chat_type,
@@ -489,6 +493,13 @@ impl Hub {
overlay: false,
}));
}
/// `ServerPlayer.sendOverlayMessage` to every guest (the action bar).
pub fn overlay_message(&self, content: Tag) {
self.broadcast(&C::SystemChat(play::SystemChat {
content,
overlay: true,
}));
}
/// A chat message from the host (a disguised chat: the host has no
/// connection to sign with).
pub fn chat(&self, sender: &str, text: &str) {
@@ -606,16 +617,24 @@ impl Hub {
// ---- joining -------------------------------------------------------
fn spawn_info(&self, game_mode: i32) -> play::SpawnInfo {
fn spawn_info(&self, i: usize) -> play::SpawnInfo {
let g = &self.guests[i];
play::SpawnInfo {
dimension_type: self.dimension_type,
dimension: self.config.dimension.clone(),
seed: horizon_core::biome::obfuscate_seed(self.config.seed),
game_type: game_mode,
game_type: g.game_mode,
previous_game_type: None,
debug: false,
flat: self.flat_world,
last_death: None,
// Player.getLastDeathLocation.
last_death: g
.last_death
.as_ref()
.map(|(dimension, pos)| play::GlobalPos {
dimension: dimension.clone(),
pos: *pos,
}),
portal_cooldown: 0,
sea_level: 63,
}
@@ -684,7 +703,7 @@ impl Hub {
reduced_debug_info: false,
show_death_screen: !self.immediate_respawn,
limited_crafting: false,
spawn: self.spawn_info(mode),
spawn: self.spawn_info(i),
// MinecraftServer.usesAuthentication, enforceSecureProfile.
online_mode: self.lan.shared.config.online.is_some(),
enforces_secure_chat: self.enforces_secure_chat(),
@@ -2520,14 +2539,7 @@ impl Hub {
/// level loading screen from the Respawn packet until the level info's "level chunks load
/// start" event and the chunk under the player, so the packets follow vanilla's order.
pub fn respawn(&mut self, i: usize, at: [f64; 3], ctx: &TickCtx) {
let mode = self.guests[i].game_mode;
let mut info = self.spawn_info(mode);
// Player.getLastDeathLocation, set where the player died.
// The level they died in: theirs until now.
info.last_death = self.guests[i].last_death.map(|pos| play::GlobalPos {
dimension: self.guests[i].dimension.clone(),
pos,
});
let info = self.spawn_info(i);
self.close_container_for(i);
let g = &mut self.guests[i];
// PlayerList.respawn: into the level in place (the respawn level).
@@ -2640,8 +2652,7 @@ impl Hub {
portal_sound: bool,
ctx: &TickCtx,
) {
let mode = self.guests[i].game_mode;
let info = self.spawn_info(mode);
let info = self.spawn_info(i);
self.close_container_for(i);
let g = &mut self.guests[i];
g.dimension = self.config.dimension.clone();
@@ -2738,7 +2749,7 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
}
/// What the others see of a guest's body, from its server-side state.
fn guest_body(g: &Guest) -> BodyFlags {
pub fn guest_body(g: &Guest) -> BodyFlags {
let m = g.motion.as_deref();
BodyFlags {
gliding: g.fall_flying,
@@ -86,6 +86,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
guest.seen_credits = level.seen_credits;
guest.last_death = level.last_death.clone();
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
@@ -168,6 +169,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.seen_credits = guest.seen_credits;
level.last_death = guest.last_death.clone();
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
+9 -7
View File
@@ -22,12 +22,10 @@ pub mod block_entity_anim;
pub mod block_entity_item;
pub mod block_entity_render;
pub mod block_transform;
pub mod blocks;
pub mod border_render;
pub mod boss_overlay;
pub mod breaking;
pub mod brewing;
pub mod bundle;
pub mod camera;
pub mod cauldron;
pub mod chat;
@@ -43,7 +41,6 @@ pub mod confirm_screen;
pub mod container_screen;
pub mod containers;
pub mod control;
pub mod cooldowns;
pub mod crafting;
pub mod crash;
pub mod creative;
@@ -58,7 +55,6 @@ pub mod dialog_screen;
pub mod discord_presence;
pub mod dynamic_light;
pub mod effects;
pub mod enchant;
pub mod end_crystal;
pub mod entities;
pub mod entity_culling;
@@ -94,11 +90,9 @@ pub mod input;
pub mod interaction_input;
pub mod inv_sort;
pub mod inventory_effects;
pub mod item_data;
pub mod item_render;
pub mod item_sync;
pub mod item_tooltip;
pub mod items;
pub mod java_text;
pub mod key_names;
pub mod keybinds;
@@ -148,7 +142,6 @@ pub mod pose;
pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_sequences;
pub mod random_tick_sections;
pub mod recipe_book;
pub mod recipe_book_screen;
@@ -169,6 +162,8 @@ pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
pub mod sprites;
pub mod stations;
@@ -209,3 +204,10 @@ pub use horizon_client_core::scoreboard;
pub use horizon_client_core::snbt;
pub use horizon_client_core::text;
pub use horizon_command as command;
pub use horizon_items::blocks;
pub use horizon_items::bundle;
pub use horizon_items::cooldowns;
pub use horizon_items::enchant;
pub use horizon_items::item_data;
pub use horizon_items::items;
pub use horizon_items::random_sequences;
+4 -3
View File
@@ -61,7 +61,7 @@ pub struct TerrainVertex {
/// UV within the sprite, `UV_STEPS` per unit (`TerrainVertex::new`,
/// `terrain.wgsl`'s `terrain_uv`).
pub(crate) uv: [u16; 2],
/// Sprite array layer, with `OPAQUE_LAYER` for opaque leaves.
/// Sprite array layer, with `OPAQUE_LAYER` for opaque leaves and quads over opaque texels.
pub layer: u16,
/// Smooth light, 16x fixed point: sky in the low byte, block in the high one.
pub light: u16,
@@ -896,8 +896,9 @@ impl Mesher<'_> {
target.push(TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
q.uv[i],
// Opaque leaves (forceOpaque) draw in the solid layer: no alpha cutout.
if !self.cutout_leaves && state.leaves {
// Opaque leaves (forceOpaque) and quads over opaque texels draw in the
// solid layer: no alpha cutout (a dirt path's side skips its clear top row).
if face.opaque && !face.translucent || !self.cutout_leaves && state.leaves {
face.layer as u16 | OPAQUE_LAYER
} else {
face.layer as u16
+155 -13
View File
@@ -53,6 +53,7 @@ pub mod nautilus;
pub mod nav;
pub mod nether;
pub mod panda;
pub mod parrot;
pub mod path;
pub mod phantom;
pub mod piglin;
@@ -503,17 +504,14 @@ impl MobTypes {
Ok(Self { types: out })
}
}
/// Who caused damage or is being targeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Who {
Player,
Mob(u32),
}
pub use horizon_client_core::who::Who;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DamageKind {
PlayerAttack,
/// `mace_smash`: a mace hit after a fall (the player attack tags too).
MaceSmash,
/// `spear`: a stab of a spear (the player attack tags too).
Spear,
MobAttack,
Arrow,
Thrown,
@@ -578,6 +576,14 @@ impl Damage {
from: Some(from),
}
}
/// `DamageSources.spear`: a player's stab with a spear.
pub fn spear(from: [f64; 3]) -> Self {
Self {
kind: DamageKind::Spear,
attacker: Some(Who::Player),
from: Some(from),
}
}
pub fn mob(id: u32, from: [f64; 3]) -> Self {
Self {
kind: DamageKind::MobAttack,
@@ -656,6 +662,8 @@ pub struct Attrs {
pub spawn_reinforcements: f64,
pub jump_strength: f64,
}
/// `minecraft:wolf_armor`'s maximum damage.
pub const WOLF_ARMOR_DURABILITY: i32 = 64;
/// Species-specific persistent state (variants, colours, taming, ageing...).
#[derive(Clone, Debug, Default)]
pub struct Extra {
@@ -689,6 +697,12 @@ pub struct Extra {
/// `Mob.canPickUpLoot` (also lets dispensers equip it).
pub pick_up_loot: bool,
pub saddled: bool,
/// The durability a wolf's body armor (`extra.head`) has lost.
pub body_damage: i32,
/// The components (dye, name...) of that armor stack.
pub body_extra: Option<Box<crate::item_data::StackData>>,
/// `Cat.RELAX_STATE_ONE`: watching a sleeping owner before lying down.
pub relax_one: bool,
/// The saddle's components (name, enchantments...) when it is not a bare
/// one; `saddled` stays the gameplay flag.
pub saddle_stack: Option<crate::items::ItemStack>,
@@ -699,6 +713,8 @@ pub struct Extra {
pub inventory: Vec<crate::items::ItemStack>,
pub tame: bool,
pub sitting: bool,
/// `Parrot.partyParrot` and `jukebox`: the playing jukebox it dances to.
pub party_jukebox: Option<[i32; 3]>,
/// The owner's UUID (tamed animals).
pub owner: Option<[i32; 4]>,
/// Slime/magma cube size (0 = not sized).
@@ -709,7 +725,6 @@ pub struct Extra {
pub anger: i32,
pub carried: Option<horizon_world::registry::BlockStateId>,
pub charged: bool,
pub levitation: i32,
/// Held item ids (main hand, off hand) and head item.
pub main_hand: Option<Arc<str>>,
pub off_hand: Option<Arc<str>>,
@@ -723,6 +738,8 @@ pub struct Extra {
pub blocking: bool,
pub fall_flying: bool,
pub fall_fly_ticks: i32,
/// `Entity.DATA_POSE` of another player, as the server sent it.
pub synced_pose: u8,
/// `LivingEntity.isAutoSpinAttack` (Riptide).
pub auto_spin_attack: bool,
pub main_hand_left: bool,
@@ -943,12 +960,23 @@ pub enum MobEvent {
mob: u32,
at: [f64; 3],
},
/// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`.
/// `Level.broadcastEntityEvent` for a mob: guests run its
/// `handleEntityEvent`.
EntityEvent {
mob: u32,
event: i8,
at: [f64; 3],
},
/// `ServerLevel.sendParticles`: drawn here and sent to the players within
/// 32 blocks, which spread and speed the way the client does for a count
/// (a count of 0 is one particle moving by speed times spread).
LevelParticles {
kind: &'static str,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
},
Sound(&'static str, [f64; 3], f32, f32),
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
/// chest's opener count goes up or down (interaction range 3).
@@ -962,6 +990,11 @@ pub enum MobEvent {
mob: u32,
hive: [i32; 3],
},
/// `LandOnOwnersShoulderGoal`: a tame parrot touched its owner and sits
/// on their shoulder (the host stores its tag and discards it).
ParrotToShoulder {
mob: u32,
},
/// `CopperGolem.turnToStatue`.
BecomeStatue {
mob: u32,
@@ -1043,6 +1076,15 @@ pub enum MobEvent {
target: Who,
velocity: [f64; 3],
},
/// `SonicBoom`: 10 damage to the target, then a push along `direction`
/// (the unit vector from the warden's chest to the target's eyes) only
/// if the hit landed, less the target's knockback resistance.
SonicBoom {
target: Who,
attacker: u32,
from: [f64; 3],
direction: [f64; 3],
},
/// `Entity.move(MoverType.SHULKER, delta)`: shift a target's position
/// (an opening shulker lifts what stands on its shell).
Shift {
@@ -1111,6 +1153,11 @@ pub enum MobEvent {
count: u32,
at: [f64; 3],
},
/// `Mob.dropFromGiftLootTable`: a gift loot table's items dropped at `at`.
DropGift {
table: &'static str,
at: [f64; 3],
},
SetBlock {
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
@@ -1139,6 +1186,14 @@ pub enum MobEvent {
pos: [i32; 3],
data: i32,
},
/// `InfestedBlock.infestedStateByHost` (`infested`) or `hostStateByInfested`
/// for the block there, keeping its axis; `poof` is the silverfish that
/// hid in it (`spawnAnim`).
Infest {
pos: [i32; 3],
infested: bool,
poof: Option<u32>,
},
/// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock).
BreakBlock {
pos: [i32; 3],
@@ -1429,6 +1484,10 @@ pub struct Seen {
pub stats: [f64; 3],
/// `Wolf.isInSittingPose`.
pub sitting: bool,
/// `Entity.isSilent`.
pub silent: bool,
/// `Cat.isLying` or `isRelaxStateOne`.
pub lying: bool,
/// `Entity.isVehicle` or `isPassenger`.
pub ridden: bool,
/// `Attributes.KNOCKBACK_RESISTANCE`.
@@ -1523,6 +1582,8 @@ impl Mob {
m.attrs.speed,
],
sitting: m.extra.sitting,
silent: m.silent,
lying: m.species() == Species::Cat && (m.extra.flag || m.extra.relax_one),
ridden: m.rider.is_some() || m.extra.rider.is_some() || m.extra.mount.is_some(),
knockback_resistance: m.attrs.knockback_resistance,
effects: m
@@ -1693,6 +1754,10 @@ pub struct PlayerView {
/// A LAN guest: its own client moves it, and touching mobs push it there
/// (`EntitySelector.pushableBy` on the client).
pub remote: bool,
/// `isSleeping` and `getSleepTimer` (100 once slept through, then the
/// ticks fading out after waking up).
pub sleeping: bool,
pub sleep_timer: u32,
/// `isPassenger`.
pub passenger: bool,
}
@@ -2020,18 +2085,29 @@ impl Mob {
if epf > 0.0 {
amount *= 1.0 - epf / 25.0;
}
// Wolf.actuallyHurt: wolf armor takes the damage instead of the wolf.
let absorbed = self.wolf_armor_absorbs(&source);
let mut knock = true;
if self.invulnerable as f32 > 10.0 {
if amount <= self.last_hurt {
return false;
}
self.health -= amount - self.last_hurt;
let dealt = amount - self.last_hurt;
if absorbed {
self.hurt_wolf_armor(dealt);
} else {
self.health -= dealt;
}
self.last_hurt = amount;
knock = false;
} else {
self.last_hurt = amount;
self.invulnerable = 20;
self.health -= amount;
if absorbed {
self.hurt_wolf_armor(amount);
} else {
self.health -= amount;
}
self.hurt_time = 10;
}
// Animal.actuallyHurt: damage taken ends love mode (`resetLove`).
@@ -2062,7 +2138,12 @@ impl Mob {
// playHurtSound (Mob: also resets the ambient sound clock) or the
// death sound: makeSound draws the voice pitch whenever there is a sound.
let dead = !self.alive();
let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }];
// Wolf.getHurtSound: the armor's own sound when it takes the hit.
let sound = if absorbed {
"item.wolf_armor.damage"
} else {
ai::voice_sounds(self)[if dead { 2 } else { 1 }]
};
if !dead {
self.ambient_timer = -self.ambient_interval();
}
@@ -2086,6 +2167,46 @@ impl Mob {
}
true
}
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
/// the world and the untyped kills).
fn wolf_armor_absorbs(&self, source: &Damage) -> bool {
self.species() == Species::Wolf
&& self.extra.head.as_deref() == Some("minecraft:wolf_armor")
&& !matches!(
source.kind,
DamageKind::Drown
| DamageKind::Magic
| DamageKind::InWall
| DamageKind::Thorns
| DamageKind::OutOfWorld
| DamageKind::Generic
)
}
/// `Wolf.actuallyHurt`: the armor loses `ceil(damage)` durability, cracks
/// at `Crackiness.WOLF_ARMOR` steps and breaks at its maximum.
fn hurt_wolf_armor(&mut self, damage: f32) {
let crack = |lost: i32| {
let left = (WOLF_ARMOR_DURABILITY - lost) as f32 / WOLF_ARMOR_DURABILITY as f32;
[0.95_f32, 0.69, 0.32].iter().filter(|t| left < **t).count()
};
let before = self.extra.body_damage;
let after = before + damage.ceil() as i32;
let at = [self.position.x, self.position.y, self.position.z];
if after >= WOLF_ARMOR_DURABILITY {
self.extra.head = None;
self.extra.body_damage = 0;
self.extra.body_extra = None;
self.outbox
.push(MobEvent::Sound("item.wolf_armor.break", at, 1.0, 1.0));
} else {
self.extra.body_damage = after;
if crack(before) != crack(after) {
self.outbox
.push(MobEvent::Sound("item.wolf_armor.crack", at, 1.0, 1.0));
}
}
}
/// LivingEntity.knockback(power, xd, zd) towards the negative direction.
pub fn knockback(&mut self, power: f64, mut xd: f64, mut zd: f64) {
let power = power * (1.0 - self.attrs.knockback_resistance);
@@ -2134,8 +2255,10 @@ impl Mob {
/// Movement speed attribute bonus (baby zombies, sprinting modifiers).
/// MOVEMENT_SPEED including effects (speed +20%/level, slowness -15%/level).
pub fn movement_speed(&self) -> f64 {
let mut v = strider::movement_speed(self, witch::speed(self, self.attrs.speed))
* self.speed_bonus();
let mut v = strider::movement_speed(
self,
witch::speed(self, self.attrs.speed) + enderman::attacking_speed(self),
) * self.speed_bonus();
for (e, _, amp) in &self.extra.effects {
match *e {
"minecraft:speed" => v *= 1.0 + 0.2 * f64::from(amp + 1),
@@ -2527,6 +2650,25 @@ impl SwimAnimation {
}
}
impl Mob {
/// The part of `LivingEntity.tick` and `aiStep` that animates another
/// player from its synched pose and flags: the glide count, the wings
/// (`elytraAnimationState`) and the swim amount (`updateSwimAmount`).
pub fn tick_avatar_pose(&mut self, in_water: bool) {
let e = &mut self.extra;
e.fall_fly_ticks = if e.fall_flying {
e.fall_fly_ticks + 1
} else {
0
};
// LivingEntity.isVisuallySwimming: the swimming pose, or the gliding
// pose after the glide ended.
let visually = e.synced_pose == 3 || (!e.fall_flying && e.synced_pose == 1);
e.elytra.tick(e.fall_flying, e.crouching, self.velocity);
e.swim.tick(visually, in_water);
}
}
#[cfg(test)]
mod elytra_tests {
use super::*;
+78 -14
View File
@@ -53,17 +53,62 @@ pub struct Ai {
/// Swing animation progress (attackAnim) and ticks.
pub swing_time: i32,
pub swinging: bool,
/// The current swing's `SwingDescription.durationTicks`; 0 is the default 6.
pub swing_duration: i32,
/// Walked distance and the next footstep.
pub steps: super::living::Steps,
}
impl Mob {
/// `LivingEntity.getCurrentSwingDuration`: 6 ticks unless a swing set its own.
pub fn swing_duration(&self) -> i32 {
if self.ai.swing_duration > 0 {
self.ai.swing_duration
} else {
6
}
}
/// `SwingState.startIfAble`: a swing past the first half of the current
/// one restarts it.
fn start_swing(&mut self, duration: i32) -> bool {
let half = self.swing_duration() / 2;
if self.ai.swinging && self.ai.swing_time >= 0 && self.ai.swing_time < half {
return false;
}
self.ai.swing_time = -1;
self.ai.swinging = true;
self.ai.swing_duration = duration.max(1);
true
}
/// LivingEntity.swing
pub fn swing(&mut self) {
if !self.ai.swinging || self.ai.swing_time >= 3 || self.ai.swing_time < 0 {
self.ai.swing_time = -1;
self.ai.swinging = true;
if self.start_swing(6) {
self.extra.swing_right_arm = None;
}
}
/// `LivingEntity.swing(hand, animation, false)` for a swing of `duration`
/// ticks (`getModifiedSwingDuration`): the arm is `hand.asArm(mainArm)`.
pub fn swing_hand(&mut self, off_hand: bool, duration: i32) {
if self.start_swing(duration) {
self.extra.swing_right_arm = Some(!off_hand != self.main_arm_left());
}
}
/// `swing_hand` for a swing of `base` ticks, lengthened or shortened by
/// this mob's haste, conduit power or mining fatigue
/// (`LivingEntity.getModifiedSwingDuration`).
pub fn swing_hand_modified(&mut self, off_hand: bool, base: i32) {
let amplifier = |id: &str| self.extra.effects.iter().find(|e| e.0 == id).map(|e| e.2);
let dig_speed = amplifier("minecraft:haste").max(amplifier("minecraft:conduit_power"));
let fatigue = amplifier("minecraft:mining_fatigue");
let duration = crate::first_person::swing_duration(
crate::first_person::SwingAnimation {
kind: 1,
duration: base,
},
dig_speed,
fatigue,
);
self.swing_hand(off_hand, duration);
}
}
/// The module implementing a species' behaviour.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -123,7 +168,7 @@ pub fn family(s: Species) -> Family {
| S::Parched
| S::WitherSkeleton
| S::ZombifiedPiglin => Family::Undead,
S::Creeper | S::Spider | S::CaveSpider => Family::Hostile,
S::Creeper | S::Spider | S::CaveSpider | S::Silverfish => Family::Hostile,
S::Squid
| S::GlowSquid
| S::Cod
@@ -182,9 +227,12 @@ pub fn register(m: &mut Mob) {
Family::Undead => undead::register(m),
Family::Hostile => hostile::register(m),
Family::Aquatic => aquatic::register(m),
// Turtle.registerGoals has its own list, none of the generic goals.
Family::Generic if m.species() == Species::Turtle => super::turtle::register(m),
Family::Generic if m.species() == Species::Armadillo => super::armadillo::register(m),
Family::Generic if m.species() == Species::Frog => super::frog::register(m),
Family::Generic if m.species() == Species::Fox => super::fox::register(m),
Family::Generic if m.species() == Species::Parrot => super::tame::register_parrot(m),
Family::Generic => {
generic(m);
if m.species() == Species::Rabbit {
@@ -202,9 +250,6 @@ pub fn register(m: &mut Mob) {
gravity: true,
};
}
if m.species() == Species::Turtle {
super::turtle::register(m);
}
if m.species() == Species::Goat {
super::goat::register(m);
}
@@ -540,18 +585,21 @@ fn effects_from_blocks(m: &mut Mob, w: &MobWorld) {
}
}
}
/// LivingEntity.updateSwingTime (6-tick swing duration).
/// LivingEntity.updateSwingTime.
pub fn tick_swing(m: &mut Mob) {
let duration = m.swing_duration();
if m.ai.swinging {
m.ai.swing_time += 1;
if m.ai.swing_time >= 6 {
if m.ai.swing_time >= duration {
m.ai.swing_time = 0;
m.ai.swinging = false;
m.ai.swing_duration = 0;
m.extra.swing_right_arm = None;
}
} else {
m.ai.swing_time = 0;
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
m.anim.attack_anim = m.ai.swing_time as f32 / duration as f32;
}
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
@@ -587,6 +635,9 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.was_eye_in_water = m.fluids.eye_in_water;
m.update_fluids(w, events);
if m.species() == Species::Drowned {
super::undead::drowned_update_swimming(m, w);
}
let fire = crate::motion::environment::fire_contact(w.world, w.registry, m.bounds());
if fire > 0.0 {
m.ticks_frozen = 0;
@@ -785,6 +836,7 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
"entity.wolf.pant"
}
}
Species::Parrot => super::parrot::ambient(m, w),
Species::Cat if m.extra.tame && m.extra.in_love > 0 => "entity.cat.purr",
Species::Cat if !m.extra.tame => "entity.cat.stray_ambient",
Species::PolarBear if m.is_baby() => "entity.polar_bear.ambient_baby",
@@ -836,6 +888,9 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
if m.species() == Species::Parrot {
super::parrot::ai_step(m, w, level_random, events);
}
// Witch.aiStep runs before super.aiStep().
if m.species() == Species::Witch {
let far = m
@@ -897,8 +952,8 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
if m.kind.flags.climber {
m.climbing = m.horizontal_collision;
}
// AgeableMob.aiStep: a forced age-up's particles (positions drawn on the
// server too), then the age.
// AgeableMob.aiStep: a forced age-up's particles (`sendParticles`, so the
// players are sent them), then the age.
if m.extra.forced_age_timer > 0 {
if m.extra.forced_age_timer % 4 == 0 {
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
@@ -907,10 +962,12 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
let y = m.position.y + h * r.next_double() + 0.5;
let z = m.position.z + w * (2.0 * r.next_double() - 1.0);
// sendParticles(HAPPY_VILLAGER, x, y, z, 1, 0, 0, 0, 0): exactly there, at rest.
events.push(MobEvent::Particles {
events.push(MobEvent::LevelParticles {
kind: "happy_villager",
at: [x, y, z],
count: 0,
spread: [0.0; 3],
speed: 0.0,
count: 1,
});
}
m.extra.forced_age_timer -= 1;
@@ -1536,6 +1593,9 @@ pub fn walk_target_value(m: &Mob, w: &MobWorld, p: [i32; 3]) -> f32 {
if m.species() == Species::Strider {
return super::strider::walk_target_value(m, w, p);
}
if m.species() == Species::Turtle {
return super::turtle::walk_target_value(m, w, p, light());
}
// Hoglin.getWalkTargetValue: nowhere near a repellent, crimson nylium best.
if m.species() == Species::Hoglin {
let near = m.extra.repellent.is_some_and(|r| {
@@ -1605,6 +1665,10 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool {
super::panda::hurt(m);
true
}
Species::Silverfish => {
super::hostile::silverfish_hurt(m, source);
true
}
Species::Enderman => super::enderman::on_damage(m, source),
Species::WitherSkeleton | Species::Blaze | Species::MagmaCube | Species::Strider
if source.is_fire() =>
+71 -1
View File
@@ -244,6 +244,8 @@ pub enum ArmPose {
CrossbowCharge,
/// Raising a trident to throw it (or to launch with Riptide).
ThrowTrident,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
}
/// HumanoidModel.setupAnim with empty hands (endermen).
pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) {
@@ -295,6 +297,10 @@ pub struct Hum {
pub swing_right_arm: Option<bool>,
/// `mainArm` is the left one (`Mob.isLeftHanded`): a mob's swing uses it.
pub left_main: bool,
/// The spear each arm (right, left) holds, and how long that arm has
/// been using it (`ticksUsingItem(arm)`, 0 when it is not).
pub spear: [Option<crate::spear::Kinetic>; 2],
pub spear_ticks: [f32; 2],
}
impl Hum {
pub fn new(right: ArmPose, left: ArmPose) -> Self {
@@ -313,6 +319,8 @@ impl Hum {
using_item: false,
swing_right_arm: None,
left_main: false,
spear: [None; 2],
spear_ticks: [0.0; 2],
}
}
}
@@ -388,6 +396,23 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
}
ArmPose::CrossbowHold => crossbow_hold(p, hx, hy, right),
ArmPose::CrossbowCharge => crossbow_charge(p, h.charge.0, h.charge.1, right),
// SpearAnimations.thirdPersonHandUse.
ArmPose::Spear => {
let side = usize::from(!right);
let Some(kinetic) = h.spear[side] else {
return;
};
let (x, y, z) = crate::spear::third_person_arm(
&kinetic,
h.spear_ticks[side],
right,
(hx, hy),
h.fall_flying || h.swim > 0.0,
);
p.xrot(arm, x);
p.yrot(arm, y);
p.get(arm)[5] += z;
}
}
}
/// `HumanoidModel.setupAnim` (and `ZombieModel.setupAttackAnimation`).
@@ -515,6 +540,26 @@ fn rot_lerp_rad(a: f32, from: f32, to: f32) -> f32 {
}
from + a * diff
}
/// `DrownedModel.setupSwimAnimation`: the arms reach up and sway, the legs
/// kick and the head lies straight.
fn drowned_swim(p: &mut Poses, s: &State, amount: f32) {
if amount <= 0.0 {
return;
}
let sway = amount * 0.35 * (0.1 * s.age).msin();
let kick = amount * 0.55 * (0.1 * s.age).msin();
let reach = -PI * 4.0 / 5.0;
let (right_x, left_x) = (p.get("right_arm")[3], p.get("left_arm")[3]);
let (right_z, left_z) = (p.get("right_arm")[5], p.get("left_arm")[5]);
p.xrot("right_arm", rot_lerp_rad(amount, right_x, reach) + sway);
p.xrot("left_arm", rot_lerp_rad(amount, left_x, reach) - sway);
p.zrot("right_arm", rot_lerp_rad(amount, right_z, -0.15));
p.zrot("left_arm", rot_lerp_rad(amount, left_z, 0.15));
let (left_leg, right_leg) = (p.get("left_leg")[3], p.get("right_leg")[3]);
p.xrot("left_leg", left_leg - kick);
p.xrot("right_leg", right_leg + kick);
p.xrot("head", 0.0);
}
/// `HumanoidModel.setupSwimAnimation`: the stroke of both arms over 26
/// walk-animation units, the head down and a slow flutter kick. The left arm
/// eases the short way round, the right one linearly, as in vanilla.
@@ -672,6 +717,9 @@ pub fn setup(
h.crouching = m.extra.crouching;
h.passenger = m.extra.passenger;
humanoid(p, s, &h);
if m.kind.anim == Anim::Drowned {
drowned_swim(p, s, m.extra.swim.amount(s.partial));
}
if m.kind.anim == Anim::ZombifiedPiglin {
super::piglin::ears(p, s);
}
@@ -687,6 +735,14 @@ pub fn setup(
ArmPose::Empty
};
let using_main = m.extra.using_item > 0 && !m.extra.using_off_hand;
// AvatarRenderer.getArmPose: an arm holding a spear (at rest too).
let spear_in = |hand: &Option<std::sync::Arc<str>>| {
hand.as_deref()
.filter(|_| player)
.and_then(crate::spear::Kinetic::of)
};
let (main_spear, off_spear) =
(spear_in(&m.extra.main_hand), spear_in(&m.extra.off_hand));
let pose = if player {
match m.extra.main_hand.as_deref() {
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
@@ -698,6 +754,7 @@ pub fn setup(
Some("minecraft:crossbow") if m.extra.charged && s.swing == 0.0 => {
ArmPose::CrossbowHold
}
Some(_) if main_spear.is_some() => ArmPose::Spear,
Some(_) => ArmPose::Item,
None => ArmPose::Empty,
}
@@ -705,7 +762,9 @@ pub fn setup(
right
};
let off = if player && m.extra.off_hand.is_some() && !pose.affects_offhand() {
if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking {
if off_spear.is_some() {
ArmPose::Spear
} else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking {
ArmPose::Block
} else {
ArmPose::Item
@@ -731,6 +790,17 @@ pub fn setup(
h.using_item = m.extra.using_item > 0;
h.swing_right_arm = m.extra.swing_right_arm;
h.left_main = m.main_arm_left();
let main_side = usize::from(m.main_arm_left());
h.spear[main_side] = main_spear;
h.spear[1 - main_side] = off_spear;
if m.extra.using_item > 0 {
let using_side = if m.extra.using_off_hand {
1 - main_side
} else {
main_side
};
h.spear_ticks[using_side] = m.extra.using_item as f32 + s.partial;
}
humanoid(p, s, &h);
if m.ai.aggressive && !bow {
// SkeletonModel melee pose.
+3 -1
View File
@@ -981,7 +981,9 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial;
let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial;
// `Parrot.isFlying` is `!onGround`, in water too.
let pose = if m.extra.sitting {
let pose = if m.extra.party_jukebox.is_some() {
ParrotPose::Party
} else if m.extra.sitting {
ParrotPose::Sitting
} else if !m.on_ground {
ParrotPose::Flying
@@ -118,6 +118,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage) -> bool {
source.kind,
DamageKind::PlayerAttack
| DamageKind::MaceSmash
| DamageKind::Spear
| DamageKind::MobAttack
| DamageKind::Arrow
| DamageKind::Thrown
+37 -25
View File
@@ -151,37 +151,49 @@ pub fn jump(m: &mut Mob) {
};
m.velocity.y = f64::from(m.jump_power() + boost);
}
/// `AbstractCubeMob.tick`'s landing splash: crumbs of goo (or flames) around
/// the cube's feet, drawn by the side that ticks the mob.
fn landing_splash(m: &mut Mob, events: &mut Vec<MobEvent>) {
let size = m.width() * 2.0;
let radius = size / 2.0;
let kind = match m.species() {
Species::MagmaCube => "flame",
Species::SulfurCube => "sulfur_cube_goo",
_ => "item_slime",
};
let mut i = 0;
while (i as f32) < size * 16.0 {
let dir = m.random.next_float() * std::f64::consts::TAU as f32;
let d = m.random.next_float() * 0.5 + 0.5;
let xd = trig::sin(f64::from(dir)) * radius * d;
let zd = trig::cos(f64::from(dir)) * radius * d;
events.push(MobEvent::Particles {
kind,
at: [
m.position.x + f64::from(xd),
m.position.y,
m.position.z + f64::from(zd),
],
count: 0,
});
i += 1;
}
}
/// `AbstractCubeMob.tick` on a client of a server: the landing splash follows
/// the on-ground flag the server sends.
pub fn client_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.on_ground && !m.extra.flag2 {
landing_splash(m, events);
}
m.extra.flag2 = m.on_ground;
}
/// AbstractCubeMob.tick squish and landing sounds, plus contact damage.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let v = &mut m.anim.v;
v[SQUISH] += (v[TARGET_SQUISH] - v[SQUISH]) * 0.5;
let was = m.extra.flag2;
if m.on_ground && !was {
// The landing splash: drawn on the server too, where it spawns nothing.
let size = m.width() * 2.0;
let radius = size / 2.0;
let kind = match m.species() {
Species::MagmaCube => "flame",
Species::SulfurCube => "sulfur_cube_goo",
_ => "item_slime",
};
let mut i = 0;
while (i as f32) < size * 16.0 {
let dir = m.random.next_float() * std::f64::consts::TAU as f32;
let d = m.random.next_float() * 0.5 + 0.5;
let xd = trig::sin(f64::from(dir)) * radius * d;
let zd = trig::cos(f64::from(dir)) * radius * d;
events.push(MobEvent::Particles {
kind,
at: [
m.position.x + f64::from(xd),
m.position.y,
m.position.z + f64::from(zd),
],
count: 0,
});
i += 1;
}
landing_splash(m, events);
let squish = match (m.species(), tiny(m)) {
(Species::SulfurCube, _) => super::sulfur_cube::squish_sound(m),
(Species::MagmaCube, true) => "entity.magma_cube.squish_small",
+5
View File
@@ -251,6 +251,9 @@ pub fn slot_item(m: &Mob, slot: &str) -> Option<LootStack> {
1
};
let mut stack = loot_stack(item?, count as i32);
if slot == "body" && m.species() == Species::Wolf {
stack.damage = m.extra.body_damage;
}
if let (Some(index), Some(g)) = (index, gear::gear(m)) {
for (e, l) in &g.slots[index] {
stack.enchantments.push((e.to_string(), *l as i32));
@@ -288,6 +291,8 @@ pub fn clear_slot(m: &mut Mob, slot: &str) {
}
"body" if body => {
m.extra.head = None;
m.extra.body_damage = 0;
m.extra.body_extra = None;
return;
}
"saddle" => {
@@ -154,6 +154,15 @@ fn teleport_towards(
let z = m.position.z + (m.random.next_double() - 0.5) * 8.0 - d[2] * 16.0;
teleport_to(m, w, x, y, z, events)
}
/// `Enderman.SPEED_MODIFIER_ATTACKING`, an ADD_VALUE modifier `setTarget`
/// adds while the enderman has a target.
pub fn attacking_speed(m: &Mob) -> f64 {
if m.species() == Species::Enderman && m.ai.target.is_some() {
f64::from(0.15f32)
} else {
0.0
}
}
/// Enderman.customServerAiStep: in daylight, flee by teleporting. The End has a
/// fixed time, so `Level.isBrightOutside` is never true there.
pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
+21
View File
@@ -471,6 +471,11 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
pub fn animate(m: &mut Mob) {
let (eating, standing) = (m.extra.flag, m.extra.counter > 0);
let v = &mut m.anim.v;
if standing {
@@ -488,6 +493,22 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
v[MOUTH] = (v[MOUTH] - v[MOUTH] * 0.7 - 0.05).max(0.0);
}
/// `AbstractHorse.tick` on a client that is not the server: the animations
/// follow the flags of the synched data (`set_flags`).
pub fn client_tick(m: &mut Mob) {
m.anim.vo = m.anim.v;
animate(m);
}
/// `AbstractHorse.DATA_ID_FLAGS` on a client: eating 16 and standing 32.
pub fn set_flags(m: &mut Mob, flags: u8) {
m.extra.flag = flags & 16 != 0;
m.extra.counter = i32::from(flags & 32 != 0);
}
/// Whether `ai_step` above runs this species' grazing and rearing, so that
/// the server's flags 16 and 32 mean eating and standing.
pub fn grazes(s: Species) -> bool {
is_equine(s) && !super::camel::is_camel(s) && !super::llama::is_llama(s)
}
/// Body texture and the markings overlay.
pub fn texture(m: &Mob) -> String {
let baby = if m.is_baby() { "_baby" } else { "" };
+30 -3
View File
@@ -516,9 +516,13 @@ fn effect_modifiers(m: &Mob, a: Attr) -> impl Iterator<Item = (f64, Op)> + '_ {
}
/// `AttributeInstance.getValue`: the base, then the modifiers by operation.
pub fn value(m: &Mob, a: Attr) -> f64 {
compute(m, a, true)
compute(m, a, true, &[])
}
fn compute(m: &Mob, a: Attr, with_transient: bool) -> f64 {
/// `value` with more modifiers, such as the ones worn equipment grants.
pub fn value_with(m: &Mob, a: Attr, extra: &[(f64, Op)]) -> f64 {
compute(m, a, true, extra)
}
fn compute(m: &Mob, a: Attr, with_transient: bool, extra: &[(f64, Op)]) -> f64 {
let mut base = base_value(m, a);
let empty = Vec::new();
let mods = m.extra.attributes.as_ref().map_or(&empty, |s| &s.modifiers);
@@ -531,6 +535,7 @@ fn compute(m: &Mob, a: Attr, with_transient: bool) -> f64 {
ops.extend(transient(m, a));
ops.extend(effect_modifiers(m, a));
}
ops.extend(extra.iter().copied());
for (amount, op) in &ops {
if *op == Op::AddValue {
base += amount;
@@ -573,7 +578,7 @@ fn refresh(m: &mut Mob, a: Attr) {
/// The value without the transient baby modifier (`speed_bonus` applies
/// that one to movement speed).
fn base_with_modifiers(m: &Mob, a: Attr) -> f64 {
compute(m, a, false)
compute(m, a, false, &[])
}
/// `AttributeInstance.setBaseValue`.
pub fn set_base(m: &mut Mob, a: Attr, v: f64) {
@@ -898,6 +903,28 @@ pub fn slot_enchantments(m: &Mob, slot: usize) -> gear::Levels {
pub fn set_slot_enchantments(m: &mut Mob, slot: usize, levels: gear::Levels) {
gear::set(m, gear_slot(slot), levels);
}
/// `EquipmentUser.equip` for one rolled stack: `setItemSlot` puts the item in
/// `slot` with its own enchantments, then the slot's drop chance is stored
/// when the table names one.
pub fn equip_stack(
m: &mut Mob,
slot: usize,
stack: &horizon_world::loot::LootStack,
drop_chance: Option<f32>,
) {
set_item(m, slot, &stack.item);
if slot <= HEAD {
let levels = stack
.enchantments
.iter()
.map(|(id, level)| (Arc::from(id.as_str()), (*level).max(0) as u32))
.collect();
gear::set(m, gear_slot(slot), levels);
}
if let Some(chance) = drop_chance {
set_drop_chance(m, slot, chance);
}
}
/// `Mob.setDropChance`.
pub fn set_drop_chance(m: &mut Mob, slot: usize, chance: f32) {
let c = m
+14 -4
View File
@@ -675,6 +675,9 @@ pub struct Panic {
pub speed: f64,
/// Only the damage of `#panic_environmental_causes` (an armadillo).
pub environmental: bool,
/// `TurtlePanicGoal`: always look for water this far away, not only
/// when on fire.
pub water_range: Option<i32>,
pos: [f64; 3],
}
impl Panic {
@@ -682,9 +685,15 @@ impl Panic {
Self {
speed,
environmental: false,
water_range: None,
pos: [0.0; 3],
}
}
/// `TurtlePanicGoal`.
pub fn looking_for_water(mut self, range: i32) -> Self {
self.water_range = Some(range);
self
}
}
pub fn should_panic(m: &Mob) -> bool {
m.last_damage
@@ -707,18 +716,19 @@ impl Goal for Panic {
if !wanted {
return false;
}
if m.fire_ticks > 0 {
let range = self.water_range.or((m.fire_ticks > 0).then_some(5));
if let Some(range) = range {
let (bx, by, bz) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
if cx.w.data.state(cx.w.world.block(bx, by, bz)).top == 0.0 {
// findBlocksInBoxByManhattanDistance(pos, 5, 1) for water.
// findBlocksInBoxByManhattanDistance(pos, range, 1) for water.
let mut best: Option<([i32; 3], i32)> = None;
for dy in -1i32..=1 {
for dx in -5i32..=5 {
for dz in -5i32..=5 {
for dx in -range..=range {
for dz in -range..=range {
let d = dx.abs() + dy.abs() + dz.abs();
if cx.w.fluid(bx + dx, by + dy, bz + dz) == crate::blocks::Fluid::Water
&& best.is_none_or(|b| d < b.1)
+2 -2
View File
@@ -341,12 +341,12 @@ fn snowball_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let zd = t.position.z - m.position.z;
let yo = (xd * xd + zd * zd).sqrt() * 0.2;
let mut p = Projectile::new(ProjectileKind::Snowball, start, Some(Who::Mob(m.id)));
// rangedAttackUncertainty: 14 - 4 * difficulty (normal: 6).
// SnowGolem.rangedAttackUncertainty: 12 on every difficulty.
p.shoot(
&mut crate::projectiles::own_random(m),
[xd, yd + yo - start.y, zd],
1.6,
6.0,
12.0,
);
cx.events.push(MobEvent::Projectile(p));
let pitch = 0.4 / (m.random.next_float() * 0.4 + 0.8);
+21 -9
View File
@@ -112,14 +112,14 @@ impl Goal for GuardianAttack {
if self.attack_time == 0 {
m.extra.flag = true;
m.extra.counter2 = 0;
// Entity event 21: the attack sound loop starts.
let sound = "entity.guardian.attack";
cx.events.push(MobEvent::Sound(
sound,
[m.position.x, m.position.y, m.position.z],
1.0,
1.0,
));
// Entity event 21: the attack sound loop starts on the clients.
if !m.silent {
cx.events.push(MobEvent::EntityEvent {
mob: m.id,
event: 21,
at: [m.position.x, m.position.y, m.position.z],
});
}
} else if self.attack_time >= attack_duration(m) {
// Indirect magic (1, +2 on Hard, +2 for the elder), then
// doHurtTarget.
@@ -291,7 +291,10 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
&& let Some((d, _)) = m.last_damage
&& matches!(
d.kind,
DamageKind::PlayerAttack | DamageKind::MaceSmash | DamageKind::MobAttack
DamageKind::PlayerAttack
| DamageKind::MaceSmash
| DamageKind::Spear
| DamageKind::MobAttack
)
{
let from = [m.position.x, m.position.y, m.position.z];
@@ -473,3 +476,12 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
p.yrot("tail1", tail.msin() * PI * 0.1);
p.yrot("tail2", tail.msin() * PI * 0.15);
}
/// `Guardian.getAttackAnimationScale(0)`: how far the beam has charged. A
/// beam that ended counts from zero again.
pub fn attack_scale(m: &Mob) -> f32 {
if m.extra.flag {
m.extra.counter2 as f32 / attack_duration(m) as f32
} else {
0.0
}
}
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more