Compare commits

...
Author SHA1 Message Date
Ludovic Marie 59ed6869e3 Merge pull request #2801 from ludovic111/feat/nightly-game-label
🤖 Agent: verified with pr_ready --quick (fmt, clippy, 1443 tests green); nightly label screenshot in the body. Queued at 2af3308.
2026-10-27 14:19:33 +01:00
Ludovic Marie 1ac4bcbe9b Merge pull request #2690 from kacpherek/fix/mob-ambient-particles
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:27 +01:00
Ludovic Marie 9b487bdfe9 Merge pull request #2687 from kacpherek/feat/shelf-use
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:23 +01:00
Ludovic Marie 1ec2b888aa Merge pull request #2680 from kacpherek/fix/spider-jockey-hoglin-toss
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:19 +01:00
Ludovic Marie dbb396e599 Merge pull request #2678 from kacpherek/fix/skeleton-freezes-to-stray
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:19:12 +01:00
Ludovic Marie 1e3a9d53a7 Merge pull request #2386 from kacpherek/fix/mace-smash-knockback-players
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:10:14 +01:00
Ludovic Marie e5cd63b608 Merge pull request #2337 from kacpherek/fix/shears-wear
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:10:04 +01:00
Ludovic Marie 06522c11db Merge pull request #2334 from kacpherek/fix/cat-spawner
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 14:09:56 +01:00
Ludovic Marie 7bdfe942a4 Merge pull request #2779 from ludovic111/feat/nightly-channel
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after reading the workflows: the union build runs without secrets, a separate job only hashes and signs with a channel-tagged message (release launchers refuse nightly signatures, #2792), and the workflows always run from main. The release dry run on this head (40504185084) is green on every job.
2026-10-27 14:02:39 +01:00
Ludovic MarieandClaude Opus 5.5 2af3308892 Nightly builds name themselves on the title screen and in reports
A game built by the nightly workflow (HORIZON_NIGHTLY="<date> <sha>") draws
"Nightly <date> · <sha>" in the title screen's top-left corner, reports it as
its version in crash reports, and logs it as the log's first line, so a bug
report that carries the log names the build. Telemetry gets the version the
launcher installs it as (nightly-<date>-<sha>). Releases and source builds
are unchanged; only the "Horizon <version>" log line is new for them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 14:01:12 +01:00
Ludovic Marie 2b910fa16d Merge pull request #2793 from ludovic111/test/win-screen-temp-dirs
🤖 Agent: test-only fix for the win_screen flake (two parallel tests shared one temp dir; each call now gets its own). pr_ready --quick green (1329 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify passes.
2026-10-27 14:00:30 +01:00
Ludovic Marie 4483c2613c Merge pull request #2792 from ludovic111/feat/nightly-launcher
🤖 Agent: merging the opt-in Nightly setting (off by default, own instance); fmt, clippy -D warnings, launcher lib tests and horizon-release-sign tests pass, including the release channel refusing nightly signatures.
2026-10-27 13:55:38 +01:00
Ludovic Marie 3b19c386cf Merge pull request #2354 from kacpherek/fix/dragon-kill-credit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:46:08 +01:00
Ludovic Marie b4b80eaeb2 Merge pull request #2361 from kacpherek/fix/immediate-respawn
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:40:48 +01:00
Ludovic Marie 71d0220215 Merge pull request #2332 from kacpherek/fix/stations-unbreakable
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:16 +01:00
Ludovic Marie c27e3287c5 Merge pull request #2331 from kacpherek/fix/mob-custom-persistence
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:13 +01:00
Ludovic Marie c326afc7f1 Merge pull request #2327 from kacpherek/fix/thorns-unbreaking
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:09 +01:00
Ludovic Marie fd6c1e1770 Merge pull request #2323 from kacpherek/fix/warden-disables-shield
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:40:03 +01:00
Ludovic Marie 82d36d1f9b Merge pull request #2322 from kacpherek/fix/impaling-aquatic-tag
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:39:55 +01:00
Ludovic Marie a64c4c6562 Merge pull request #2476 from kacpherek/fix/chat-links
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A3 0c0206714 (main d9b6483aa + 2503 2521 2513 2555 2476) Mac mini 95/.
2026-10-27 13:39:22 +01:00
Ludovic Marie b3fff5222b Merge pull request #2495 from kacpherek/fix/pet-owner-checks
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:15 +01:00
Ludovic Marie 9281a0b5d2 Merge pull request #2491 from kacpherek/fix/1780-exit-portal-rebuild
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:07 +01:00
Ludovic Marie 38588ab7aa Merge pull request #2490 from kacpherek/fix/2485-redstone-ore-step
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union A 5fb64f0d6 (main c1ebf5d1b + 50 PRs) Mac mini 352/354 harnesses (fin.
2026-10-27 13:34:01 +01:00
Ludovic Marie 8521aaac41 Merge pull request #2400 from kacpherek/fix/cat-sit-block
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:21 +01:00
Ludovic Marie bdd6b0ea12 Merge pull request #2396 from kacpherek/fix/spectator-crosshair
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:16 +01:00
Ludovic Marie c179c8753f Merge pull request #2377 from kacpherek/fix/raider-banner-reachable
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:12 +01:00
Ludovic Marie 19abf6182d Merge pull request #2330 from kacpherek/fix/mob-farmland-trample
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked hours ago, with no new objection since. Proven in cg19 union R1 bb29ce68e (union/cg19-r1: main c6c2ec5f9 + 83 PRs) Mac mini: 98 se.
2026-10-27 13:33:08 +01:00
Ludovic Marie f87bbb5342 Merge pull request #2329 from kacpherek/fix/punch-knockback-resistance
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:19 +01:00
Ludovic Marie 7c7937438e Merge pull request #2321 from kacpherek/fix/bane-slowness
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:15 +01:00
Ludovic Marie 6207624448 Merge pull request #2311 from kacpherek/fix/1661-shulker-rotation
🤖 Agent: Proven in union R1 (branch union/cg19-r1, main + 83 of kacpherek's PRs): the 98 harnesses it can affect, clippy -D warnings, tests, lan_e2e in-frame and on the server thread.
2026-10-27 13:32:12 +01:00
Ludovic Marie 4f7a0f2ba1 Merge pull request #2781 from ludovic111/refactor/cut-options-opt
🤖 Agent: move only: the Opt enum (option ids) goes from options_ui.rs to settings.rs (re-exported from options_ui), cutting graphics_ui -> options_ui (step B of the cycle plan). pr_ready --quick green (1323 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify and parity pass.
2026-10-27 13:32:08 +01:00
Ludovic MarieandClaude Opus 5.5 24800ac54e ci: hand-started release dry runs get their own concurrency group
A dry run publishes nothing, so it can't race a real release; in the shared game-release group a release queued
behind it replaced it as the pending run and GitHub cancelled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:29:22 +01:00
Ludovic MarieandClaude Opus 5.5 66dabf51ee launcher: an opt-in Nightly setting that plays in its own instance
Settings > Updates gets "Nightly builds", off by default: "Reviewed fixes not released yet; may break." On, the
launcher installs the `nightly` pre-release as its own version (nightly-<date>-<commit>), in an instance of its
own ("Nightly"), and plays it; every update check (30 min) installs a newer nightly and drops the older one. The
Horizon instance keeps the release and the player's worlds: choosing it in the sidebar plays the release again,
and turning Nightly off does that too.

Nightly checksums must be signed for the nightly channel ("horizon release nightly"): horizon-release-sign's
signed_digest refuses nightly tags outright, and a nightly's signature never verifies under a release tag nor a
release's under the nightly channel (tests in both crates).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:28:17 +01:00
Ludovic Marie 88aa505533 Merge pull request #2778 from ludovic111/refactor/cut-health-rules
🤖 Agent: move only: HealthRules (and its Default) go from health.rs to world.rs (re-exported from health), cutting world -> health (step B of the cycle plan). pr_ready --quick green (1323 tests). Queued once pr_ready was green, as the orchestrator asked; merge after verify and parity pass.
2026-10-27 13:26:11 +01:00
Ludovic Marie ab080a5407 Merge pull request #2776 from ludovic111/refactor/cut-effect-tables
🤖 Agent: move only: the MobEffects/Potions tables go from brewing.rs to horizon-client-core::potions (re-exported from brewing), cutting effects -> brewing and hud_overlays -> brewing (step B of the cycle plan). pr_ready --quick green (1361 tests). Queued at the orchestrator's request once pr_ready was green; merge after verify and parity pass.
2026-10-27 13:21:06 +01:00
Ludovic MarieandClaude Opus 5.5 d89e27113f ci: an opt-in Nightly build from the proven union branches
nightly-build.yml builds, once a night, the newest union/<agent>-<name> branch on origin when it is newer than
main and holds something main lacks (else main), and publishes it as the `nightly` pre-release; the previous one
stays a week as nightly-<date> (tools/ci/nightly.py pick/rotate/prune). Launchers ignore it until a player turns
Nightly on (next PR).

The release builds move unchanged into game-build.yml, a reusable workflow both release.yml and the nightly call
from main's copy. The nightly builds with no secrets and the macOS keychain locked; only the sign job holds the
key, and it runs nothing but main's sign_release.sh over the archives, for the message "horizon release nightly".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:14:32 +01:00
Ludovic MarieandClaude Opus 5.5 f08a564fa6 test: give each win_screen test its own temp dir
The helper named its resource root after the pid, the poem flag and the
end text's length, so two tests with the same arguments, running in
parallel, shared it and one removed the other's files (os error 22 in
the_screen_ends_once_everything_rolled_past). A counter makes each call's
directory unique.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 13:08:52 +01:00
Ludovic Marie 7262304a6d Merge pull request #2773 from ludovic111/feat/sculk-sidebar-card
🤖 Agent: merging the Sculk sidebar card (Ludovic approved the screenshots); fmt, clippy -D warnings and launcher tests pass on the head merged with main.
2026-10-27 13:04:06 +01:00
Ludovic Marie 1d2c5db426 Merge pull request #2769 from ludovic111/refactor/cut-villager-poi
🤖 Agent: verify passed at 84c631a88. Move only: the villager Poi enum goes from mobs/villager_brain.rs to poi_sections.rs (re-exported), cutting world -> mobs (step B of the cycle plan). pr_ready --quick green (1323 tests). Ready to merge once parity is green.
2026-10-27 12:54:54 +01:00
Ludovic MarieandClaude Opus 5.5 964fbe1c29 Merge origin/main into feat/sculk-sidebar-card
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:52:56 +01:00
Ludovic Marie 6ab1b40ad4 Merge pull request #2252 from kacpherek/fix/wither-rose-kills
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:09 +01:00
Ludovic Marie cd18488cc2 Merge pull request #2258 from kacpherek/fix/rain-sound-vanilla
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:06 +01:00
Ludovic Marie 970ee43f8e Merge pull request #2259 from kacpherek/fix/levitation-and-spyglass-triggers
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21); main merged in since, CI green at this head.
2026-10-27 12:46:02 +01:00
Ludovic Marie e05847289f Merge pull request #2766 from ludovic111/refactor/cut-saved-proto
🤖 Agent: verify passed at 83bdb31ee. Move only: SavedProto goes from save.rs to chunk.rs (re-exported from save), cutting streaming -> save (step B of the cycle plan). pr_ready --quick green (1323 tests). Ready to merge once parity is green.
2026-10-27 12:45:22 +01:00
Ludovic MarieandClaude Opus 5.5 bc0c0fdf4a refactor: the MobEffects and Potions tables live in horizon-client-core
effects.rs and hud_overlays.rs read the effect and potion tables from
brewing, which made them depend on brewing (and through it on containers
and items), edges that keep the client lib in a single cycle. The tables
have no dependencies, so they move to horizon-client-core; brewing
re-exports them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:43:11 +01:00
Ludovic Marie 97437beaff Merge pull request #2296 from masa-masa-0314/fix/enchantment-glint
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:40:44 +01:00
Ludovic Marie 869efb87d2 Merge pull request #2130 from Joansitoh/fix/launcher-text-field-edit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:40:40 +01:00
Ludovic MarieandClaude Opus 5.5 2edb5193ef Merge main into fix/wither-rose-kills (combat: keep hive_emptying_blast and explosion_owner; wither: the skull's owner set around main's hive-emptying blast)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:32:55 +01:00
Ludovic Marie d9f49ecec0 Merge pull request #2261 from kacpherek/fix/death-and-hurt-effects
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:46 +01:00
Ludovic Marie 73d0cd36e8 Merge pull request #2257 from kacpherek/fix/container-open-sound-pitch
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:43 +01:00
Ludovic Marie abda92d574 Merge pull request #2251 from kacpherek/fix/brewed-potion-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:39 +01:00
Ludovic MarieandClaude Opus 5.5 cacd39338f Merge origin/main into fix/rain-sound-vanilla
Keeps main's Decreased and Minimal particle options. As in
ClientLevel.tickWeatherEffects, the rain position is set before Minimal's
break, so the rain sound still plays with Minimal particles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:30:36 +01:00
Ludovic Marie d7cd2ed5e8 Merge pull request #2229 from kacpherek/fix/fishing-rod-hooked-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (72 kacpherek PRs incl. this one) playhorizon 94/95 + Mac mini 33/33, lan_e2e 23/23 both ).
2026-10-27 12:30:34 +01:00
Ludovic Marie ca63a998cf Merge pull request #2214 from masa-masa-0314/fix/superflat-customize
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab387, Mac mini); main merged in since, CI green at this head.
2026-10-27 12:28:29 +01:00
Ludovic MarieandClaude Opus 5.5 86b651405a Merge main into fix/levitation-and-spyglass-triggers (advancements.rs: keep the levitation/using_item triggers and main's hurt triggers)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:28:06 +01:00
Ludovic MarieandClaude Opus 5.5 cf9fb96efa launcher: a vertical Sculk card for the sidebar, readable at any size
The sidebar showed Sculk's wide banner shrunk to 228 px, and its baked-in text could not be read.
It now shows a card made for the sidebar: Sculk's characters and logo cropped from its banner, and
the headline, "Ready in under a minute", a "Get my server" button and the HORIZON15 code drawn by
the launcher, so they stay sharp and follow its language (French and Russian added). Where the
window is too short for the tall card, a compact one puts the characters beside the text. The
Partner label, the 180 s before Hide, the link and the hover lift stay; the Servers page keeps
the wide banner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:26:41 +01:00
Ludovic Marie 6850ec1b3a Merge pull request #2528 from kacpherek/fix/server-mob-animation-sync
🤖 Agent: Merged main in (clean) so GitHub sees one merge base; proven in union A2 (union/cg19-a2): clippy, tests, parity, lan_e2e both paths.
2026-10-27 12:22:21 +01:00
Ludovic Marie c2d43643aa Merge pull request #2125 from kacpherek/fix/remote-entity-events
🤖 Agent: Read against the vanilla 26.3 sources (Server players see villager hearts, taming hearts and smoke, spawn poofs and snowball crumbs) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 12:22:17 +01:00
Ludovic MarieandClaude Opus 5.5 ab083f9005 Merge main into fix/enchantment-glint
entity.wgsl: the vanilla glint path also takes main's overlay 33 (a translucent item with the glint).
gfx.rs: main's energy swirl pipeline; lightning's offset stays on attribute 7 (6 is the glint UV).
item_render.rs: main's translucent item overlays, chosen with the glint UV the branch adds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:21:19 +01:00
Ludovic Marie 94290b01ee Merge pull request #2275 from kacpherek/fix/spawn-egg-baby
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:19:04 +01:00
Ludovic Marie fc6102738c Merge pull request #2266 from kacpherek/fix/tnt-minecart-collision
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:19:01 +01:00
Ludovic MarieandClaude Opus 5.5 cbbf6f8b2e Merge main into fix/launcher-text-field-edit (main.rs: keep main's background_error and the LineEdit field)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:18:50 +01:00
Ludovic Marie f00228e76a Merge pull request #2250 from kacpherek/fix/hopper-minecart-pickup
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:27 +01:00
Ludovic Marie cad80ba3aa Merge pull request #2248 from kacpherek/fix/stats-combat
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:23 +01:00
Ludovic MarieandClaude Opus 5.5 f1d14c918f Merge main into fix/superflat-customize (world_loading: keep the flat settings and main's game rules)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:18:22 +01:00
Ludovic Marie 7953969510 Merge pull request #2246 from kacpherek/fix/mob-effect-merge
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:19 +01:00
Ludovic Marie b62c2854ad Merge pull request #2244 from kacpherek/fix/lightning-cleans-copper
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:16 +01:00
Ludovic Marie 830b841850 Merge pull request #2236 from kacpherek/fix/harvest-full-beehive
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:18:12 +01:00
Ludovic Marie ba01b418c2 Merge pull request #2232 from kacpherek/fix/note-block-attack-and-heads
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:22 +01:00
Ludovic Marie 5a3ea22419 Merge pull request #2230 from kacpherek/fix/player-hurt-entity-trigger
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:19 +01:00
Ludovic Marie cd43ad9a4a Merge pull request #2226 from kacpherek/fix/armadillo-scutes
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:15 +01:00
Ludovic Marie 98a11014ea Merge pull request #2223 from kacpherek/fix/invisibility-mob-detection
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:11 +01:00
Ludovic Marie 467e28f2eb Merge pull request #2220 from kacpherek/fix/explosion-beehive-bees
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:17:07 +01:00
Ludovic Marie 069a4f7f23 Merge pull request #2128 from kacpherek/fix/remote-hurt-tilt
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:16:24 +01:00
Ludovic Marie f78ba8c75f Merge pull request #2123 from kacpherek/fix/remote-leash-knots
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg21 union 65b0cb252 (main c1ebf5d1b + 72 kacpherek PRs) playhorizon 94/95, use_item_on fixed (#2126 523280e78).
2026-10-27 12:16:20 +01:00
Ludovic Marie fdb763d5e3 Merge pull request #2493 from carlosnunez09/fix/end-gateway-exit
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:16 +01:00
Ludovic Marie 9aca5c35ca Merge pull request #2414 from mahirsn/fix/sign-editor-update
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:12 +01:00
Ludovic Marie a16d84d15d Merge pull request #2298 from carlosnunez09/fix/leave-game-statistic
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head; a re-review was asked, with no new objection since. Proven in wave 5's union run (cg20 union C 5900ab3879 (main b6f18dc8f + 57 PRs) Mac mini 122/122 delta harness).
2026-10-27 12:16:09 +01:00
Ludovic MarieandClaude Opus 5.5 35fddca7e6 refactor: the options screen's Opt enum lives in settings.rs
graphics_ui named options_ui::Opt for its rows, which made it depend on
options_ui, one of the edges that keep the client lib in a single cycle.
The enum (the option ids) moves to settings.rs; options_ui re-exports it
and keeps its methods. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 90a3fc82aa refactor: HealthRules lives in world.rs
World holds the health rules but they were defined in health.rs, which made
world depend on health, one of the edges that keep the client lib in a
single cycle. The plain data type (and its Default) moves to world.rs and
health re-exports it. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 84c631a88b refactor: the villager Poi enum lives in poi_sections.rs
World's POI sections are typed by mobs::villager_brain::Poi, which made
world depend on mobs, one of the edges that keep the client lib in a single
cycle. The enum moves to poi_sections.rs; villager_brain re-exports it and
keeps its methods. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:36 +01:00
Ludovic MarieandClaude Opus 5.5 83bdb31eea refactor: SavedProto lives in chunk.rs
streaming's ChunkGenerator trait named save::SavedProto, which made
streaming depend on save, one of the edges that keep the client lib in a
single cycle. The plain data type moves beside ChunkExtras in chunk.rs and
save re-exports it. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:15:35 +01:00
Ludovic Marie 8d1df095e7 Merge pull request #2521 from kacpherek/feat/energy-swirl
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Scroll and add the energy swirl of charged creepers and powered withers. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:13:48 +01:00
Ludovic Marie 1ac76ac103 Merge pull request #2503 from kacpherek/fix/particles-option-level
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Make the Particles option thin all particles, explosion debris and rain splashes. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:13:43 +01:00
Ludovic MarieandClaude Opus 5.5 4a82c014bb Merge main into fix/server-mob-animation-sync
Brings main in so the branch has one merge base with it (GitHub saw a
conflict through the criss-cross bases; the merge itself is clean).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:08:05 +01:00
Ludovic Marie cd4ff3828b Merge pull request #2555 from kacpherek/fix/2546-bubble-column-entities
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Bubble columns lift and drag mobs, items and ender pearls. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:07:29 +01:00
Ludovic Marie 2a6b5836a9 Merge pull request #2513 from kacpherek/fix/desert-leaves-ambient
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the sand idle and poplar leaves ambient sounds. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs.
2026-10-27 12:07:25 +01:00
Ludovic MarieandClaude Opus 5.5 380c40562d Merge main into fix/dragon-kill-credit
main's dragon hurt sound resets ambient_time, so the dragon is borrowed
mutably there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:05:03 +01:00
Ludovic MarieandClaude Opus 5.5 f010708103 Merge main into fix/immediate-respawn
throw_from_eyes takes thrown_from_hand now (main): the cursor stack is
dropped with drop(carried, false).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:03:59 +01:00
Ludovic Marie 9c62aba384 Merge pull request #2729 from ludovic111/refactor/cut-pack-pattern
🤖 Agent: verify passed (after merging main for the rabbit-arm clippy fix). Move only: the pack.mcmeta filter regex goes from packs/pattern.rs to horizon-client-core (re-exported), cutting atlas_sources -> packs (step A). pr_ready --quick green (1352 tests). Ready to merge once parity is green.
2026-10-27 11:59:25 +01:00
Ludovic MarieandClaude Opus 5.5 7d08cdf860 Merge main into fix/mob-ambient-particles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:59:23 +01:00
Ludovic Marie c6c2ec5f9f Merge pull request #2744 from ludovic111/refactor/cut-chunk-extras
🤖 Agent: verify passed. Move only: ChunkExtras and RolledLoot go from save.rs to chunk.rs (re-exported from save), cutting chunk -> save (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-27 11:59:22 +01:00
Ludovic MarieandClaude Opus 5.5 01e6dae6f7 Merge main into fix/shears-wear
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 ede89f4d37 Merge main into fix/mace-smash-knockback-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:38 +01:00
Ludovic MarieandClaude Opus 5.5 033d704ac5 Merge main into fix/cat-spawner
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:58:25 +01:00
Ludovic MarieandClaude Opus 5.5 fec49b4482 Merge origin/main into fix/death-and-hurt-effects
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 86e5db17b5 Merge origin/main into fix/container-open-sound-pitch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:01 +01:00
Ludovic MarieandClaude Opus 5.5 9f04b02e12 Merge origin/main into fix/brewed-potion-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:57:00 +01:00
Ludovic MarieandClaude Opus 5.5 0239df6f9d Merge origin/main into fix/fishing-rod-hooked-trigger
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:56:59 +01:00
Ludovic Marie de1a7b6954 Merge pull request #2482 from levzzz5154/fix/piston-entity-push
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:54:19 +01:00
Ludovic Marie bcd92633ac Merge pull request #2708 from ludovic111/refactor/cut-model-library-test
🤖 Agent: verify passed. A test move only: the item_render error-order check goes from model_library's test to item_render's tests, cutting model_library -> item_render (step A of the cycle plan). pr_ready --quick green (1316 tests). Ready to merge.
2026-10-27 11:49:41 +01:00
Ludovic Marie f065d0d11d Merge pull request #2537 from kacpherek/feat/glowing
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw entity outlines for the Glowing effect. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 11:49:37 +01:00
Ludovic MarieandClaude Opus 5.5 32a7a4c730 Merge origin/main into fix/death-and-hurt-effects
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:56 +01:00
Ludovic MarieandClaude Opus 5.5 1b9f8cab58 Merge origin/main into fix/container-open-sound-pitch
Resolved as in contrib-gate-21's union of the kacpherek PRs: both sides kept.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:48:36 +01:00
Ludovic MarieandClaude Opus 5.5 95000c906a Merge origin/main into fix/fishing-rod-hooked-trigger
advancements.rs keeps main's trade trigger and the PR's fishing rod trigger.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:46:59 +01:00
Ludovic Marie 512545ade7 Merge pull request #2554 from kacpherek/fix/2549-mob-water-push
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Mobs are pushed by flowing water and lava. I checked it against vanilla 26.3, merged main in, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 11:44:09 +01:00
Ludovic Marie 33afb63acb Merge pull request #2313 from socksprox/fix/player-head-textures
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:42:39 +01:00
Ludovic Marie 26b172eb1d Merge remote-tracking branch 'origin/main' into refactor/cut-pack-pattern 2026-10-27 11:42:20 +01:00
Ludovic Marie ac42f11d7a Merge pull request #2280 from kacpherek/fix/elytra-firework-trail
🤖 Agent: Read against the vanilla 26.3 sources (Firework rockets: smooth flight and spark trail on a server) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:49 +01:00
Ludovic Marie 3690dba775 Merge pull request #2260 from kacpherek/fix/dragon-ambient-sound
🤖 Agent: Read against the vanilla 26.3 sources (Give the Ender Dragon its ambient sound) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:45 +01:00
Ludovic Marie 7f01cb4f10 Merge pull request #2255 from kacpherek/fix/camel-ai-speeds
🤖 Agent: Read against the vanilla 26.3 sources (Walk, follow and flee at the camel's real AI speeds) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:37:42 +01:00
Ludovic MarieandClaude Opus 5.5 627ab546b9 Merge origin/main into fix/remote-entity-events (picks up main's rabbit-arm fix, #2716)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:37:27 +01:00
Ludovic MarieandClaude Opus 5.5 751f242824 Merge origin/main into fix/rain-sound-vanilla
The rain sound stays where the PR moved it (tickRainParticles, from the
splash loop's random); main's copy in the tick is dropped. Main's Horizon
Weather gate covers the whole block, so the option still silences the rain.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:36:13 +01:00
Ludovic Marie 6817cb4af0 Merge pull request #2253 from kacpherek/fix/item-used-on-block-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire item_used_on_block for block transforms, waxing, jukebox discs and vault keys) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:35:01 +01:00
Ludovic Marie c2fb0b6730 Merge pull request #2249 from kacpherek/fix/copper-golem-struck-by-lightning
🤖 Agent: Read against the vanilla 26.3 sources (Lightning takes a copper golem back one oxidation step) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:58 +01:00
Ludovic MarieandClaude Opus 5.5 6a56f7e39f Merge origin/main into fix/remote-entity-events
remote.rs keeps both the PR's entity-event particles (20, 6/7/12/13/14/18, 3)
and main's drowning event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:25:46 +01:00
Ludovic MarieandClaude Opus 5.5 f2070cef54 Merge main into fix/chat-links
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:18:01 +01:00
Ludovic MarieandClaude Opus 5.5 648060047c Merge main into fix/particles-option-level
main moved the cloud's client tick into cloud_client_tick; its particles
stay always visible (addAlwaysVisibleParticle).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:55 +01:00
Ludovic MarieandClaude Opus 5.5 92f33c89fb Merge main into fix/2546-bubble-column-entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:27 +01:00
Ludovic MarieandClaude Opus 5.5 bcb539057d Merge main into fix/desert-leaves-ambient
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:26 +01:00
Ludovic MarieandClaude Opus 5.5 e2b35bc827 Merge main into feat/energy-swirl
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:17:25 +01:00
Ludovic MarieandClaude Opus 5.5 8601d34f20 Merge main into fix/particles-option-level
The shulker's teleport particles (event 2016, from main) take the
engine's own particle level like the other block_teleportation calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:13:01 +01:00
Ludovic Marie d812712ded Merge remote-tracking branch 'origin/main' into HEAD 2026-10-27 11:12:50 +01:00
Ludovic MarieandClaude Opus 5.5 ce94056986 Merge main into fix/chat-links
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:12:06 +01:00
Ludovic MarieandClaude Opus 5.5 d50a5e1f64 refactor: ChunkExtras and RolledLoot live in chunk.rs
The chunk stores them, but they were defined in save.rs, which made chunk.rs
depend on save, one of the edges that keep the client lib in a single cycle.
They move to chunk.rs and save re-exports them. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:11:39 +01:00
Ludovic MarieandClaude Opus 5.5 86d90c38a9 refactor: the pack filter regex lives in horizon-client-core
packs/pattern.rs is self-contained; atlas_sources reached into packs for it,
one of the edges that keep the client lib in a single cycle. It moves to
horizon-client-core and packs re-exports it under its old path. No
behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:34:22 +01:00
Ludovic MarieandClaude Opus 5.5 ae713f7bd8 refactor: item_render's error-order check lives in item_render's tests
model_library's test ended with a check of ItemRenderer::load_with_library,
which made model_library depend on item_render, one of the edges that keep
the client lib in a single cycle. The check moves to item_render's tests
with its own fixture. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:27:51 +01:00
Ludovic MarieandClaude Opus 5.5 2ce21900ae Merge main into fix/server-mob-animation-sync
main's ease_peek and onPeekAmountChange; client_tick eases with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:57 +01:00
Ludovic MarieandClaude Opus 5.5 03cbb48fa4 Merge main into feat/glowing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:15:25 +01:00
Ludovic MarieandClaude Opus 5.5 42cff7bbc2 Merge main into fix/2549-mob-water-push
main's jumpOutOfFluid (isFree with containsAnyLiquid and the shulker
collision) replaces this branch's is_free.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:13:52 +01:00
Ludovic MarieandClaude Opus 5.5 712306d3b9 Merge main into #2130 (launcher: the args field stays a LineEdit beside main's proxy field)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:39:43 +01:00
Ludovic MarieandClaude Opus 5.5 ea5f288a1f Merge #2510 into fix/2546-bubble-column-entities
Both change the same lines; this head carries #2510's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:56 +01:00
Ludovic MarieandClaude Opus 5.5 c7c890dd69 Merge #2526 into fix/server-mob-animation-sync
Both change the same lines; this head carries #2526's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 62b8f33671 Merge #2517 into fix/server-mob-animation-sync
Both change the same lines; this head carries #2517's, so it merges after it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:06:53 +01:00
Ludovic MarieandClaude Opus 5.5 d77569c672 The dragon's ambient pitch draws from its own random
LivingEntity.getVoicePitch uses this.random, the same random the
ambient roll draws from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:35 +01:00
Ludovic MarieandClaude Opus 5.5 937dcba5b8 A TNT minecart's fuse blast uses its horizontal speed only
MinecartTNT.tick calls explode(getDeltaMovement().horizontalDistanceSqr())
when the fuse runs out; the vertical-speed branch has no vanilla source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:05 +01:00
Ludovic MarieandClaude Opus 5.5 36f6ab9839 Infested, Weaving and Wind Charged draw from the bearer's random
InfestedMobEffect.onMobHurt, WeavingMobEffect.spawnCobwebsRandomlyAround
and WindChargedMobEffect.onMobRemoved all use mob.getRandom(): a mob's
own random, the world's for the player as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:59:59 +01:00
Ludovic MarieandClaude Opus 5.5 d3b28edfb6 Merge main into fix/death-and-hurt-effects
The Infested event stays immediate (onMobHurt runs in hurtServer); the
other events a hit raises are left in the outbox for the end of the
tick, as on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:58:20 +01:00
Ludovic MarieandClaude Opus 5.5 9c49424b4d Merge main into fix/wither-rose-kills
A blast's attacker is the projectile's owner, else main's exploding mob
(getIndirectSourceEntity); both CombatTracker fields kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:57:29 +01:00
Ludovic MarieandClaude Opus 5.5 f4271d7c16 Merge #2492 into the energy swirl branch
#2492 takes overlay code 32 for translucent item quads (33 with glint): the
energy swirl moves to 34 and is the fifth of eight entity materials.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 6a65ee1124 A guest saved by a totem is not a player kill
The guest's health goes through the same totem path as the host's
(applied on the next tick_totem), so the kill check skips a fired totem
for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:46 +01:00
Ludovic MarieandClaude Opus 5.5 ad70f755d7 Merge main into fix/stats-combat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:43 +01:00
Ludovic MarieandClaude Opus 5.5 d2ca443d6f Merge main into fix/mob-effect-merge
Keeps this branch's add_effect (infinite durations, isShorterDurationThan)
over main's similar fix, and main's hidden_effects cleanup with infinite
effects kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:05 +01:00
Ludovic MarieandClaude Opus 5.5 78dba05d8f Merge #2516 into fix/desert-leaves-ambient
Both change Animate::Falling: suspicious sand and gravel take the new flag
false (BrushableBlock.animateTick drops dust only; the desert sound is
SandBlock's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:16 +01:00
Ludovic Marie 0e927e6f11 Merge commit 'refs/cg19/pr/2516' into HEAD 2026-10-27 06:54:01 +01:00
levzzz 2eb96ddb7c Merge remote-tracking branch 'upstream/main' into fix/piston-entity-push 2026-10-27 10:53:49 +05:00
Ludovic MarieandClaude Opus 5.5 393b8ef7e6 Merge main into fix/player-hurt-entity-trigger
The trident's PLAYER_HURT_ENTITY now keys off main's hurt_at (kept for
Channeling).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:53:29 +01:00
Ludovic MarieandClaude Opus 5.5 15be176380 Merge main into fix/fishing-rod-hooked-trigger
Keeps the hooked trigger with main's item-entity damage (3) and main's
fish_caught per #fishes catch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:52:43 +01:00
Ludovic MarieandClaude Opus 5.5 8905aaf0dd Merge main into fix/armadillo-scutes
Keeps this branch's ShedScute event and main's GiftLoot (with its mob
and the turtle's mob_drops check).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:51:37 +01:00
Ludovic MarieandClaude Opus 5.5 bbc1ffb408 Merge main into fix/invisibility-mob-detection
Keeps both PlayerView methods: visibility_percent and main's has_effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:47:27 +01:00
Ludovic MarieandClaude Opus 5.5 4c78d32e36 Merge main into fix/explosion-beehive-bees
Keeps main's ExplosionSource event and charged_exploders, and this
branch's Species on Explode and hive_emptying_blast.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:09 +01:00
Kacpherek e5e1b74fd8 Draw the enderman portal trail, phantom wing dust and bee nectar drips 2026-10-27 06:44:37 +01:00
Ludovic MarieandClaude Opus 5.5 9809bf38b6 Merge main into fix/remote-hurt-tilt
indicateDamage now uses main's knockback direction, which is zero when
the hit has no source position (as in dealDefaultKnockback).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 c752c21246 Merge main into fix/remote-entity-events
Keeps both entity-event handlers in remote.rs: this branch's events 20,
6/7/12/13/14/18 and 3, and main's honey slide (53).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:42:22 +01:00
Kacpherek 2f6d259615 Put books into a chiseled bookshelf and take them out
Horizon only kept a chiseled bookshelf's saved data, so using it did
nothing. It is now a block entity with six slots and lastInteractedSlot:

- ChiseledBookShelfBlock.useItemOn / useWithoutItem: the slot comes from
  the hit position on the front face (getHitSlot, 3x2). A #bookshelf_books
  item goes into an empty slot (one book, the insert sounds, the used
  statistic), a book comes out to the inventory or the ground, with the
  pickup sounds. The slot_N_occupied states follow
  (ChiseledBookShelfBlockEntity.updateState) and reach a server's clients
  with the block update; the client's prediction ends the click the same way.
- Hoppers and droppers use its container: books only, one to a slot, and the
  occupied state and last slot follow what they moved.
- The comparator reads lastInteractedSlot from the block entity.
2026-10-27 06:38:17 +01:00
Ludovic MarieandClaude Opus 5.5 6c769f38ae Merge main into fix/server-mob-animation-sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:58 +01:00
Ludovic MarieandClaude Opus 5.5 475f64b79d Merge main into fix/end-gateway-exit
block_entity_nbt moved into horizon-client-core (crate split): save.rs
reads gateway_exit across the crate boundary, so it is public. The
generated block entities now load through decode_block_entity, which
reads the gateway's exit with this PR's decode_end_gateway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:09 +01:00
Kacpherek b22c42ea85 Stop jockey spiders attacking and shove hoglin targets like vanilla 2026-10-27 06:34:37 +01:00
Ludovic MarieandClaude Opus 5.5 a21cf57a11 Merge main into fix/desert-leaves-ambient
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:01 +01:00
Kacpherek 6c36da40e0 Turn skeletons into strays after standing in powder snow 2026-10-27 06:33:50 +01:00
Ludovic MarieandClaude Opus 5.5 765cef8a4e Put the candle's doc comment back on candle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:56 +01:00
Ludovic MarieandClaude Opus 5.5 79fca90886 Tame a pet to the player's real UUID, not an unset placeholder
acting_uuid() falls back to [0, 0, 0, 0] while level.player_uuid is unset (a new
world before its first save); the owner saved then never matches the UUID the
world gets, so the player could no longer command the pet after a reload.
acting_player_uuid() assigns the world's player UUID first, as dropped items do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:42 +01:00
Ludovic MarieandClaude Opus 5.5 03d8ffe2c6 Sign editor: send the lines as edited, sign or not
AbstractSignEditScreen.setMessage writes messages[line] and the sign;
removed() sends messages. The editor keeps its lines up to date, so a
sign removed while it is open still gets the player's text (review by
rdbt's agent).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:22:28 +01:00
Kacpherek ff28fd8390 Read a chiseled bookshelf's last slot and a shelf's back mask with a comparator 2026-10-27 06:13:40 +01:00
Kacpherek a19cad22cb A mob jumps out of a fluid only where Entity.isFree allows: no liquid in the box 2026-10-27 05:51:10 +01:00
Ludovic MarieandClaude Opus 5.5 36c18fd8ea Merge main into fix branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:38:30 +01:00
Ludovic MarieandClaude Opus 5.5 c352c80c96 Books: save a plain page as a string, as ComponentSerialization does
Pages kept as components for their click events wrote a plain text page as
{text:"p"}; vanilla's codec writes a component with no style or siblings
as its string (the hotbar harness).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:11:32 +01:00
levzzzandminimax-m3.1-flash 9ab0e2b6a8 Cover the slime block's push and bounce in the LAN harness
A piston pushing a slime block moves the body in front of it and bounces
it: the block's swept collision shape pushes the body out of the way, and
the slime block sets the body's velocity along the push axis
(`PistonMovingBlockEntity.moveCollidedEntities`, the `causeBounce` branch).
The new `piston-slime` LAN case checks both for the host and the guest, with
a plain block as the control: a plain block only pushes the body 0.81
blocks, the slime block pushes and bounces it 2.5.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:08:40 +05:00
Joansitoh 9cebcc8d7f Launcher: a paste keeps the spaces at its ends
Pasting " --world Test" after an argument lost its leading space and
joined the two arguments. The first line of the clipboard is inserted as
it is.
2026-10-27 00:59:55 +01:00
levzzzandminimax-m3.1-flash 6384234e37 Stop a piston push at the world border
`move_pushed` resolved the pushed movement with `SceneCollisions`, which
supplies no world-border collision, so a piston next to the border could push
a player through it even though ordinary movement stops at the border.
`Entity.move` collects the border's walls in
`collectCollidersIgnoringWorldBorder` whenever
`WorldBorder.isInsideCloseToBorder` holds, and `CollisionGetter.borderCollision`
adds `WorldBorder.getCollisionShape` to them. The push now resolves through
`EntityCollisions::with_border`, which applies that same rule to the pushed
body's own x and z, so a body pushed into the border's wall stops there.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:32:16 +05:00
Carlos Nunez 3caf28c4d6 Merge remote-tracking branch 'upstream/main' into tmp/merge-2298
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 19:27:09 -04:00
Kacpherek 2c894783aa Bubble columns lift and drag mobs, items and ender pearls 2026-10-26 23:37:14 +01:00
Kacpherek 9266e0c7c9 Mobs are pushed by flowing water and lava 2026-10-26 23:34:03 +01:00
Kacpherek 909f15b909 Count the warden tendril and heart animations down again 2026-10-26 23:22:03 +01:00
masa-masa-0314andZCode 66607c9eab Items: glint enchanted armour on armour stands
ArmorStandRenderer draws worn armour through the same
HumanoidArmorLayer as a living entity, so its pieces should use the
armour glint when ItemStack.hasFoil() is true. The stand's equipment
loop now checks the stack's enchantment_glint_override component, then
the item's default override, then its enchantments component (vanilla's
Item.isFoil), and draws both the armour and its trim layer with overlay
13. The existing ARMOR_GLINT path supplies the model UV and samples
enchanted_glint_armor through ARMOR_ENTITY_GLINT_TEXTURING.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 06:14:36 +08:00
Kacpherek e544924504 Draw entity outlines for the Glowing effect
Glowing entities are drawn as team-coloured silhouettes into an outline
target with no depth test, then vanilla's entity_outline chain (sobel,
box blur across and up) is blended over the frame after the hand.
Mobs, players, item entities and projectiles get outlines. The server
sends shared flag 6 for mobs and items, and the remote client reads it.
2026-10-26 23:03:39 +01:00
masa-masa-0314andZCode 43e407562f Items: animate the armour glint from enchanted_glint_armor
Enchanted worn armour lost its animation: mobs/render.rs gave every
armour vertex [0, 0] for glint_uv, while the entity shader samples the
glint at that coordinate for overlay 7, so the armour showed a single
texel instead of the scrolling pattern.

Armour layers now carry overlay 13 (ARMOR_GLINT) and the layer's model
UV. entity.wgsl applies ARMOR_ENTITY_GLINT_TEXTURING (scale 0.16) and
samples misc/enchanted_glint_armor through the same repeating, linear
GlintSampler as items, matching ARMOR_CUTOUT_NO_CULL_GLINT. The scroll
clock (millis * glintSpeed * 8 modulo the offsets' common period) rides
in globals.time.w, so armour and items move together.

Addresses the review comment on #2296.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 05:54:10 +08:00
Kacpherek a45d0de43e Server-thread clients animate warden, sniffer, breeze, camel and shulker
A client of a server (single player's window included) never got the
animation state these mobs compute on the server: no pose, no sniffer
state, no shulker peek or wall face, no camel sit or dash, no warden
tendril, attack or sonic boom events.

Send Entity.DATA_POSE and the species' synched data from mob_meta,
broadcast the warden's entity events 4, 61 and 62, read both in the
remote mob code, and run the client animation steps in
mobs::sync::client_tick.
2026-10-26 22:48:27 +01:00
Kacpherek f6db8b1f56 Scroll and add the energy swirl of charged creepers and powered withers 2026-10-26 22:44:16 +01:00
Kacpherek 426b3c83cf Play the sand idle and poplar leaves ambient sounds 2026-10-26 22:40:47 +01:00
Kacpherek a146754b0b Make the Particles option thin every particle, explosion debris and rain splash
The Particles option only changed two effects. Follow ClientLevel.doAddParticle
and calculateParticleLevel in the one add path: Decreased drops a third, Minimal
drops all but the always-visible and limiter-overriding particles. Explosion
block debris is thrown only on All, and rain splashes halve on Decreased and
stop on Minimal.
2026-10-26 22:28:10 +01:00
levzzz bafd010865 Merge remote-tracking branch 'upstream/main' into fix/piston-entity-push 2026-10-27 02:22:04 +05:00
Kacpherek 8f78fdeb7b Keep leash knot ids per level and clear remote knots with the other remote objects 2026-10-26 22:08:35 +01:00
Kacpherek d2a8cf3bdf Keep a hurt pet's attacker as its player instead of swapping in the owner 2026-10-26 22:05:02 +01:00
Kacpherek ed9fdb5208 Point an explosion's hurt tilt at the blast 2026-10-26 22:04:40 +01:00
Ludovic MarieandClaude Opus 5.5 2929bb6dba open_url: accept the http and https schemes in any case
Util.parseAndValidateUntrustedUri lowercases the scheme before checking it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Kacpherek 6120c8c0ad Attack the block when a guest's START_DESTROY_BLOCK arrives 2026-10-26 22:03:17 +01:00
Kacpherek 53ea3e7224 Play the rain sound for this client only 2026-10-26 22:00:04 +01:00
Kacpherek 462787aee1 Keep the Infested event when a mob is hit by a player or another mob 2026-10-26 21:58:25 +01:00
Carlos NunezandClaude Sonnet 5.5 b90f9ec1ef Make an End gateway's block entity the source of its exit
The side file kept an entry for a gateway after Horizon created its exit,
so removing the exit with /data remove block ... exit_portal, or placing
a different gateway there, sent the player to the old exit again.

Once the gateway's chunk is loaded, gateway_job moves an entry the side
file still holds into the block entity (unless the block entity has an
exit of its own) and drops the entry. set_gateway_exit writes only the
block entity when the chunk is loaded, and leaves an entry in the side
file only when the chunk is already unloaded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:57:07 -04:00
Kacpherek 77fe538e09 Let only the owner command a tame wolf or cat, and make pets follow their owner
Sitting, standing, dyeing and healing a cat or wolf now check the saved
owner. The follow, teleport and owner-hurt goals use the owner, not the
nearest player, and a new tame wolf or cat is owned by whoever tamed it.
2026-10-26 21:48:05 +01:00
Kacpherek d8b8246622 Light redstone ore under dropped items too 2026-10-26 21:46:31 +01:00
Kacpherek 35a02c79a8 Cats also skip chests a copper golem has open 2026-10-26 21:46:18 +01:00
Kacpherek db985f717c Spectator crosshair also checks the other half of a double chest 2026-10-26 21:46:04 +01:00
Kacpherek e41c602359 Keep picking up a banner next to a raider even when its path was cached as unreachable 2026-10-26 21:46:00 +01:00
Kacpherek b33ae69938 Find the End podium level from its rim so a broken centre column is fine 2026-10-26 21:43:08 +01:00
951b88a3c2 Read an End gateway's exit from its block entity
Horizon had no End gateway block entity: game/end.rs kept each gateway's
exit in its own side file and gateway_job only looked there, so a
gateway set with /setblock end_gateway{exit_portal:[I;..],ExactTeleport:1b}
(or the exit worldgen writes) was ignored, and /data get block said the
block was not a block entity.

TheEndGatewayBlockEntity stores Age, exit_portal and ExactTeleport.
BlockEntity::EndGateway now holds them with vanilla's NBT and the
spawnable-bounds check. gateway_job reads the block entity's exit first
and keeps the side file and the 512-block End-spawn rule as fallbacks,
so older saves and gateways without data behave as before. The exits
Horizon creates are stored in the block entity as well, a new exit keeps
the gateway's own ExactTeleport (setExitPosition), and a command that
only changes a block entity's NBT now marks its chunk modified so the
change is saved.

Fixes #1517.

Implementation by Codex (GPT-6.1 Sol), reviewed and extended by Claude.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 16:36:53 -04:00
Kacpherek 414a2b82a8 Do not rebuild the End exit podium once the fight has its location 2026-10-26 21:30:57 +01:00
Kacpherek 9958fe0e49 Light redstone ore when an entity stands on it 2026-10-26 21:29:26 +01:00
Kacpherek cdafcd756f Open links and page links in written books 2026-10-26 21:18:05 +01:00
Kacpherek 3fe301aaa1 Record the dragon's attacker before the crystal blast and only for applied damage 2026-10-26 21:17:18 +01:00
levzzzandCodex dca6ebba0e Merge upstream main into piston branch
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:15:46 +05:00
levzzzandCodex 6af81b600f Push entities with moving pistons
Move players, mobs and common objects when piston block entities advance, applying collision and the per-tick piston limit. Exercise host, guest, inventory-open and mob pushes in both single-player paths, and compare the push geometry with vanilla.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-27 01:01:31 +05:00
Kacpherek 851789830a Open chat links from open_url click events 2026-10-26 20:44:17 +01:00
masa-masa-0314andZCode Agent 317f30427e Superflat: keep an empty layer list on Re-Create
FlatSettings::from_tag rejected layers: [], so a world with every layer removed fell back to the classic bedrock/dirt/grass settings when re-created. An empty saved list is a valid void flat generator; a non-empty list whose entries all fail to parse is still rejected.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
kohad1ckandZCode Agent 91f1f839ab Superflat: the Customize screen edits layers and presets
Create New World greyed out Customize for a Superflat world because Horizon
had no preset editor. Bring vanilla's CreateFlatWorldScreen and
PresetFlatWorldScreen:

- the layer list (top first), Remove Layer and the layer heights;
- the preset chooser with the nine visible flat_world_preset entries, their
  display items and names;
- the share box and Select, parsing layers;biome and validating block and
  biome ids against the prepared registries;
- Done keeps the layers in WorldOptions, Cancel and Escape discard them.

world_loading writes them with write_world_gen_settings_with, so a new
world's world_gen_settings keeps the custom generator; Re-Create reads them
back for the editor.

Co-Authored-By: ZCode Agent <[email protected]>
2026-10-27 03:25:05 +08:00
masa-masa-0314andZCode 1c6c6c0b97 Items: sample the glint from its own repeating texture
Vanilla's GlintSampler repeats enchanted_glint_item per pixel, and the
GLINT transform's scale 8 spans about 8 repeats across an item. Folding
the UV per vertex into the shared atlas could only interpolate between
the four corners, so the pattern rendered as one wide diagonal band
instead of the texture's fine weave. The glint now binds as its own
128x128 texture with a repeat, linear, mip-free sampler (the texture's
mcmeta: clamp false, blur true) in the HUD and entity pipelines, and
`Glint` passes the unwrapped `GLINT_TEXTURING` UV through.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:11:47 +08:00
masa-masa-0314andZCode a1761dcb65 Lightning: move the bolt offset to vertex attribute 7
The retained bolt offset and the entity vertex's new glint UV were both
declared at attribute location 6, and the lightning pipeline builds its
vertex buffers from the entity layout plus the bolt-offset instance
buffer, so Device::create_render_pipeline rejected the location clash
and the client crashed at startup.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
masa-masa-0314andZCode cf6b91a710 Items: draw the enchantment glint from enchanted_glint_item
Horizon invented the glint: the GUI drew sine bands in screen space and
the entity shader layered the same over held and dropped items. Vanilla
26.3 samples `misc/enchanted_glint_item` through `GLINT_TEXTURING`
(`item.vsh`/`item.fsh`, `TextureTransform.setupGlintTexturing`), so
items now carry the real texture:

- `ItemQuad.foil_uv` keeps the model UV the glint samples; `Glint` turns
  it into the atlas UV with the 26.3 transform (translation(-o0, o1) *
  rotateZ(PI/18) * scale) and `WorldSink` gives foil quads overlay 7 and
  the per-corner glint UV.
- The GUI glint is a separate additive HUD pass masked by the item's
  texture alpha (`item.fsh`'s ALPHA_CUTOUT 0.1 and GLINT path).
- `entity.wgsl` samples the glint at overlay 7/8 like vanilla's
  `color.rgb += glintColor.rgb * glintColor.rgb`.
- The Accessibility options Glint Speed and Glint Strength work: both
  sliders persist to `options.txt` (they were hidden placeholders), the
  GUI pass takes them per draw, and `Frame.glint_alpha` feeds the entity
  shader every frame instead of whatever the last HUD item set.
- GUI items use `GLINT_TEXTURING`'s scale 8, world items
  `ENTITY_GLINT_TEXTURING`'s scale 0.5.

Co-Authored-By: ZCode <[email protected]>
2026-10-27 03:04:05 +08:00
mahirsn 2b2aecd975 fix(client): send sign editor text like vanilla
A new editor or a removed sign closes the open editor (Minecraft.setScreen, AbstractSignEditScreen.tick), and closing sends ServerboundSignUpdatePacket with the lines it opened with when the sign is gone. Sign lines resolve key binds and translations (Component.getString) instead of keeping only plain text.
2026-10-26 21:49:15 +03:00
Kacpherek 8aad511579 Drop the cursor stack when the dead player respawns 2026-10-26 19:46:01 +01:00
Kacpherek 752138e866 Cats sit on a chest only while nobody has it open, and on a bed's foot only 2026-10-26 19:43:27 +01:00
Kacpherek c74626448c Show the crosshair to spectators over containers, stations and portals 2026-10-26 19:40:06 +01:00
Kacpherek 10d2b202d5 Push the other players too with a mace smash 2026-10-26 19:35:53 +01:00
Kacpherek 97f8bca9da Raiders skip ominous banners they cannot reach
A captainless raider walked towards the nearest fallen ominous banner
even when no path led to it. Check that a path reaches the banner before
going for it, and skip a banner without one for 600 ticks, like vanilla.
2026-10-26 19:32:41 +01:00
Kacpherek 484cc945aa Add the rockets list to the remote motion test fixture 2026-10-26 19:23:52 +01:00
Kacpherek ab40cd63e6 Merge origin/main into fix/remote-leash-knots 2026-10-26 19:20:16 +01:00
Kacpherek 00f2663e7b Merge origin/main into fix/harvest-full-beehive 2026-10-26 19:19:20 +01:00
Kacpherek e6587dce6a Respawn at once without a death screen when immediate_respawn is on 2026-10-26 19:17:05 +01:00
Carlos Nunez d75f767925 Merge remote-tracking branch 'upstream/main' into tmp-resolve-2298 2026-10-26 14:13:17 -04:00
Kacpherek a45c73bb36 Credit the player who kills the ender dragon 2026-10-26 19:12:49 +01:00
Kacpherek 91d6bdba85 Fly firework rockets on by themselves between server updates 2026-10-26 19:11:21 +01:00
Kacpherek 4248048ab6 Merge origin/main into fix/elytra-firework-trail 2026-10-26 19:08:50 +01:00
Kacpherek 2277c85fc3 Run neighbour updates when a mob trampling farmland places dirt 2026-10-26 19:07:02 +01:00
Kacpherek b32989cf1e Wear shears on mooshrooms and apply Unbreaking to held tool wear 2026-10-26 18:49:13 +01:00
Kacpherek fece9d85e3 Spawn village and swamp hut cats 2026-10-26 18:48:18 +01:00
Kacpherek 7569f9615a Treat unbreakable items as undamageable in the anvil and grindstone 2026-10-26 18:47:58 +01:00
Kacpherek 90501f9ab4 Keep leashed mobs and carrying endermen from despawning
Mob.requiresCustomPersistence also covers a leashed mob and an enderman holding a block, and the spawn caps skip such mobs. Gather the existing cases in one helper, add the missing ones, and use it for both the despawn check and the cap count.
2026-10-26 18:46:46 +01:00
Kacpherek c1844dfe57 Let mobs trample farmland when they land on it
A cow dropped onto farmland left it untouched. A landing mob now turns
farmland to dirt with chance fall distance - 0.5, when mob griefing is on and
the mob is big enough, and is lifted onto the dirt.
2026-10-26 18:46:37 +01:00
Kacpherek bf527e7a13 Apply knockback resistance to Punch arrows 2026-10-26 18:46:36 +01:00
Kacpherek dbef78bcb8 Apply Unbreaking to armor wear from Thorns
Thorns always cost the worn piece 2 durability. Run it through the same
durability loss as other wear so Unbreaking can cancel points.
2026-10-26 18:45:54 +01:00
Kacpherek 564b230ab1 Make a warden's hit disable the player's shield
A warden holds no weapon, so its blows never put the shield on cooldown. Warden.getSecondsToDisableBlocking is a flat 5 seconds, so use that when the attacker is a warden.
2026-10-26 18:45:13 +01:00
Kacpherek 555ddb94bf Use the impaling entity tag for thrown tridents
The bonus went to every swimming or water-breathing mob, including undead,
blazes, ghasts and iron golems. Read the damage from the enchantment data so
only #sensitive_to_impaling mobs take it.
2026-10-26 18:45:13 +01:00
Kacpherek e92937d956 Apply Slowness with Bane of Arthropods
Post-attack effects only handled ignite, so the apply_mob_effect effect of
Bane of Arthropods never did anything. Handle apply_mob_effect like vanilla.
2026-10-26 18:44:23 +01:00
Carlos Nunez d62a1e5d20 Merge remote-tracking branch 'upstream/main' into fix/leave-game-statistic
# Conflicts:
#	tools/lan_e2e.py
2026-10-26 13:33:28 -04:00
Carlos NunezandClaude Sonnet 5.5 1862395e4b Keep a leaving guest's play state until their statistics are saved
lan_crafting_left removed the guest's GuestPlay from host.play before
the leave_game award and save_guest_records ran, so a normal guest
disconnect awarded nothing and wrote no statistics. It now takes the
crafting table and workstation state out of the entry and leaves the
entry for HostEvent::Left to remove after saving.

New lan_e2e check `leave`: a guest leaves twice and the host's saved
statistics show leave_game 1, then 2. It fails on the previous commit
(0, then [0, 0]) and passes here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 13:32:44 -04:00
socksproxandDevin 077da2e06b Queue the first skin upload as well as re-uploads
The completed-download push into ready never marked the location queued,
so an again() before the next take_ready() put the same skin in twice:
store_skin allocated two slots, and evicting one removed the sprite while
show_skin still found the other entry — the skin never uploaded again.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:09 +01:00
Kacpherek a6d2eb9d64 Shulkers keep their body still while the head turns 2026-10-26 18:17:27 +01:00
Kacpherek 0300ad8922 Keep the current attack target regardless of invisibility 2026-10-26 18:13:33 +01:00
socksproxandDevin 7a3a19697a Resolve dynamic head profiles, and re-upload evicted skins
Player heads whose profile carries only a name or only a UUID
(ResolvableProfile.Dynamic) resolved to nothing, so heads some servers
send that way kept the default skin. Adds skins::Resolver
(ProfileResolver.Cached: profiles/getByName then
session/getProfileById through the services discovery document, on
background threads) used by placed heads, head items and mannequins; the
profile-derived DefaultPlayerSkin stands in until the download lands,
with the name's offline UUID keying it for name-only profiles.

A skin evicted from the atlas's 128 slots could never return: the decoded
image was dropped and show_skin simply failed. The downloader now keeps
the images (and the disk cache backs them), and again queues the upload
again where the tab list, level players, mannequins and heads notice the
missing sprite.

profile_tag keeps id so id-only and name+id profiles survive into the
item data JSON, and the player-head item fallback matches vanilla's
slim/steve (was wide/steve).

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:13:22 +01:00
Kacpherek e8ae26c270 Carry the exploding mob's kind with its blast and anger bees at guests too 2026-10-26 18:09:01 +01:00
Carlos NunezandClaude Sonnet 5.5 e50eb7c71f Count leave_game for every player who leaves, owner included
Vanilla's PlayerList.remove awards Stats.LEAVE_GAME to a player before
saving them, and PlayerList.removeAll does it for everyone when the
server stops. Horizon only counted it in the in-frame path, so on the
default server thread the owner never got it, and guests who left or
were removed at shutdown did not either.

LanHost::award_leave_game counts it once per guest (a guest is saved
more than once on the way out) and runs where a guest leaves, when the
integrated server stops, and when LAN closes.

Part of #2095 (item 4 of the report). The signal handling and the
remote-world disconnect are #2116's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 12:36:46 -04:00
Kacpherek b62095dfcb Run the trident player_hurt_entity trigger as the thrower 2026-10-26 17:25:37 +01:00
Kacpherek ba3b567f54 Predict a full beehive reacting to shears and glass bottles on the client 2026-10-26 17:15:29 +01:00
Kacpherek 5d3e9d218d Clean copper for a skeleton trap's visual lightning too 2026-10-26 17:05:35 +01:00
Kacpherek 36ef05b0b9 Keep infinite mob effects through the effect tick 2026-10-26 16:51:22 +01:00
Kacpherek 49ad61035b Count shield damage only for accepted hits and skip kills saved by a totem 2026-10-26 16:47:11 +01:00
Kacpherek 3842d851d8 Match the brewed_potion trigger's potion condition as a PotionsPredicate 2026-10-26 16:38:07 +01:00
Kacpherek b28108128d Keep a hopper minecart's items in sync with a leftover menu copy 2026-10-26 16:33:08 +01:00
Kacpherek c50557ea0b Credit the projectile owner for explosion damage to mobs 2026-10-26 16:29:05 +01:00
Kacpherek f364c4f9c6 Leave wandering traders out of the spawn egg baby check 2026-10-26 16:28:28 +01:00
Kacpherek 18f4dd92ae Play container open and close sounds only when the opener count crosses zero 2026-10-26 16:24:42 +01:00
Kacpherek 47654b62a3 Save the armadillo scute timer and reset it only when a scute can drop 2026-10-26 16:24:26 +01:00
Kacpherek 2f3dade42f Show the firework trail on a boosting rocket and hide its sprite
On a server-thread client an elytra boost rocket drew no spark trail and
showed up as a static item sprite. The client now follows the entity the
rocket is attached to, spawns the trail each tick like
FireworkRocketEntity.tick, and does not draw an attached rocket.
2026-10-26 16:19:53 +01:00
Kacpherek af49fd7dc3 Use a collision-shape raycast for the spyglass line-of-sight check 2026-10-26 16:07:14 +01:00
Kacpherek af6faa88e5 Report the hook's open-water flag to the fishing_rod_hooked trigger 2026-10-26 16:06:30 +01:00
Kacpherek e71d4281ac Spawn a baby when a spawn egg is used on its own mob kind
Mob.checkAndHandleImportantInteractions: a spawn egg of the clicked mob's
kind spawns a baby at the mob's position (SpawnEggItem.spawnOffspringFromSpawnEgg),
inheriting the parent's variant, and uses one egg outside creative.

Fixes #2269.
2026-10-26 16:04:35 +01:00
Kacpherek 7102727197 Play the container close sound when a server-thread player closes it 2026-10-26 15:24:26 +01:00
Kacpherek 70868f3208 Give Oozing, Infested, Weaving and Wind Charged their effects
These four 26.x effects only existed as potion entries. Oozing spawns two slimes, Weaving places cobwebs and Wind Charged makes a gust when the bearer dies. Infested sometimes releases silverfish when the bearer is hurt.
2026-10-26 15:20:34 +01:00
Kacpherek aff673a08d Give the Ender Dragon its ambient sound 2026-10-26 15:20:10 +01:00
Kacpherek 2e642857fc Fire the levitation and using_item advancement triggers 2026-10-26 15:20:03 +01:00
Kacpherek 147e5269c7 Play the rain sound the way vanilla picks it 2026-10-26 15:19:20 +01:00
Kacpherek b3a499d85d Play chest, barrel and shulker box sounds with a random pitch from the right spot 2026-10-26 15:18:05 +01:00
Kacpherek b913dab5e5 Walk, follow and flee at the camel's real AI speeds 2026-10-26 15:17:53 +01:00
Kacpherek 8c8f857313 Fire item_used_on_block when an axe, honeycomb, disc or vault key is used on a block 2026-10-26 15:17:42 +01:00
Kacpherek 9c427dfae0 Mobs killed by a wither leave a wither rose 2026-10-26 15:16:54 +01:00
Kacpherek ef711f8b1f Fire the brewed_potion trigger when a potion is taken from a brewing stand 2026-10-26 15:16:44 +01:00
Kacpherek 601e7918a8 Lightning scrapes one oxidation step off a copper golem
A copper golem hit by a bolt goes back one weathering step, and its
weathering timer (or wax) is reset.
2026-10-26 15:16:43 +01:00
Kacpherek dd3697bb4d Hopper minecarts take from the container above, one item per tick 2026-10-26 15:16:43 +01:00
Kacpherek 273729aac8 Count the combat statistics
damage_resisted, damage_absorbed and damage_blocked_by_shield now add up when
Resistance, absorption hearts or a shield take damage, and a player who lands
the killing blow on another player gets player_kills.
2026-10-26 15:16:34 +01:00
Kacpherek 53501ab94e Merge a mob's repeated effects like vanilla
A stronger effect now replaces the old one with its own duration, instead of taking the longer duration and the higher level separately.
2026-10-26 15:15:55 +01:00
Kacpherek 85a6ce0921 Lightning scrapes oxidation off copper
A struck copper block goes back to unoxidized, and 3 to 5 random walks
take one step of oxidation off the weathering copper around it. Waxed
blocks stay as they are but still start the walks.
2026-10-26 15:15:30 +01:00
Kacpherek 8691d9a8e8 Let players harvest a full beehive with shears or a glass bottle 2026-10-26 15:14:34 +01:00
Kacpherek 8d77913ea5 Note blocks play on a left click, and take mob heads on top
Mining a note block now plays its note and counts the play_noteblock
statistic. A mob head used on the top face is placed instead of tuning
the block.
2026-10-26 15:14:10 +01:00
Kacpherek 5fa423169f Fire player_hurt_entity and entity_hurt_player advancement triggers 2026-10-26 15:13:51 +01:00
Kacpherek 183382cd2b Fire the fishing_rod_hooked trigger when a hook is reeled in 2026-10-26 15:13:34 +01:00
Kacpherek 329ce1b724 Make armadillos shed scutes on their own
Armadillos never dropped scutes because nothing ran their shed timer.
Follow Armadillo.customServerAiStep: count down 5 to 10 minutes, then
drop a scute, play the sound and post the entity_place game event.
2026-10-26 15:13:27 +01:00
Kacpherek 2a0824cf5f Invisible players are harder for mobs to notice
Mobs now scale their detection range by 0.7 times the worn armor fraction (at least 0.1) when the player has Invisibility, like vanilla's getVisibilityPercent.
2026-10-26 15:13:15 +01:00
Kacpherek cc39d57e07 Explosions empty and anger the bees of destroyed hives 2026-10-26 15:12:42 +01:00
Kacpherek 7cc8def23f Let a lit TNT minecart explode when it hits a wall 2026-10-26 15:10:57 +01:00
Joansitoh 2f6f0e3e0f Launcher: Game arguments edits at the caret
The field only appended and popped the last character, and Space typed
nothing on Windows (it arrives without a character). A shared one-line
field, launcher/src/line_edit.rs, now holds the text, caret, selection
and undo history: arrows, Home/End, Ctrl+arrows by word, Shift to select,
mouse click, drag, double and triple click, Ctrl+A/C/X/V and Ctrl+Z / Ctrl+Y.
2026-10-26 13:16:35 +01:00
Kacpherek 4825e537cc Tilt a server player's camera towards the hit
The server never sent ClientboundHurtAnimationPacket, and the client
ignored the yaw it carried, so a player on a server got the flash but the
camera never tilted towards where the hit came from.

ServerPlayer.indicateDamage now runs where the hit's knockback is dealt
(and with no direction for hits that have none), the packet goes out when
the guest's turn ends, and the client keeps the yaw as Player.hurtDir for
GameRenderer.bobHurt.
2026-10-26 13:05:51 +01:00
Kacpherek 2e91bd84e9 Send villager, taming, love, spawn poof and snowball entity events to server clients
Single player on the server thread drew these particles only into the server's own engine. The server now sends the entity event byte where vanilla does (20, 12/13/14, 6/7, 18 and 3), and the client draws it in handleEntityEvent's way.
2026-10-26 13:05:47 +01:00
Kacpherek 3287a74d64 Send fence knots to server players so leads tied to fences are drawn 2026-10-26 13:00:40 +01:00
206 changed files with 12348 additions and 1775 deletions

No files matched your search

+274
View File
@@ -0,0 +1,274 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
on:
workflow_call:
inputs:
ref:
description: The commit to build
type: string
required: true
version:
description: The version built in (HORIZON_VERSION), e.g. 0.2.81 or nightly-2026-10-28
type: string
required: true
nightly:
description: A nightly's "<date> <short sha>" (HORIZON_NIGHTLY), shown by the game; empty for a release
type: string
default: ''
developer_id:
description: Sign the macOS programs with Developer ID and notarize the zip (the runner's keychain)
type: boolean
default: true
permissions:
contents: read
jobs:
exporters:
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: exporters
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.ref }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
run: packaging/macos/keychain.sh lock
+166
View File
@@ -0,0 +1,166 @@
name: nightly build
# The opt-in Nightly channel (docs/release-flow.md "Nightly"; Ludovic, 2026-10-27). Once a night it builds the newest
# union branch on origin (union/<agent>-<name>: main plus pull requests the contribution gate proved together), or
# main when no union is newer, and publishes it as the `nightly` pre-release. Launchers install it only for players
# who turn Nightly on. The previous nightly stays a week as nightly-<date>.
#
# Why the signing key never meets the union's code:
# - This file, game-build.yml and every script below run from main's copy: a schedule runs the default branch, and
# `uses: ./…` takes the caller's commit. The union's own .github/ is never read.
# - `build` (game-build.yml) checks out and compiles the union with no secrets: its token reads contents only and
# isn't kept in the checkout; the macOS programs are signed ad hoc (Developer ID needs the runner's keychain,
# which stays locked for nightlies).
# - `sign` is the only job with RELEASE_SIGNING_KEY. It checks out nothing but main's tools/ci/sign_release.sh, which
# runs the runner's sha256sum and OpenSSL over the archives: it hashes them, never runs them. The signed message
# names the channel ("horizon release nightly"), so a nightly's signature can't pass for a release's.
# - `publish` verifies the signature with main's horizon-release-sign, checks the smoke test, and only then moves
# the tag. Like sign, it never executes what build produced.
# gh workflow run "nightly build" # now (skips when the pick is the same commit as last night's)
# gh workflow run "nightly build" -f force=true # build and publish even if unchanged
# gh workflow run "nightly build" -f dry_run=true # build and sign with a throwaway key, publish nothing
on:
schedule:
- cron: '17 20 * * *' # 20:17 UTC: after the day's waves, before the 03:07 Paris parity audit takes the Mac mini
workflow_dispatch:
inputs:
force:
description: Build and publish even if tonight's pick is the commit already published
type: boolean
default: false
dry_run:
description: Build and sign with a throwaway key, publish nothing
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly-build
cancel-in-progress: false
jobs:
pick:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
build: ${{ steps.pick.outputs.build }}
sha: ${{ steps.pick.outputs.sha }}
source: ${{ steps.pick.outputs.source }}
date: ${{ steps.pick.outputs.date }}
version: ${{ steps.pick.outputs.version }}
nightly: ${{ steps.pick.outputs.nightly }}
dry_run: ${{ steps.mode.outputs.dry_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- id: mode
# A dry run never publishes; a hand-started run off main is always one.
run: echo "dry_run=${{ github.event_name == 'workflow_dispatch' && (inputs.dry_run || github.ref != 'refs/heads/main') }}" >> "$GITHUB_OUTPUT"
- id: pick
env:
GH_TOKEN: ${{ github.token }}
FORCE: ${{ (inputs.force || steps.mode.outputs.dry_run == 'true') && 'yes' || '' }}
run: |
python3 tools/ci/nightly.py pick ${FORCE:+--force} | tee -a "$GITHUB_STEP_SUMMARY"
{ echo; cat nightly-notes.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v4
with:
name: nightly-notes
path: nightly-notes.md
retention-days: 3
build:
needs: pick
if: needs.pick.outputs.build == 'true'
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.pick.outputs.sha }}
version: ${{ needs.pick.outputs.version }}
nightly: ${{ needs.pick.outputs.nightly }}
developer_id: false
sign:
needs: [pick, build]
runs-on: ubuntu-latest
timeout-minutes: 10
# The key's only job: main's sign_release.sh hashes the archives and signs with the runner's OpenSSL.
environment: ${{ needs.pick.outputs.dry_run != 'true' && 'release' || '' }}
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: tools/ci/sign_release.sh
sparse-checkout-cone-mode: false
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
pattern: horizon-*
merge-multiple: true
- name: Sign the checksums (SHA256SUMS, SHA256SUMS.sig) for the nightly channel
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_SIGNING_KEY_NEXT: ${{ secrets.RELEASE_SIGNING_KEY_NEXT }}
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
path: dist/SHA256SUMS*
if-no-files-found: error
retention-days: 3
- uses: actions/upload-artifact@v4
if: needs.pick.outputs.dry_run == 'true'
with:
name: ephemeral.pub
path: dist/ephemeral.pub
retention-days: 3
publish:
needs: [pick, build, sign]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: The signature, checked as launchers check it
env:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag nightly --publishing "${key[@]}" dist
- name: The smoke test drew a world
# No release-blocker check: a nightly is how blockers get tested.
run: python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json
- name: Publish as the `nightly` pre-release
if: needs.pick.outputs.dry_run != 'true'
env:
GH_TOKEN: ${{ github.token }}
SHA: ${{ needs.pick.outputs.sha }}
DATE: ${{ needs.pick.outputs.date }}
run: |
set -euo pipefail
python3 tools/ci/nightly.py rotate
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
# A pre-release, never GitHub's latest: launchers on the release channel never see it.
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Nightly game files: Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Nightly game files: Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Nightly game files: macOS (horizon-macos-arm64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
python3 tools/ci/nightly.py prune --days 7
- name: Dry run, the nightly that would be published
if: needs.pick.outputs.dry_run == 'true'
run: |
echo "Would publish nightly at ${{ needs.pick.outputs.sha }} (${{ needs.pick.outputs.source }})"
ls -l dist
+10 -245
View File
@@ -40,7 +40,10 @@ permissions:
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
# One release at a time. A hand-started dry run publishes nothing, so it can't race a real release: it gets a
# group of its own. In the shared group, a release queued behind it replaced it as the pending run and GitHub
# cancelled it (#2779's dry run, 2026-10-27), which happens all day while releases go out in batches.
group: game-release${{ github.event_name == 'workflow_dispatch' && inputs.dry_run && '-dry-run' || '' }}
cancel-in-progress: false
jobs:
decide:
@@ -79,252 +82,14 @@ jobs:
# A dry run builds whatever the decision, to test the builds.
if [[ "${{ steps.mode.outputs.dry_run }}" == true ]]; then echo "release=true" >> "$GITHUB_OUTPUT"; fi
exporters:
build:
needs: decide
if: needs.decide.outputs.release == 'true'
# This runner holds the maintainer's own pinned Minecraft jars. Only our classes leave it.
runs-on: ${{ vars.MAC_RUNNER || 'ludovics-mac-mini' }}
# Three full preparations (two isolated, one batched) besides the compile.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
clean: true
- uses: dtolnay/rust-toolchain@stable
- run: bash tools/ci/runner-env.sh
- run: bash tools/ci/vanilla-data.sh
- name: Compile and validate original exporters against the pinned jars
shell: bash
run: |
set -euo pipefail
# target/ is gone after the clean checkout, and vanilla-data.sh builds the preparer only when it
# prepares afresh.
cargo build --release --locked -p horizon-prepare
# Compiled here, never taken from the runner's shared preparations: pull requests' parity jobs
# write those (vanilla-data.sh links .local/exporters to them).
rm -rf .local/exporters
bundle=$(target/release/horizon-prepare compile-exporters --root "$PWD" | tail -1)
mkdir -p exporters
cp -R "$bundle/server" "$bundle/client" "$bundle/manifest.json" exporters/
# Only our classes and their manifest leave this runner.
stray=$(find exporters -type f ! -path exporters/manifest.json \
! \( -name '*.class' \( -path 'exporters/server/dev/horizonlab/parity/*' -o -path 'exporters/client/dev/horizonlab/parity/*' \) \))
[ -z "$stray" ] || { echo "::error::not one of our classes: $stray"; exit 1; }
python3 tools/check_prepare_exporters.py --binary target/release/horizon-prepare
- uses: actions/upload-artifact@v4
with:
name: original-exporters
path: exporters/
if-no-files-found: error
retention-days: 3
build:
needs: [decide, exporters]
if: needs.decide.outputs.release == 'true'
name: build ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: linux
os: ubuntu-22.04
dir: horizon-linux-x86_64
asset: horizon-linux-x86_64.tar.gz
# The server build too: packaging/server/install.sh installs it on Linux.
server_dir: horizon-server-linux-x86_64
server_asset: horizon-server-linux-x86_64.tar.gz
- name: windows
os: windows-latest
dir: horizon-windows-x64
asset: horizon-windows-x64.zip
exe: .exe
# The C runtime linked in, so the game runs without the Visual C++ redistributable.
rustflags: -C target-feature=+crt-static
- name: macos
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
runs-on: ${{ matrix.mac && (vars.MAC_RUNNER || 'ludovics-mac-mini') || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# The release's version, built in (horizon_core::release): --version, user agents, crash reports and
# telemetry say it. No version-bump commits.
HORIZON_VERSION: ${{ needs.decide.outputs.version }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.decide.outputs.head }}
persist-credentials: false
# Always from a clean checkout, the Mac mini's runner too: its work folder is shared with pull requests'
# parity jobs, and nothing they leave (target/, an untracked .cargo/config.toml, a build script's output)
# may end up in a release. target/ is rebuilt from scratch; .local/ is relinked by vanilla-data.sh, which
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
shared-key: release-${{ matrix.name }}
- name: ALSA and udev headers
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: packaging/macos/keychain.sh unlock
- name: Signed with Developer ID and the hardened runtime
# Before the checks below, so they run the programs as players will.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
env:
MACOS_SIGN_IDENTITY: ${{ vars.MACOS_SIGN_IDENTITY }}
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
else
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
# The version is the release's.
for binary in horizon-prepare horizon-server; do
out=$("$bin/$binary${{ matrix.exe }}" --version | tr -d '\r')
echo "$out"
[[ "$out" == "$binary $HORIZON_VERSION" ]] || { echo "::error::expected $binary $HORIZON_VERSION"; exit 1; }
done
- name: Tools from the runner
if: runner.environment == 'self-hosted'
run: bash tools/ci/runner-env.sh
- name: Resources prepared from the runner's own Minecraft
if: runner.environment == 'self-hosted'
run: bash tools/ci/vanilla-data.sh
- name: The game runs (offscreen, a new world, 1500 frames)
if: runner.environment == 'self-hosted'
run: |
set -euo pipefail
# From a scratch folder: the game keeps its saves and options in the folder it starts in.
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
--smoke-report "$home/smoke.json" > "$home/client.log" 2>&1) || { tail -40 "$home/client.log"; exit 1; }
python3 - "$home/smoke.json" <<'EOF'
import json, sys
report = json.load(open(sys.argv[1]))
print(json.dumps(report))
if report.get("error") is not None or report.get("frames", 0) < 1500 or not report.get("chunks") \
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
if-no-files-found: error
retention-days: 3
- uses: actions/download-artifact@v4
with:
name: original-exporters
path: exporters
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
*.tar.gz) tar czf "${{ matrix.asset }}" "${{ matrix.dir }}" ;;
*) if [[ "$RUNNER_OS" == Windows ]]; then 7z a -tzip "${{ matrix.asset }}" "${{ matrix.dir }}" > /dev/null
else
# Without resource forks and extended attributes: plain ditto adds an
# AppleDouble `._` file beside every file that has any.
ditto -c -k --norsrc --noextattr --noqtn --keepParent "${{ matrix.dir }}" "${{ matrix.asset }}"
if unzip -Z1 "${{ matrix.asset }}" | grep -E '(^|/)(\._|__MACOSX/)'; then
echo "::error::macOS metadata files in ${{ matrix.asset }}"; exit 1
fi
fi ;;
esac
ls -l "${{ matrix.asset }}"
- name: Notarized
# A zip of programs can't be stapled: Gatekeeper asks Apple the first time each one runs.
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --verify --strict "dist/${{ matrix.dir }}/$binary"
done
packaging/macos/notarize.sh "dist/${{ matrix.asset }}"
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset }}
path: dist/${{ matrix.asset }}
if-no-files-found: error
retention-days: 3
- name: The server build (the game without sound or controllers, as horizon-engine)
if: matrix.server_asset
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
"$folder/horizon-engine" --help | grep -q -- '--resources'
[[ "$("$folder/horizon-server" --version)" == "horizon-server $HORIZON_VERSION" ]]
cd dist
rm -f "${{ matrix.server_asset }}"
tar czf "${{ matrix.server_asset }}" "${{ matrix.server_dir }}"
ls -l "${{ matrix.server_asset }}"
- uses: actions/upload-artifact@v4
if: matrix.server_asset
with:
name: ${{ matrix.server_asset }}
path: dist/${{ matrix.server_asset }}
if-no-files-found: error
retention-days: 3
- name: The signing keychain, locked again
if: always() && runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != ''
run: packaging/macos/keychain.sh lock
# Every platform's archive, and macOS's smoke test (game-build.yml).
uses: ./.github/workflows/game-build.yml
with:
ref: ${{ needs.decide.outputs.head }}
version: ${{ needs.decide.outputs.version }}
sign:
needs: [decide, build]
@@ -61,6 +61,16 @@ fn f32_tag(v: f32) -> Tag {
Tag::Float(v.to_bits())
}
/// `TheEndGatewayBlockEntity.loadAdditional`: BlockPos.CODEC and Level.isInSpawnableBounds.
pub fn gateway_exit(tag: &Tag) -> Option<[i32; 3]> {
let Tag::IntArray(v) = tag else { return None };
let p: [i32; 3] = v.as_slice().try_into().ok()?;
((-30_000_000..30_000_000).contains(&p[0])
&& (-20_000_000..20_000_000).contains(&p[1])
&& (-30_000_000..30_000_000).contains(&p[2]))
.then_some(p)
}
/// What a block entity reads and writes, in `saveAdditional` order.
struct Fields<'a> {
input: &'a Tag,
@@ -424,10 +434,7 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:end_gateway" => {
f.long("Age", 0);
f.keep("exit_portal", |t| {
is_pos(t)
&& matches!(t, Tag::IntArray(v) if v[1].abs() <= 30_000_000 && v[0].abs() <= 30_000_000 && v[2].abs() <= 30_000_000)
});
f.keep("exit_portal", |t| gateway_exit(t).is_some());
if flag(tag.get("ExactTeleport")) == Some(true) {
f.put("ExactTeleport", boolean(true));
}
@@ -173,6 +173,22 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
+5 -2
View File
@@ -1,8 +1,9 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
//! sound source tables commands name, and the `pack.mcmeta` filter regex.
//! horizon-client re-exports each module under its old path
//! (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
@@ -14,6 +15,8 @@ pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod pattern;
pub mod potions;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
+249
View File
@@ -0,0 +1,249 @@
//! The `MobEffects` and `Potions` registries (code in vanilla, ported as
//! tables): each effect's colour and category, each potion's effects, and the
//! potion colour mix. horizon-client re-exports them from `brewing`.
use std::{collections::HashMap, sync::OnceLock};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
+6
View File
@@ -42,6 +42,10 @@ pub fn named_color(name: &str) -> Option<u32> {
/// Click actions carried by locally constructed text components.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
/// `ClickEvent.OpenUrl`: an http or https link.
OpenUrl(String),
/// `ClickEvent.ChangePage`: a book's page, counted from 1.
ChangePage(i32),
SuggestCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
@@ -529,6 +533,8 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
}
if let Some(click) = &style.click {
let event = match click {
ClickEvent::OpenUrl(url) => json!({ "action": "open_url", "url": url }),
ClickEvent::ChangePage(page) => json!({ "action": "change_page", "page": page }),
ClickEvent::SuggestCommand(command) => {
json!({ "action": "suggest_command", "command": command })
}
@@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
@@ -1071,6 +1072,8 @@ pub fn main() -> horizon_client::Result<()> {
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m, s.creative, world.game_time);
}
// No player has a chest open in the scenarios.
let lid_openers = std::collections::HashMap::new();
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
@@ -1082,6 +1085,7 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -1099,12 +1103,14 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
}
};
}
+117 -21
View File
@@ -437,6 +437,8 @@ pub struct EntityView {
/// mainhand, offhand, head, chest, legs, feet, body.
pub equipment: [Option<ItemView>; 7],
pub vehicle: Option<Box<EntityView>>,
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
pub looking_at: Option<Box<EntityView>>,
pub effects: Vec<(String, i32, i32)>,
/// Player: game mode name.
pub gamemode: Option<String>,
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
fn range_i(v: &Value, x: i64) -> bool {
pub fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
_ => false,
}
}
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
/// over the potion's effects (`contains`, `count`, `size`).
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
if v.is_null() {
return true;
}
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
{
return false;
}
let c = &v["effects"];
if c.is_null() {
return true;
}
let effects: Vec<(String, i32, i32)> = potion
.and_then(crate::brewing::potion)
.map(|d| {
d.effects
.iter()
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
.collect()
})
.unwrap_or_default();
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
let test = |pred: &Value, e: &(String, i32, i32)| {
pred.as_object().is_some_and(|m| {
m.get(&e.0).is_some_and(|want| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
})
};
c["contains"]
.as_array()
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
&& c["count"].as_array().is_none_or(|l| {
l.iter().all(|entry| {
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
range_i(&entry["count"], n as i64)
})
})
&& range_i(&c["size"], effects.len() as i64)
}
/// `ItemPredicate`.
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
if v.is_null() {
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
})
})
}
/// `DistancePredicate.matches` between two points.
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
range_f(&v["x"], d[0].abs())
&& range_f(&v["y"], d[1].abs())
&& range_f(&v["z"], d[2].abs())
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&v["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
"minecraft:stepping_on" => {
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" | "distance" => {
let d = [
e.position[0] - origin[0],
e.position[1] - origin[1],
e.position[2] - origin[2],
];
range_f(&p["x"], d[0].abs())
&& range_f(&p["y"], d[1].abs())
&& range_f(&p["z"], d[2].abs())
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&p["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
"minecraft:distance" | "distance" => distance(p, e.position, origin),
"minecraft:flags" | "flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
})
})
}),
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}),
"minecraft:type_specific/player" => {
p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
@@ -703,6 +753,52 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
/// The damage type id (`minecraft:arrow`).
pub kind: String,
/// `DamageSource.getDirectEntity` and `getEntity`.
pub direct: Option<EntityView>,
pub source: Option<EntityView>,
/// `DamageSource.isDirect`: the direct entity is the causing one.
pub is_direct: bool,
/// The damage before and after the shield took its share.
pub original: f32,
pub actual: f32,
pub blocked: bool,
}
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
return true;
}
let by = |key: &str, e: &Option<EntityView>| {
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
};
if !range_f(&v["dealt"], f64::from(d.original))
|| !range_f(&v["taken"], f64::from(d.actual))
|| !by("source_entity", &d.source)
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
{
return false;
}
let ty = &v["type"];
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
});
let direct_ok = ty["direct_entity"].is_null()
|| d.direct
.as_ref()
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
let source_ok = ty["source_entity"].is_null()
|| d.source
.as_ref()
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
}
/// The entities a loot-style condition list may refer to.
pub struct Entities<'a> {
pub this: Option<&'a EntityView>,
+137 -28
View File
@@ -36,8 +36,9 @@ enum Animate {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
/// the biome foliage colour unless a fixed colour is given (azalea); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -48,8 +49,9 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
@@ -109,17 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
}
"spruce_leaves" => Animate::Leaves(None, None),
"spruce_leaves" => Animate::Leaves(None, None, false),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -128,16 +130,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -171,8 +174,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3),
"suspicious_gravel" => Animate::Falling(0x707070),
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -239,6 +242,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_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`).
@@ -314,6 +321,8 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
burnable,
ambient,
})
@@ -402,6 +411,10 @@ impl AnimateContext<'_> {
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add(&ParticleOptions::simple(ty), p, v);
}
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
}
@@ -777,7 +790,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint) => {
Animate::Leaves(falling, fixed_tint, poplar) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -789,6 +802,9 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -961,7 +977,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::SulfurBubbles, p, [0.0; 3]);
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -1081,7 +1097,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color) => {
Animate::Falling(color, sand) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -1098,6 +1114,14 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
@@ -1215,7 +1239,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1228,7 +1252,7 @@ fn block_animate_tick(
);
}
} else {
add(
add_visible(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1240,7 +1264,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1318,6 +1342,91 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
+1 -1
View File
@@ -3,8 +3,8 @@
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use horizon_client_core::pattern::Pattern;
use serde_json::Value;
use std::{
collections::BTreeMap,
+1 -1
View File
@@ -179,7 +179,7 @@ fn agent() -> ureq::Agent {
}
/// The discovery document, fetched once per game (a failure is retried next time).
fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
static CACHE: Mutex<Option<Arc<serde_json::Value>>> = Mutex::new(None);
if let Some(d) = CACHE.lock().unwrap().clone() {
return Ok(d);
@@ -149,6 +149,8 @@ pub fn draw_part(
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -650,6 +652,8 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
layer: 0,
color: c,
light: call.light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -736,7 +740,7 @@ pub struct Scene<'a> {
/// The skin sprite a `player_head` shows at a position (`SkullBlockRenderer`
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
pub skull: &'a dyn Fn([i32; 3]) -> Option<&'a str>,
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
/// Font glyph cell (none for blank glyphs such as the space) and advance.
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
}
@@ -1288,11 +1292,12 @@ impl BlockEntityRenderer {
};
// SkullBlockRenderer.getRenderType: the profile's skin when it
// resolves, `SKIN_BY_TYPE` (steve) otherwise.
let texture = if *kind == SkullType::Player {
(scene.skull)(pos).unwrap_or(texture)
let skin = if *kind == SkullType::Player {
(scene.skull)(pos)
} else {
texture
None
};
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
@@ -1716,6 +1721,8 @@ impl BeamPart {
layer: 6,
color,
light: self.wrap,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1786,6 +1793,7 @@ fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>)
layer,
color: 0,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
+2
View File
@@ -49,6 +49,8 @@ pub fn geometry(
color: 0xffff_ffff,
light: stage_light(stage),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
+5 -250
View File
@@ -7,257 +7,12 @@ use crate::{
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
sync::{Arc, OnceLock},
};
use std::{collections::HashSet, fs, path::Path, sync::Arc};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
Beneficial,
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
pub color: u32,
pub category: Category,
pub instant: bool,
}
const fn fx(id: &'static str, color: u32, category: Category) -> EffectDef {
EffectDef {
id,
color,
category,
instant: false,
}
}
use Category::{Beneficial as B, Harmful as H, Neutral as N};
/// `MobEffects` in registration order.
pub const EFFECTS: &[EffectDef] = &[
fx("speed", 3402751, B),
fx("slowness", 9154528, H),
fx("haste", 14270531, B),
fx("mining_fatigue", 4866583, H),
fx("strength", 16762624, B),
EffectDef {
id: "instant_health",
color: 16262179,
category: B,
instant: true,
},
EffectDef {
id: "instant_damage",
color: 11101546,
category: H,
instant: true,
},
fx("jump_boost", 16646020, B),
fx("nausea", 5578058, H),
fx("regeneration", 13458603, B),
fx("resistance", 9520880, B),
fx("fire_resistance", 16750848, B),
fx("water_breathing", 10017472, B),
fx("invisibility", 16185078, B),
fx("blindness", 2039587, H),
fx("night_vision", 12779366, B),
fx("hunger", 5797459, H),
fx("weakness", 4738376, H),
fx("poison", 8889187, H),
fx("wither", 7561558, H),
fx("health_boost", 16284963, B),
fx("absorption", 2445989, B),
EffectDef {
id: "saturation",
color: 16262179,
category: B,
instant: true,
},
fx("glowing", 9740385, N),
fx("levitation", 13565951, H),
fx("luck", 5882118, B),
fx("unluck", 12624973, H),
fx("slow_falling", 15978425, B),
fx("conduit_power", 1950417, B),
fx("dolphins_grace", 8954814, B),
fx("bad_omen", 745784, N),
fx("hero_of_the_village", 4521796, B),
fx("darkness", 2696993, H),
fx("trial_omen", 1484454, N),
fx("raid_omen", 14565464, N),
fx("wind_charged", 12438015, H),
fx("weaving", 7891290, H),
fx("oozing", 10092451, H),
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
pub struct PotionDef {
pub id: &'static str,
pub name: &'static str,
pub effects: &'static [(&'static str, i32, i32)],
}
const fn p(
id: &'static str,
name: &'static str,
effects: &'static [(&'static str, i32, i32)],
) -> PotionDef {
PotionDef { id, name, effects }
}
/// `Potions` in registration order.
pub const POTIONS: &[PotionDef] = &[
p("water", "water", &[]),
p("mundane", "mundane", &[]),
p("thick", "thick", &[]),
p("awkward", "awkward", &[]),
p("night_vision", "night_vision", &[("night_vision", 3600, 0)]),
p(
"long_night_vision",
"night_vision",
&[("night_vision", 9600, 0)],
),
p("invisibility", "invisibility", &[("invisibility", 3600, 0)]),
p(
"long_invisibility",
"invisibility",
&[("invisibility", 9600, 0)],
),
p("leaping", "leaping", &[("jump_boost", 3600, 0)]),
p("long_leaping", "leaping", &[("jump_boost", 9600, 0)]),
p("strong_leaping", "leaping", &[("jump_boost", 1800, 1)]),
p(
"fire_resistance",
"fire_resistance",
&[("fire_resistance", 3600, 0)],
),
p(
"long_fire_resistance",
"fire_resistance",
&[("fire_resistance", 9600, 0)],
),
p("swiftness", "swiftness", &[("speed", 3600, 0)]),
p("long_swiftness", "swiftness", &[("speed", 9600, 0)]),
p("strong_swiftness", "swiftness", &[("speed", 1800, 1)]),
p("slowness", "slowness", &[("slowness", 1800, 0)]),
p("long_slowness", "slowness", &[("slowness", 4800, 0)]),
p("strong_slowness", "slowness", &[("slowness", 400, 3)]),
p(
"turtle_master",
"turtle_master",
&[("slowness", 400, 3), ("resistance", 400, 2)],
),
p(
"long_turtle_master",
"turtle_master",
&[("slowness", 800, 3), ("resistance", 800, 2)],
),
p(
"strong_turtle_master",
"turtle_master",
&[("slowness", 400, 5), ("resistance", 400, 3)],
),
p(
"water_breathing",
"water_breathing",
&[("water_breathing", 3600, 0)],
),
p(
"long_water_breathing",
"water_breathing",
&[("water_breathing", 9600, 0)],
),
p("healing", "healing", &[("instant_health", 1, 0)]),
p("strong_healing", "healing", &[("instant_health", 1, 1)]),
p("harming", "harming", &[("instant_damage", 1, 0)]),
p("strong_harming", "harming", &[("instant_damage", 1, 1)]),
p("poison", "poison", &[("poison", 900, 0)]),
p("long_poison", "poison", &[("poison", 1800, 0)]),
p("strong_poison", "poison", &[("poison", 432, 1)]),
p("regeneration", "regeneration", &[("regeneration", 900, 0)]),
p(
"long_regeneration",
"regeneration",
&[("regeneration", 1800, 0)],
),
p(
"strong_regeneration",
"regeneration",
&[("regeneration", 450, 1)],
),
p("strength", "strength", &[("strength", 3600, 0)]),
p("long_strength", "strength", &[("strength", 9600, 0)]),
p("strong_strength", "strength", &[("strength", 1800, 1)]),
p("weakness", "weakness", &[("weakness", 1800, 0)]),
p("long_weakness", "weakness", &[("weakness", 4800, 0)]),
p("luck", "luck", &[("luck", 6000, 0)]),
p("slow_falling", "slow_falling", &[("slow_falling", 1800, 0)]),
p(
"long_slow_falling",
"slow_falling",
&[("slow_falling", 4800, 0)],
),
p("wind_charged", "wind_charged", &[("wind_charged", 3600, 0)]),
p("weaving", "weaving", &[("weaving", 3600, 0)]),
p("oozing", "oozing", &[("oozing", 3600, 0)]),
p("infested", "infested", &[("infested", 3600, 0)]),
];
pub fn potion(id: &str) -> Option<&'static PotionDef> {
let id = id.trim_start_matches("minecraft:");
POTIONS.iter().find(|p| p.id == id)
}
/// `PotionContents.BASE_POTION_COLOR` (ARGB -13083194) as RGB.
pub const BASE_POTION_COLOR: u32 = 0x385DC6;
/// `PotionContents.getColor`: amplifier-weighted mix of visible effect colours.
pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
total += w;
}
match (
r.checked_div(total),
g.checked_div(total),
b.checked_div(total),
) {
(Some(r), Some(g), Some(b)) => (r << 16) | (g << 8) | b,
_ => BASE_POTION_COLOR,
}
}
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
};
/// `MobEffectUtil.formatDuration` at 20 TPS: m:ss or h:mm:ss.
pub fn format_duration(ticks: i32) -> String {
let secs = ticks / 20;
+72 -1
View File
@@ -2,6 +2,7 @@
//! threads can mesh and light immutable snapshots while the game thread edits
//! (copy-on-write through `Arc::make_mut`).
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::{Arc, OnceLock};
@@ -155,7 +156,7 @@ pub struct Chunk {
/// positions, applied once by LevelChunk.postProcessGeneration.
pub postprocess: Vec<[i32; 3]>,
/// Saved data Horizon keeps but does not simulate (save.rs).
pub saved: Option<Arc<crate::save::ChunkExtras>>,
pub saved: Option<Arc<ChunkExtras>>,
/// Entities read from a save (vanilla entity NBT), spawned on arrival.
pub entities: Vec<horizon_world::nbt::Tag>,
/// Structure starts reaching into this chunk (`structures.References`):
@@ -309,6 +310,76 @@ impl Chunk {
}
}
/// A proto chunk saved before it became final. Decoration writes into a
/// chunk's neighbours, and decorated chunks wait for theirs, so the chunks
/// past the finished ones hold writes that generating them again would lose
/// (whole structure pieces, trees, ores). Vanilla saves its proto chunks too.
pub struct SavedProto {
/// Blocks, scheduled ticks (in `saved`) and post-processing marks.
pub column: Chunk,
/// Decorated itself (`features`), not only written to by its neighbours
/// (`terrain`).
pub decorated: bool,
/// Block entity data in placement order, with `x`, `y` and `z`.
pub block_entities: Vec<Tag>,
/// Entities placed by structures, with absolute `Pos`.
pub entities: Vec<Tag>,
}
/// A container's loot table and what Horizon rolled from it. Vanilla keeps
/// `LootTable` and `LootTableSeed` until the container is first opened;
/// Horizon rolls when it generates or loads the chunk, and saves the table
/// again while the container still holds exactly the rolled items.
#[derive(Clone, Debug, PartialEq)]
pub struct RolledLoot {
pub pos: (i32, i32, i32),
pub table: String,
/// The saved `LootTableSeed`, written back unless 0 (trySaveLootTable).
pub seed: Option<i64>,
/// The rolled `Items`.
pub items: Tag,
}
impl RolledLoot {
/// Puts the loot table back in place of `Items` while they are still the
/// roll; false once the container holds anything else.
pub fn restore(&self, map: &mut Compound) -> bool {
if map.get(&"Items".into()) != Some(&self.items) {
return false;
}
map.remove(&"Items".into());
map.insert("LootTable".into(), Tag::String(self.table.as_str().into()));
if let Some(seed) = self.seed.filter(|s| *s != 0) {
map.insert("LootTableSeed".into(), Tag::Long(seed));
}
true
}
}
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
/// Block entities Horizon does not model.
pub block_entities: Vec<Tag>,
/// The NBT of the modelled block entities as loaded, to restore the fields and
/// detail Horizon does not model when it saves them (`block_entity_nbt::overlay`).
pub block_entity_sources: Vec<((i32, i32, i32), Tag)>,
/// Every saved block entity's position in the order of the save's list,
/// the order `SerializableChunkData` puts them in the chunk's maps.
pub block_entity_order: Vec<(i32, i32, i32)>,
/// Scheduled ticks (`block_ticks`/`fluid_ticks`): delays relative to the
/// save time. Registered with the level's tick scheduler on arrival and
/// replaced with the scheduler's pending ticks when saving.
pub block_ticks: Vec<horizon_core::ticks::SavedTick>,
pub fluid_ticks: Vec<horizon_core::ticks::SavedTick>,
/// Game time written as `LastUpdate`.
pub last_update: i64,
/// Containers still holding their loot table's roll.
pub rolled_loot: Vec<RolledLoot>,
}
#[cfg(test)]
mod tests {
use super::*;
+2
View File
@@ -179,6 +179,8 @@ impl Mesh {
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
+141 -5
View File
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
}
// Vanilla scopes this direction to Entity.move while a piston pushes it.
thread_local! {
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
}
/// Suppress the moving part of pistons moving in this direction during a push.
pub fn with_piston_noclip<T>(
direction: horizon_core::geometry::Direction,
f: impl FnOnce() -> T,
) -> T {
struct Reset(Option<horizon_core::geometry::Direction>);
impl Drop for Reset {
fn drop(&mut self) {
PISTON_NOCLIP.with(|value| value.set(self.0));
}
}
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
let _reset = Reset(previous);
f()
}
pub struct SceneCollisions<'w, 'r> {
pub world: &'w World,
pub binding: &'w BlockCollisionRegistry<'r>,
@@ -140,8 +161,21 @@ impl<W: EntityMovementWorld<Error = io::Error>> EntityMovementWorld for EntityCo
border.collision_shape().map(Some).map_err(io::Error::other)
}
}
impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
/// `SceneCollisions::resolve` that also stops at the entity boxes.
impl<'a, W> EntityCollisions<'a, W>
where
W: BlockCollisionWorld<Error = io::Error> + EntityMovementWorld<Error = io::Error>,
{
/// `EntityCollisions` for a body the world border also stops: the border
/// and the body's own x and z (`WorldBorder.isInsideCloseToBorder`).
pub fn with_border(blocks: W, border: &'a BorderGeometry, at: [f64; 2]) -> Self {
Self {
blocks,
entities: &[],
border: Some((border, at)),
}
}
/// `SceneCollisions::resolve` that also stops at the entity boxes and,
/// with a border, at the world border's walls.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -149,12 +183,11 @@ impl EntityCollisions<'_, SceneCollisions<'_, '_>> {
max_step: f32,
on_ground: bool,
) -> Result<Vec3> {
let context = self.blocks.context(bounds);
Ok(resolve_world_movement(
self,
MovementRequest {
entity: EntityIdentity(0),
context,
context: CollisionContext::position(bounds.min_position().y),
bounds,
movement,
max_step,
@@ -324,7 +357,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.map(Some)
.map_err(io::Error::other)
}
@@ -388,6 +421,42 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
}
}
/// A collision world without blocks: no chunk is resident, so only the
/// world border stops a body.
pub struct EmptyCollisions;
impl BlockCollisionWorld for EmptyCollisions {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
_: CollisionBlock,
_: BlockPos,
_: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
impl EntityMovementWorld for EmptyCollisions {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
@@ -475,3 +544,70 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
Ok(false)
}
}
#[cfg(test)]
mod piston_noclip_tests {
use super::*;
use horizon_core::geometry::Direction;
#[test]
fn piston_noclip_is_scoped_and_restored_after_unwind() {
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
with_piston_noclip(Direction::East, || {
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
let _ =
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
});
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
}
}
#[cfg(test)]
mod border_movement_tests {
use super::*;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `WorldBorder.isInsideCloseToBorder` and `WorldBorder.getCollisionShape`:
/// a body inside close to the border collides with the border's walls.
#[test]
fn a_body_next_to_the_border_stops_at_its_wall() {
// A body 0.6 wide, 0.4 blocks short of the wall, moved east into it.
let bounds = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [4.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
// The wall stops the body 0.4 blocks short of the whole move.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same body moves the whole way.
#[test]
fn a_body_away_from_the_border_moves_whole() {
let bounds = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let mut collisions = EntityCollisions::with_border(EmptyCollisions, &border, [0.3, 0.3]);
let moved = collisions
.resolve(bounds, Vec3::new(0.51, 0.0, 0.0), 0.0, false)
.expect("the movement resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole way: {moved:?}"
);
}
}
+54 -1
View File
@@ -98,9 +98,34 @@ pub enum BlockEntity {
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).
@@ -190,6 +215,9 @@ impl Skull {
#[derive(Clone, Debug)]
pub struct SkullSkin {
pub download: Option<(String, String)>,
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<crate::skins::NameOrId>,
pub fallback: &'static str,
}
impl SkullSkin {
@@ -198,10 +226,21 @@ impl SkullSkin {
let location = crate::skins::skin_location(&crate::skins::texture_hash(&url)?);
Some((url, location))
});
// A `Dynamic` profile (a name or id only) resolves through the
// services when the skull is drawn.
let dynamic = crate::skins::dynamic_nbt(profile);
// DefaultPlayerSkin.get(id), or getDefaultSkin (slim steve) without an id.
let fallback = crate::skins::profile_uuid_nbt(profile)
.or_else(|| {
crate::skins::nbt_text(profile.get("name"))
.map(|name| horizon_protocol::crypto::offline_uuid(&name))
})
.map_or("entity/player/slim/steve", crate::tab_list::default_skin);
Self { download, fallback }
Self {
download,
dynamic,
fallback,
}
}
}
/// The skull and head blocks (`SkullBlock`, `WallSkullBlock`): every
@@ -366,6 +405,9 @@ impl BlockEntity {
if is_skull(name) {
return Some(Self::Skull(Skull::default()));
}
if name == "minecraft:end_gateway" {
return Some(Self::EndGateway(EndGateway::default()));
}
if name == "minecraft:daylight_detector" {
return Some(Self::DaylightDetector);
}
@@ -381,6 +423,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -418,8 +463,10 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -436,6 +483,7 @@ impl BlockEntity {
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
@@ -443,6 +491,7 @@ impl BlockEntity {
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -456,9 +505,11 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -480,9 +531,11 @@ impl BlockEntity {
| Self::Lectern(_)
| Self::Sculk(_)
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+16 -3
View File
@@ -105,9 +105,13 @@ impl Report {
}
}
/// The release this binary is (`v0.2.3`); for a release from before 0.2, its tag and the client's
/// crate version; for a build from source, that version alone.
/// The release this binary is (`v0.2.3`); a nightly's label (`Nightly 2026-10-28 · 0c02067`); for a
/// release from before 0.2, its tag and the client's crate version; for a build from source, that
/// version alone.
pub fn version() -> String {
if let Some(label) = horizon_core::release::nightly_label() {
return label;
}
let version = env!("CARGO_PKG_VERSION");
match (horizon_core::release::RELEASE, release_tag()) {
(Some(_), Some(tag)) => tag,
@@ -117,8 +121,12 @@ pub fn version() -> String {
}
/// The release this binary came with: the version release.yml built in (docs/versioning.md), else
/// the folder the launcher installed it in, named after the tag (releases from before 0.2).
/// the folder the launcher installed it in, named after the tag (releases from before 0.2). A
/// nightly is its version as the launcher installs it (`nightly-2026-10-28-0c02067`).
pub fn release_tag() -> Option<String> {
if let Some(nightly) = horizon_core::release::nightly_version() {
return Some(nightly);
}
if let Some(version) = horizon_core::release::RELEASE {
return Some(format!("v{version}"));
}
@@ -525,6 +533,11 @@ mod tests {
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
if let Some(label) = horizon_core::release::nightly_label() {
assert_eq!(super::version(), label);
assert_eq!(release_tag(), horizon_core::release::nightly_version());
return;
}
match horizon_core::release::RELEASE {
Some(version) => {
assert_eq!(release_tag(), Some(format!("v{version}")));
+5 -5
View File
@@ -2,7 +2,7 @@
//! add/update with hidden effects, per-tick application of the periodic
//! effects (regeneration, poison, wither, hunger, saturation, instant
//! effects), and the derived flags the movement and damage code read.
use crate::brewing;
use horizon_client_core::potions;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
@@ -183,7 +183,7 @@ impl PlayerEffects {
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = brewing::effect(id) {
if let Some(def) = potions::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
@@ -306,10 +306,10 @@ impl PlayerEffects {
) -> (Vec<EffectInstance>, Vec<EffectAction>) {
let mut lasting = Vec::new();
let mut now = Vec::new();
if let Some(def) = potion.and_then(brewing::potion) {
if let Some(def) = potion.and_then(potions::potion) {
for (id, duration, amp) in def.effects {
let id = brewing::effect(id).map_or("speed", |e| e.id);
if brewing::effect(id).is_some_and(|e| e.instant) {
let id = potions::effect(id).map_or("speed", |e| e.id);
if potions::effect(id).is_some_and(|e| e.instant) {
now.extend(instant(id, *amp, 1.0));
} else {
// withScaledDuration: max(floor(d * scale), 1).
+71 -13
View File
@@ -14,7 +14,8 @@ struct Globals {
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
// y: Nether cardinal lighting, z: how many dynamic_lights are set.
// y: Nether cardinal lighting, z: how many dynamic_lights are set,
// w: the glint scroll clock (`Glint::scroll_ticks`).
time: vec4<f32>,
crumbling_sprite: vec4<f32>,
// Selection outline parameters (read by terrain.wgsl).
@@ -27,6 +28,12 @@ struct Globals {
@group(0) @binding(2) var atlas_sampler: sampler;
@group(0) @binding(3) var lightmap: texture_2d<f32>;
@group(0) @binding(4) var lightmap_sampler: sampler;
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(5) var glint_tex: texture_2d<f32>;
@group(0) @binding(6) var glint_sampler: sampler;
// The armour layers' GlintSampler: vanilla samples enchanted_glint_armor with
// the same repeating, linearly filtered sampler as the item glint.
@group(0) @binding(7) var glint_armor_tex: texture_2d<f32>;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -44,6 +51,9 @@ struct Out {
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// The `GLINT_TEXTURING` sample for overlay 7 (item foil), or the model UV
// `ARMOR_ENTITY_GLINT_TEXTURING` scrolls for overlay 13 (armour).
@location(11) glint_uv: vec2<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -103,18 +113,23 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
out.spherical = select(length(position), 0.0, overlay >= 2u && overlay <= 4u);
out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u);
out.overlay = overlay;
out.glint_uv = vec2(0.0);
if overlay == 4u { out.spherical = length(position); }
return out;
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>) -> Out {
return entity_vertex(position, uv, overlay, color, light, normal);
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay, color, light, normal);
out.glint_uv = glint_uv;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
// subtraction as the former CPU path before adding each local f32 position.
// The bolt offset is instance attribute 7: 6 is the entity vertex's glint UV.
@vertex fn vs_lightning(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>, @location(5) normal: vec4<f32>,
@location(6) offset: vec3<f32>) -> Out {
@location(7) offset: vec3<f32>) -> Out {
return entity_vertex(offset + position, uv, overlay, color, light, normal);
}
struct CloudInfo {
@@ -166,6 +181,27 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d >= end { return 1.0; }
return (d - start) / (end - start);
}
// TextureTransform.setupGlintTexturing(0.16), ARMOR_ENTITY_GLINT_TEXTURING.
// globals.time.w is (Util.getMillis() * glintSpeed * 8) modulo 330000, the
// common period of both offsets.
fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
let ticks = globals.time.w;
let offset0 = (ticks % 110000.0) / 110000.0;
let offset1 = (ticks % 30000.0) / 30000.0;
let angle = 0.17453292;
let c = cos(angle);
let s = sin(angle);
let scaled = model * 0.16;
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>, dimensions: vec2<u32>) -> vec2<f32> {
let size = vec2<f32>(dimensions);
@@ -204,21 +240,29 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
let t = globals.misc.y * 0.05;
let band = sin((px.x + px.y) * 0.9 - t * 6.0) * 0.5 + 0.5;
let band2 = sin((px.x - px.y) * 0.6 + t * 4.0) * 0.5 + 0.5;
let shimmer = pow(band * 0.6 + band2 * 0.4, 2.0);
let glint = vec3(0.50, 0.25, 0.80) * (0.25 + 0.75 * shimmer);
color = vec4(min(color.rgb + glint * 0.6, vec3(1.0)), color.a);
// 33: a translucent item with the glint.
// Enchantment glint: item.fsh's GLINT path, sampled from
// enchanted_glint_item at the `GLINT_TEXTURING` UV.
color = glint_over(color, textureSample(glint_tex, glint_sampler, in.glint_uv));
} else if in.overlay == 13u {
// Enchanted armour: enchanted_glint_armor at the scrolling model UV.
color = glint_over(
color,
textureSample(glint_armor_tex, glint_sampler, armor_glint_uv(in.glint_uv)),
);
}
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@@ -237,6 +281,20 @@ fn celestial_color(in: Out) -> vec4<f32> {
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
return vec4(0.0);
@@ -186,6 +186,8 @@ mod tests {
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
};
let prior = vertex([0.0; 3]);
let body = [vertex([8.0, -1.0, -1.0]), vertex([10.0, 1.0, 1.0])];
+10
View File
@@ -577,6 +577,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
}
}
// Armadillo.addAdditionalSaveData: the time left to the next scute.
"minecraft:armadillo" => {
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
}
"minecraft:ravager" => {
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
@@ -1198,6 +1202,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
st.vibration = crate::sculk::Data::from_nbt(listener);
}
}
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
if m.species() == crate::mobs::Species::Armadillo
&& let Some(t) = number(tag.get("scute_time"))
{
m.extra.egg_time = t as i32;
}
if m.species() == crate::mobs::Species::Ravager {
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
+2
View File
@@ -48,6 +48,8 @@ pub fn flames(
color: 0xffff_ffff,
light: 0xf000 | (u32::from(scene_light >> 4) * 16),
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
remaining -= 0.45;
+182 -117
View File
@@ -22,7 +22,9 @@ mod bundles;
mod campfire;
mod campfire_douse;
mod cartography;
mod cat_spawner;
mod chat;
mod chiseled_bookshelf;
mod client_ambient;
mod clipboard;
mod combat;
@@ -65,6 +67,7 @@ mod functions;
mod game_mode;
mod gamemode_switcher;
mod ghasts;
mod glowing;
mod golems;
mod growth;
mod hand;
@@ -132,6 +135,7 @@ mod persist;
mod perspective;
mod pickups;
mod piglins;
mod piston_push;
mod player_equip;
mod postprocess;
mod potions;
@@ -739,7 +743,7 @@ impl LevelAccess for GameLevel<'_> {
&self,
pos: BlockPos,
state: BlockStateId,
_direction: Direction,
direction: Direction,
) -> i32 {
if let Some(s) = self.registry.state(state)
&& s.name == "minecraft:detector_rail"
@@ -772,6 +776,11 @@ impl LevelAccess for GameLevel<'_> {
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal;
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
{
return signal;
}
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
}
fn block_entity_signal(
@@ -1281,6 +1290,8 @@ struct Game<'a> {
pending_pack_reload: bool,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
/// The link a chat click asks to open (`ConfirmLinkScreen`).
link_prompt: Option<String>,
dialogs: dialogs::DialogState,
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
@@ -1388,6 +1399,8 @@ struct Game<'a> {
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
pack_ids: Option<shader_packs::PackIds>,
started: Instant,
@@ -1523,6 +1536,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
rain_sound_time: i32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
lightning_seen: [u32; 2],
/// The head of the bed while sleeping; the visual timer also survives waking.
@@ -1605,10 +1620,19 @@ struct Game<'a> {
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
/// The fence knots a server tracks to this client, by entity id, at their
/// block (`LeashFenceKnotEntity`).
remote_knots: HashMap<u32, [i32; 3]>,
/// The entity ids a hosted server gives the knots its mobs hang on, by
/// block, while a lead is tied there.
knot_ids: HashMap<[i32; 3], u32>,
text_display_vertices: usize,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
/// `ProfileResolver.Cached`: `Dynamic` profiles (name- or id-only heads
/// and mannequins) resolve through the services in the background.
skin_profiles: RefCell<horizon_client::skins::Resolver>,
skin_slots: Vec<(String, usize)>,
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
trim_slots: Vec<(String, usize)>,
@@ -2510,6 +2534,15 @@ impl Game<'_> {
no_physics,
&self.res.motion().fluids,
);
// Entity.applyEffectsFromBlocks: bubble columns lift or drag the item.
horizon_client::motion::bubble_column_push(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
item.bounds(),
&mut item.velocity,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -2780,9 +2813,13 @@ impl Game<'_> {
self.process_level_events()
}
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
let note = self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]))
});
// NoteBlock.attack awards the statistic.
if note {
self.stats.custom("play_noteblock", 1);
}
self.process_level_events()
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
@@ -2978,20 +3015,29 @@ impl Game<'_> {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
self.process_level_events()
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
fn sync_moving_pistons(&mut self) {
let pistons = &mut self.world.moving_pistons;
pistons.clear();
pistons.extend(
self.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
);
let previous = std::mem::take(&mut self.world.moving_pistons);
let current: Vec<_> = self
.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
.collect();
self.world.moving_piston_fallback = previous
.into_iter()
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
.collect();
self.world.moving_pistons = current;
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
/// the side effects of each (a shrieker summoning a warden, drops, game
@@ -3093,6 +3139,11 @@ impl Game<'_> {
pos,
data,
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2018,
pos,
data,
} => self.enderman_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
self.eye_break_particles([pos.x, pos.y, pos.z]);
}
@@ -3443,15 +3494,9 @@ impl Game<'_> {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
self.container_opener_event(key, true);
let at = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
}
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
self.world.block_entities.get_or_insert_with(key, || {
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
@@ -3467,7 +3512,7 @@ impl Game<'_> {
];
self.resolve_container_maps(key, centre);
self.container = Some(key);
self.container_opener_event(key, true);
let first = self.container_opener_event(key, true);
self.post_block_game_event(
"minecraft:container_open",
[key.0, key.1, key.2],
@@ -3491,26 +3536,8 @@ impl Game<'_> {
if self.guarded_by_piglins(&name) {
self.anger_piglins(true);
}
let sound = if storage::is_shulker_box(&name) {
Some("minecraft:block.shulker_box.open")
} else if name.contains("chest") {
Some("minecraft:block.chest.open")
} else if &*name == "minecraft:barrel" {
Some("minecraft:block.barrel.open")
} else {
None
};
if let Some(sound) = sound {
self.audio.play(
sound,
Some([
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
]),
0.5,
1.0,
);
if first {
self.container_edge_sound(key, true);
}
} else if self.open_station(pos, &name) {
// Workstation menus: game/stations.rs.
@@ -3978,6 +4005,12 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
if swings {
self.swing_arm();
}
return Ok(true);
}
if self.use_composter(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -3994,6 +4027,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_beehive(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
if self.use_campfire_douse(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
@@ -4350,6 +4387,8 @@ impl Game<'_> {
self.release_used_item(&stack, ticks, hand_changed);
return;
}
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
self.trigger_using_item(&stack);
let Some(info) = self.res.items().get(&stack.item).cloned() else {
self.using = None;
return;
@@ -5050,9 +5089,14 @@ impl Game<'_> {
if let Some(host) = &self.lan {
host.hub.entity_event(host.host_entity, 3, feet);
}
self.screen = Screen::Dead;
self.capture(false);
self.ui.death_time = 0;
if self.ui.rules.bool("immediate_respawn") {
// Gui.setScreen: without the death screen the player respawns at once.
self.respawn_player()?;
} else {
self.screen = Screen::Dead;
self.capture(false);
self.ui.death_time = 0;
}
} else if dead {
// LivingEntity.tickDeath: 20 ticks after dying the player is
// removed, and Player.remove's inventoryMenu.removed drops what
@@ -5524,6 +5568,7 @@ impl Game<'_> {
};
self.draw_dialog(&mut batch);
self.draw_pack_prompt(&mut batch);
self.draw_link_prompt(&mut batch);
// Gui.extractRenderState: the debug overlay comes after the screen
// and the toasts, so it is over everything, menus included.
self.draw_debug_overlay(&mut batch, size);
@@ -5585,7 +5630,11 @@ impl Game<'_> {
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let strength = self.hand_strength.scale(0.0, self.main_hand_attack_speed());
// The 3D crosshair (F3) takes the 2D one's place.
if self.camera_type.first_person() && !spectator && !self.debug_crosshair() {
// Hud.extractCrosshair: a spectator only sees it over something to open.
if self.camera_type.first_person()
&& (!spectator || self.spectator_crosshair())
&& !self.debug_crosshair()
{
let attack = (indicator == 1).then_some(strength);
let full = attack.is_some_and(|a| {
hud_layout::full_attack_indicator(a, delay, self.crosshair_living())
@@ -5842,9 +5891,11 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_),
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -6114,9 +6165,11 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_),
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -6195,41 +6248,15 @@ impl Game<'_> {
if let Some(key) = self.container.take() {
// A cart's or chest boat's items go back to it.
self.close_cart_container(key);
self.container_opener_event(key, false);
let last = self.container_opener_event(key, false);
self.post_block_game_event(
"minecraft:container_close",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
let name = self
.res
.facts()
.get(self.world.block(key.0, key.1, key.2))
.name
.clone();
let sound = if storage::is_shulker_box(&name) {
Some("minecraft:block.shulker_box.close")
} else if &*name == "minecraft:ender_chest" {
Some("minecraft:block.ender_chest.close")
} else if name.contains("chest") {
Some("minecraft:block.chest.close")
} else if &*name == "minecraft:barrel" {
Some("minecraft:block.barrel.close")
} else {
None
};
if let Some(sound) = sound {
self.audio.play(
sound,
Some([
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
]),
0.5,
1.0,
);
if last {
self.container_edge_sound(key, false);
}
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
@@ -6749,6 +6776,7 @@ impl Game<'_> {
self.player_menu_sound(sound);
}
let crafted = std::mem::take(&mut menu.crafted);
let brewed = std::mem::take(&mut menu.brewed);
let equipped = std::mem::take(&mut menu.equipped);
for (i, stack) in after.into_iter().enumerate() {
if let Some(g) = game_of[i] {
@@ -6764,6 +6792,9 @@ impl Game<'_> {
self.player_drop(stack);
}
self.award_crafted(&crafted);
for stack in &brewed {
self.trigger_brewed_potion(stack);
}
if furnace_before > furnace_after
&& self.remote.is_none()
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
@@ -7802,6 +7833,8 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -8260,6 +8293,15 @@ impl Game<'_> {
None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic),
};
let landed = self.hurt_by_player(index, amount, source);
if landed {
let victim = self.mob_entity(&self.mobs[index]);
self.player_hit(
&victim,
creatures::damage_type(source.kind, true),
None,
amount,
);
}
if let Some(p) = self.player.as_mut() {
if p.attack_log.len() >= 64 {
p.attack_log.remove(0);
@@ -8353,15 +8395,24 @@ impl Game<'_> {
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
// its voice pitch from its own random).
let queued = std::mem::take(&mut self.mobs[index].outbox);
let mut infested = false;
for event in queued {
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
self.audio
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
} else {
// Left for the end of the tick.
self.mobs[index].outbox.push(event);
match event {
horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) => {
self.audio
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
}
horizon_client::mobs::MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
self.mobs[index].outbox.push(other);
}
}
}
if infested {
let id = self.mobs[index].id;
self.mob_infested(id);
}
self.weapon_hit(index);
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
// (inside `if (wasHurt)`), after damageStatsAndHearts.
@@ -8449,6 +8500,8 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
};
@@ -8648,6 +8701,7 @@ impl Game<'_> {
self.spawn_spiral = Default::default();
self.player_spawn = None;
self.creatures.reset_trader();
self.creatures.cat_tick = 0;
self.portals = Default::default();
self.experience = Experience {
level: self.level.xp_level,
@@ -9330,6 +9384,8 @@ impl Game<'_> {
else {
return Ok(false);
};
// BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
self.level_set(pos, new)?;
// BlockTransformer.TransformParticle.send.
self.transform_particle(&particle, pos);
@@ -9602,6 +9658,8 @@ impl Game<'_> {
if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old))
&& let Ok(state) = registry.resolve(&waxed)
{
// HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.level_set(hit.pos, state)?;
self.level_event_at(3003, hit.pos, 0);
self.audio.play(
@@ -10320,40 +10378,7 @@ impl Game<'_> {
if self.flash > 0 {
self.flash -= 1;
}
let (rain, _) = self.world.weather.levels(1.0);
let Some(eye) = self.eye_position() else {
return;
};
// WeatherEffectRenderer.tickRainParticles: occasional rain sounds.
if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 {
let r = &mut self.world.random;
let (dx, dz) = (
r.next_int_bound(21).unwrap_or(10) - 10,
r.next_int_bound(21).unwrap_or(10) - 10,
);
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
let top = self.rain_height(x, z);
if self.precipitation(x, top, z) == Some(false)
&& (top - eye[1] as i32).abs() < 10
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
{
let above = f64::from(top) > eye[1] + 1.0;
let (name, volume, pitch) = if above {
("minecraft:weather.rain.above", 0.1, 0.5)
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
// The Horizon Weather option off silences the rain too.
if self.settings.horizon.weather {
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
// The rain sounds come with the rain splashes (`tick_particles`).
}
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
fn weather_geometry(
@@ -11276,6 +11301,7 @@ impl Game<'_> {
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
@@ -11343,6 +11369,7 @@ impl Game<'_> {
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -11392,6 +11419,8 @@ impl Game<'_> {
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
hud: &hud,
glint_alpha: self.settings.file.double("glintStrength") as f32,
glint_ticks: self.glint_ticks(),
clouds: (!title
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
@@ -11409,6 +11438,7 @@ impl Game<'_> {
}),
entities: &entities,
entity_spans: &entity_spans,
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
lightning: horizon_client::gfx::lightning::Input {
bolts: &bolts,
@@ -11464,6 +11494,7 @@ impl Game<'_> {
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.glowing_vertices.get_mut() = glowing;
self.see_through_vertices = see_through;
result
}
@@ -12341,6 +12372,11 @@ impl ApplicationHandler for Game<'_> {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
}
WindowEvent::MouseInput { state, button, .. } if self.link_prompt.is_some() => {
if state == ElementState::Pressed && button == MouseButton::Left {
self.link_prompt_click();
}
}
WindowEvent::MouseInput { state, button, .. }
if self.server_packs.prompt.is_some() =>
{
@@ -12570,6 +12606,13 @@ impl ApplicationHandler for Game<'_> {
}
}
// ConfirmScreen.keyPressed: Escape answers no.
WindowEvent::KeyboardInput { event, .. } if self.link_prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
{
self.answer_link_prompt(false);
}
}
WindowEvent::KeyboardInput { event, .. } if self.server_packs.prompt.is_some() => {
if event.state == ElementState::Pressed
&& event.physical_key == PhysicalKey::Code(KeyCode::Escape)
@@ -13103,6 +13146,7 @@ impl<'a> Game<'a> {
world_loader: None,
pending_pack_reload: false,
server_packs: Default::default(),
link_prompt: None,
dialogs: Default::default(),
world_renderer_ready: false,
collision: res.collision(),
@@ -13179,6 +13223,7 @@ impl<'a> Game<'a> {
luminance: None,
see_through_vertices: Vec::new(),
entity_spans: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
started: now,
@@ -13269,6 +13314,7 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
@@ -13323,12 +13369,15 @@ impl<'a> Game<'a> {
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
text_displays: Default::default(),
remote_knots: Default::default(),
knot_ids: Default::default(),
text_display_vertices: 0,
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
.unwrap_or_default()
.join("assets/skins"),
)),
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: Vec::new(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
@@ -14070,6 +14119,12 @@ fn build_renderer(
&assets.sprite_mips,
&assets.animations,
(&atlas.pixels, atlas.width, atlas.height),
(&atlas.glint.0, atlas.glint.1, atlas.glint.2),
(
&atlas.glint_armor.0,
atlas.glint_armor.1,
atlas.glint_armor.2,
),
(&pack.forcefield.2, pack.forcefield.0),
&gui.panorama,
prepared,
@@ -14096,6 +14151,7 @@ fn loading_renderer(
) -> Result<Renderer> {
static FORCEFIELD: [u8; 4] = [0; 4];
static EMPTY_HUD: [u8; 4] = [0, 0, 0, 255];
static EMPTY_GLINT: [u8; 4] = [0; 4];
let empty_panorama = horizon_client::gfx::panorama::Faces {
size: 1,
pixels: [0u8; 4].repeat(6),
@@ -14106,6 +14162,10 @@ fn loading_renderer(
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
None => (EMPTY_HUD.as_slice(), 1, 1),
};
let (glint_pixels, glint_width, glint_height) = match atlas {
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
None => (EMPTY_GLINT.as_slice(), 1, 1),
};
// A glyph rasterized into an earlier texture is not in these pixels; the
// new texture gets it again when the character is drawn (see
// `build_renderer`).
@@ -14122,6 +14182,8 @@ fn loading_renderer(
&[],
&[],
(pixels, width, height),
(glint_pixels, glint_width, glint_height),
(glint_pixels, glint_width, glint_height),
(&FORCEFIELD[..], 1),
panorama,
None,
@@ -14169,9 +14231,12 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
debug_lines_on_top: &[],
debug_crosshair: &[],
hud,
glint_alpha: 0.75,
glint_ticks: 0.0,
nether_lighting: false,
entities: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
@@ -9,6 +9,7 @@ use horizon_client::{
text::{self, Style, Text},
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -82,6 +83,8 @@ pub(super) struct AdvState {
pub(super) quiet: bool,
/// The inventory last seen by `inventory_changed`.
pub(super) last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
pub(super) levitation: Option<([f64; 3], u64)>,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
@@ -180,6 +183,14 @@ impl adv::World for View<'_, '_> {
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"damage_type" => self
.0
.res
.stations()
.enchantments
.damage_tags
.get(tag)
.is_some_and(|t| t.contains(id)),
_ => false,
}
}
@@ -647,6 +658,28 @@ impl Game<'_> {
};
adv::conditions(&c["player"], &ctx, w)
});
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
// levitation; tick fires LEVITATION while there is one.
let levitating = self
.potions
.effects
.list
.iter()
.any(|e| e.id == "levitation");
match (levitating, self.adv.levitation) {
(true, None) => {
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.adv.levitation = at.map(|at| (at, self.ticks));
}
(false, Some(_)) => self.adv.levitation = None,
_ => {}
}
if let Some((start, since)) = self.adv.levitation {
self.trigger_levitation(start, (self.ticks - since) as i64);
}
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -681,6 +714,123 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
let me = self.player_entity();
self.trigger("minecraft:levitation", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
&& adv::range_i(&c["duration"], duration)
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let mut me = self.player_entity();
me.looking_at = self.looking_at_entity().map(Box::new);
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
});
}
/// The mob a 100 block ray from the eyes meets first, if no block is in
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
fn looking_at_entity(&self) -> Option<EntityView> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let (m, _) = self
.mobs
.iter()
.filter(|m| m.alive())
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
let from = Vec3::new(eye[0], eye[1], eye[2]);
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let world = self.mob_world_base(&[]);
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
clear.then(|| self.mob_entity(m))
}
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
pub(super) fn trigger_player_hurt_entity(
&mut self,
victim: &EntityView,
damage: &adv::DamageView,
) {
let me = self.player_entity();
let (victim, damage) = (victim.clone(), damage.clone());
self.trigger("minecraft:player_hurt_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&victim),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
&& adv::conditions(&c["entity"], &vctx, w)
});
}
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
/// `kind` damage of `amount` from `direct` (the player's own hand when
/// `None`), nothing blocked.
pub(super) fn player_hit(
&mut self,
victim: &EntityView,
kind: &str,
direct: Option<EntityView>,
amount: f32,
) {
let me = self.player_entity();
self.trigger_player_hurt_entity(
victim,
&adv::DamageView {
kind: kind.to_owned(),
is_direct: direct.is_none(),
direct: direct.or_else(|| Some(me.clone())),
source: Some(me),
original: amount,
actual: amount,
blocked: false,
},
);
}
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
let me = self.player_entity();
let damage = damage.clone();
self.trigger("minecraft:entity_hurt_player", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
});
}
/// A player condition plus one item (`item` key).
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
let me = self.player_entity();
@@ -708,6 +858,22 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
let me = self.player_entity();
let potion = stack.potion().map(str::to_owned);
self.trigger("minecraft:brewed_potion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::potions(&c["potion"], potion.as_deref(), w)
});
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
@@ -729,6 +895,35 @@ impl Game<'_> {
&& adv::item(&c["item"], &it, w)
});
}
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
/// and the items it caught.
pub(super) fn trigger_fishing_rod_hooked(
&mut self,
rod: &ItemStack,
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<ItemView> = items.iter().map(item_view).collect();
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&hooked),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["rod"], &rod, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
});
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
+16
View File
@@ -568,6 +568,22 @@ impl Game<'_> {
"generate_structures": options.structures,
"bonus_chest": options.bonus_chest(),
}),
Mode::CreateFlat {
name,
seed,
options,
flat,
..
} => json!({
"mode": "customize-flat",
"fields": { "name": name, "seed": seed },
"superflat": options.flat,
"biome": flat.settings().biome,
"layers": flat.settings().layers.iter().map(|layer| json!({
"block": layer.block,
"height": layer.height,
})).collect::<Vec<_>>(),
}),
Mode::Edit {
world,
name,
+1 -1
View File
@@ -161,7 +161,7 @@ impl Game<'_> {
}
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
self.mob_event_particles(id, 18);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.consume_held();
+17 -2
View File
@@ -711,6 +711,20 @@ impl Game<'_> {
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
// ArmorStandRenderer's HumanoidArmorLayer: the armour glint
// when `stack.hasFoil()`, as on a living entity.
let item_glint = self
.res
.items()
.enchantments
.props
.get(&stack.item)
.is_some_and(|p| p.glint);
let overlay = if stack.has_foil(item_glint) {
horizon_client::mobs::render::ARMOR_GLINT
} else {
0
};
let layer_poses = Poses::new(part);
horizon_client::mobs::render::draw(
part,
@@ -719,7 +733,7 @@ impl Game<'_> {
uv,
color,
packed,
0,
overlay,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
@@ -734,7 +748,7 @@ impl Game<'_> {
trim,
0xFFFF_FFFF,
packed,
0,
overlay,
out,
);
}
@@ -877,6 +891,7 @@ impl Game<'_> {
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
glint: self.glint(),
};
let profile = stack
.extra
+138 -5
View File
@@ -3,8 +3,10 @@
//! player breaks the hive or fire reaches it.
use super::Game;
use horizon_client::{
Result,
beehive::{self, Beehive, HiveHost, Release},
containers::BlockEntity,
items::ItemStack,
mobs::{Mob, Who, bee},
};
use horizon_core::{
@@ -194,19 +196,139 @@ impl Game<'_> {
}
self.anger_nearby_bees(at);
}
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
/// the honeycombs and a glass bottle a honey bottle from a full hive
/// (honey level 5), then the honey level resets and, unless a campfire
/// smokes the hive, the bees come out angry. True when it took the click.
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
let Some(held) = held else { return Ok(false) };
let shears = &*held.item == "minecraft:shears";
if !shears && &*held.item != "minecraft:glass_bottle" {
return Ok(false);
}
let state = self.world.block(pos[0], pos[1], pos[2]);
if !matches!(
&*self.res.facts().get(state).name,
"minecraft:beehive" | "minecraft:bee_nest"
) {
return Ok(false);
}
let prop = |key: &str| {
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get(key).cloned())
};
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
return Ok(false);
}
let facing = beehive::facing_step(prop("facing").as_deref());
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if shears {
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
for stack in self.sequence_loot(
"minecraft:harvest/beehive",
"minecraft:harvest/beehive",
Some(held),
) {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
self.damage_held(1);
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
} else {
// shrink(1), then the honey bottle in the hand, the inventory or
// on the ground.
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
let cell = self.held_mut();
if let Some(s) = cell.as_mut() {
s.count -= 1;
if s.count == 0 {
*cell = None;
}
}
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
if self.held().is_none() {
*self.held_mut() = Some(bottle);
} else if let Some(rest) = self.give(bottle)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
self.stats.add("used", &held.item, 1);
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
let key = (pos[0], pos[1], pos[2]);
if !smokey
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
{
self.anger_nearby_bees(pos);
}
// resetHoneyLevel.
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
self.level_set(pos, reset)?;
}
if !smokey
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
{
// emptyAllLivingFromHive: the bees within 4 blocks of the player
// go for them.
let released = hive.release_all(
pos,
facing,
None,
Release::Emergency,
&mut GameHive { game: self },
);
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if let Some(p) = self.player_view().map(|p| p.position) {
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
if m.distance_sqr(p) <= 16.0 {
m.ai.target = Some(Who::Player);
}
}
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, held);
Ok(true)
}
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
/// without a target go for a player in the same box.
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
/// without a target go for a random player in the same box, the window's
/// own or a guest (`None` is the window's player).
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
let min = Vec3::new(
f64::from(at[0]) - 8.0,
f64::from(at[1]) - 6.0,
f64::from(at[2]) - 8.0,
);
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
if !self
let mut players: Vec<Option<u32>> = Vec::new();
if self
.player_view()
.is_some_and(|p| p.bounds.intersects(area))
{
players.push(None);
}
if let Some(host) = self.lan.as_ref() {
players.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
.map(|g| Some(g.conn)),
);
}
if players.is_empty() {
return;
}
let mut bees: Vec<usize> = (0..self.mobs.len())
@@ -219,9 +341,20 @@ impl Game<'_> {
.collect();
bees.retain(|&i| self.mobs[i].ai.target.is_none());
for i in bees {
// Util.getRandom over the one player.
let _ = self.world.random.next_int_bound(1);
// Util.getRandom over the players.
let pick = self
.world
.random
.next_int_bound(players.len() as i32)
.unwrap_or(0) as usize;
self.mobs[i].ai.target = Some(Who::Player);
// The player a mob sees is its guest binding, else the window's.
if let Some(host) = self.lan.as_mut() {
match players[pick] {
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
None => host.mob_players.remove(&self.mobs[i].id),
};
}
}
}
}
@@ -76,23 +76,24 @@ impl Game<'_> {
/// A player opened (`open`) or closed the container at `key`: the opener
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
/// on both halves of a double chest (`CompoundContainer`). True when the
/// count went from 0 to 1 or back to 0: the only moments the container
/// plays its open or close sound.
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
// Spectators never count as openers.
if !self.spectator() {
self.container_openers_changed(key, open);
}
!self.spectator() && self.container_openers_changed(key, open)
}
/// `container_opener_event` for a player who is not a spectator (a guest).
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let pos = [key.0, key.1, key.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.lid_openers_changed(key, open);
let edge = self.lid_openers_changed(key, open);
if let Some(other) = self.chest_other_half(key) {
self.lid_openers_changed(other, open);
}
edge
}
_ if self
.res
@@ -102,26 +103,27 @@ impl Game<'_> {
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
self.barrel_openers_changed(key, open)
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
let (count, edge) = if open {
shulker.start_open()
} else {
shulker.stop_open()
};
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
edge
}
_ => {}
_ => false,
}
}
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
/// chest at `key` (the host and the guests are counted), then
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// and for the guests. True when the count crossed zero.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
@@ -139,12 +141,15 @@ impl Game<'_> {
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
(before == 0) != (count == 0)
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// True when the count crossed zero.
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
let edge = (before == 0) != (count == 0);
if edge {
self.set_bool_prop(
key,
"open",
@@ -152,6 +157,7 @@ impl Game<'_> {
horizon_world::level::flags::UPDATE_ALL,
);
}
edge
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
@@ -725,21 +731,38 @@ impl Game<'_> {
/// the player head at `pos`. The skin download is started here; while it
/// is in flight (or when it cannot be got) the profile's default skin
/// stands in. A head with no profile answers None: `SKIN_BY_TYPE`.
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<&str> {
pub(super) fn skull_skin_at(&self, pos: [i32; 3]) -> Option<String> {
let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
return None;
};
let skin = skull.skin.as_ref()?;
if let Some((url, location)) = &skin.download {
self.skin_downloads
.borrow_mut()
.state(skins::Kind::Skin, url);
if self.gui.atlas.sprites.contains_key(location) {
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
let download = skin.download.clone().or_else(|| {
let key = skin.dynamic.as_ref()?;
let skins::Resolve::Done(Some(profile)) = self.skin_profiles.borrow_mut().state(key)
else {
return None;
};
let (value, signature) = profile.textures()?;
let url = skins::unpack(&value, signature, &skins::TEXTURE_URIS).skin?;
let location = skins::skin_location(&skins::texture_hash(&url)?);
Some((url, location))
});
if let Some((url, location)) = download {
let mut downloads = self.skin_downloads.borrow_mut();
if self.gui.atlas.sprites.contains_key(&location) {
let _ = downloads.state(skins::Kind::Skin, &url);
return Some(location);
}
// Its slot was evicted: offer the image again.
if downloads.state(skins::Kind::Skin, &url) == skins::Download::Ready {
downloads.again(&location);
}
}
Some(skin.fallback)
Some(skin.fallback.to_owned())
}
}
+84 -20
View File
@@ -8,7 +8,7 @@ use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
item_data::WrittenBook,
item_data::{StackData, WrittenBook},
items::ItemStack,
menu::{Kind, Menu},
text::Text,
@@ -59,14 +59,86 @@ pub(super) enum BookScreen {
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
pages: Vec<Text>,
page: usize,
lectern: Option<(i32, i32, i32)>,
},
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
let Some(data) = data else { return Vec::new() };
let Some(w) = &data.written else {
return data.writable_pages.iter().map(Text::literal).collect();
};
let tagged = w.page_tags.len() == w.pages.len();
w.pages
.iter()
.enumerate()
.map(|(i, page)| match tagged.then(|| &w.page_tags[i]) {
Some(tag @ horizon_world::nbt::Tag::Compound(_)) => {
horizon_client::entity_nbt::name_text(tag)
}
_ => Text::literal(page),
})
.collect()
}
impl Game<'_> {
fn book_lines(&self, page: &str) -> Vec<Text> {
self.gui.atlas.font.split(&Text::literal(page), 114.0)
self.page_lines(&Text::literal(page))
}
fn page_lines(&self, page: &Text) -> Vec<Text> {
self.gui.atlas.font.split(page, 114.0)
}
/// `BookViewScreen.mouseClicked`: the click event of the text under the
/// cursor on the page.
fn book_link_at(&self, page: &Text, left: f32) -> Option<horizon_client::text::ClickEvent> {
let (mx, my) = self.cursor;
let row = ((my - 32.0) / 9.0).floor();
if row < 0.0 || row >= LINES as f32 {
return None;
}
let lines = self.page_lines(page);
let line = lines.get(row as usize)?.resolve_legacy();
let mut x = mx - (left + 36.0);
if x < 0.0 {
return None;
}
for (c, style) in line.chars() {
x -= self.gui.atlas.font.advance(c, style);
if x < 0.0 {
return style.click.clone();
}
}
None
}
/// `BookViewScreen.handleClickEvent`: a page link turns to its page
/// (`forcePage`), a web link goes through the confirm-link flow.
fn book_follow_link(
&mut self,
link: horizon_client::text::ClickEvent,
count: usize,
lectern: Option<(i32, i32, i32)>,
) {
use horizon_client::text::ClickEvent;
match link {
ClickEvent::ChangePage(target) => {
let target = (target - 1).clamp(0, count.saturating_sub(1) as i32);
let Some(BookScreen::View { page, .. }) = self.book.as_mut() else {
return;
};
if let Some(key) = lectern {
// LecternScreen.forcePage: the menu's button 100 + page.
if target != *page as i32 {
self.lectern_button(key, 100 + target);
}
} else if target != *page as i32 {
*page = target as usize;
}
}
ClickEvent::OpenUrl(url) => self.click_url(url),
_ => {}
}
}
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
/// A server's player writes in its own client's editor
@@ -115,11 +187,7 @@ impl Game<'_> {
}
}
"minecraft:written_book" => BookScreen::View {
pages: stack
.data()
.and_then(|d| d.written.as_ref())
.map(|w| w.pages.clone())
.unwrap_or_default(),
pages: view_pages(stack.data()),
page: 0,
lectern: None,
},
@@ -215,6 +283,7 @@ impl Game<'_> {
author,
generation: 0,
pages,
page_tags: Vec::new(),
});
*stack = written;
} else {
@@ -685,11 +754,7 @@ impl Game<'_> {
return false;
};
let Some(book) = &l.book else { return false };
let data = book.data();
let pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
let pages = view_pages(book.data());
let page = (l.page.max(0) as usize).min(pages.len().saturating_sub(1));
self.stats.custom("interact_with_lectern", 1);
self.open_book(BookScreen::View {
@@ -811,11 +876,7 @@ impl Game<'_> {
page,
}) = self.book.as_mut()
{
let data = book.as_ref().and_then(ItemStack::data);
*pages = data
.and_then(|d| d.written.as_ref().map(|w| w.pages.clone()))
.or_else(|| data.map(|d| d.writable_pages.clone()))
.unwrap_or_default();
*pages = view_pages(book.as_ref().and_then(ItemStack::data));
*page = (*page).min(pages.len().saturating_sub(1));
}
}
@@ -955,7 +1016,10 @@ impl Game<'_> {
lectern,
}) => {
let (page, count, lectern) = (*page, pages.len(), *lectern);
if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
// The text's click event first, then the buttons.
if let Some(link) = pages.get(page).and_then(|t| self.book_link_at(t, left)) {
self.book_follow_link(link, count, lectern);
} else if page + 1 < count && inside(left + 116.0, 159.0, 23.0, 13.0) {
self.turn_page(true);
} else if page > 0 && inside(left + 43.0, 159.0, 23.0, 13.0) {
self.turn_page(false);
@@ -1198,7 +1262,7 @@ impl Game<'_> {
} => {
indicator(batch, *page, pages.len());
if let Some(text) = pages.get(*page) {
for (i, line) in self.book_lines(text).iter().take(LINES).enumerate() {
for (i, line) in self.page_lines(text).iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
line,
@@ -0,0 +1,138 @@
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
//! in a village with enough occupied beds or inside a swamp hut.
use super::{
Dim, Game,
raids::{section, sections_to_village},
};
use horizon_client::mobs::{Species, spawn_rules};
use horizon_core::{geometry::Vec3, random::RandomSource};
const TICK_DELAY: i32 = 1200;
impl Game<'_> {
/// `CatSpawner.tick` (the overworld's custom spawner).
pub(super) fn tick_cat_spawner(&mut self) {
if self.dim != Dim::Overworld {
return;
}
self.creatures.cat_tick -= 1;
if self.creatures.cat_tick > 0 {
return;
}
self.creatures.cat_tick = TICK_DELAY;
// ServerLevel.getRandomPlayer: one of the living players.
let alive: Vec<Vec3> = self
.level_players()
.into_iter()
.filter(|p| p.alive)
.map(|p| p.position)
.collect();
if alive.is_empty() {
return;
}
let pick = self
.world
.random
.next_int_bound(alive.len() as i32)
.unwrap_or(0);
let p = alive[pick as usize];
let mut offset = || {
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
if self.world.random.next_boolean() {
-n
} else {
n
}
};
let x = offset();
let z = offset();
let pos = [
p.x.floor() as i32 + x,
p.y.floor() as i32,
p.z.floor() as i32 + z,
];
// hasChunksAt(pos +- 10)
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
});
if !loaded {
return;
}
let ok = spawn_rules::is_spawn_position_ok(
Species::Cat,
&self.mob_world_base(&[]),
pos[0],
pos[1],
pos[2],
);
if !ok {
return;
}
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
self.spawn_cat_in_village(pos);
} else {
let hut = self.structure_tag("minecraft:cats_spawn_in");
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
self.spawn_cat_in_hut(pos);
}
}
}
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
/// fewer than five cats around.
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
let mut beds: Vec<[i32; 3]> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Villager && m.alive())
.filter_map(|m| m.extra.bed)
.filter(|b| {
let d = |i: usize| i64::from(b[i] - pos[i]);
d(0).abs() <= 48
&& d(2).abs() <= 48
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
})
.collect();
beds.sort_unstable();
beds.dedup();
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
self.spawn_cat(pos, false);
}
}
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
if self.cats_near(pos, 16.0) == 0 {
self.spawn_cat(pos, true);
}
}
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
let [x, y, z] = pos.map(f64::from);
let min = Vec3::new(x - radius, y - 8.0, z - radius);
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
self.mobs
.iter()
.filter(|m| {
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
})
.count()
}
/// `spawnCat`: finalized for a natural spawn, black inside
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else {
return;
};
let black = self.structure_tag("minecraft:cats_spawn_as_black");
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.variant = 10;
}
}
}
+67 -1
View File
@@ -695,12 +695,78 @@ impl Game<'_> {
Some(ClickEvent::OpenFile(path)) => {
super::packs::open_folder(std::path::Path::new(&path));
}
Some(ClickEvent::OpenUrl(url)) => self.click_url(url),
Some(ClickEvent::SuggestCommand(command)) => {
if let Some(screen) = self.ui.chat_screen.as_mut() {
screen.suggest_command(&m, &c, &command);
}
}
None => {}
// Screen.defaultHandleClickEvent: a page link means nothing in chat.
Some(ClickEvent::ChangePage(_)) | None => {}
}
}
/// `Screen.clickUrlAction`: nothing with Web Links off, else the link
/// opens, after the confirmation unless Prompt on Links is off.
pub(super) fn click_url(&mut self, url: String) {
if !self.settings.chat_links {
return;
}
if self.settings.file.bool("chatLinksPrompt") {
self.link_prompt = Some(url);
} else {
super::packs::open_uri(&url);
}
}
/// `ConfirmLinkScreen` (untrusted): the link and its warning over the
/// chat, answered yes to open it.
fn link_prompt_layout(
&self,
url: &str,
size: (f32, f32),
) -> horizon_client::confirm_screen::Confirm {
let warning = Text::translate("chat.link.warning", &[]).with_color(0xFF_CCCC);
let message = Text::literal(url)
.append(Text::literal("\n"))
.append(warning);
horizon_client::confirm_screen::Confirm::new(
&self.gui.atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&message,
)
}
pub(super) fn draw_link_prompt(&self, batch: &mut HudBatch) {
let Some(url) = &self.link_prompt else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
horizon_client::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let (yes, no) = (
Text::translate("gui.yes", &[]),
Text::translate("gui.no", &[]),
);
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, [&yes, &no]);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
if let Some(url) = self.link_prompt.take()
&& yes
{
super::packs::open_uri(&url);
}
}
pub(super) fn link_prompt_click(&mut self) {
let Some(url) = &self.link_prompt else {
return;
};
let answer = self
.link_prompt_layout(url, self.gui_size())
.answer_at(self.cursor);
if let Some(yes) = answer {
self.audio.play_button_click();
self.answer_link_prompt(yes);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
@@ -0,0 +1,178 @@
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
//! (a book into the slot under the crosshair, or out of it) and
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
//! hopper or dropper moved (`machines::container_changed`). The slots' books
//! show through the block state's `slot_N_occupied`, which reaches a server's
//! clients with the block update.
use super::{Game, Hit, machines::dir_from_name};
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::{flags, hit_slot};
const BLOCK: &str = "minecraft:chiseled_bookshelf";
impl Game<'_> {
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
/// the main hand's turn: None when the block passes the click, else
/// whether the use succeeded and the arm swings (a click on an empty slot
/// without a book only consumes it).
pub(super) fn use_chiseled_bookshelf(
&mut self,
hit: &Hit,
held: Option<&ItemStack>,
) -> Option<bool> {
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
let info = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name == BLOCK)?;
let facing = info
.properties
.get("facing")
.and_then(|f| dir_from_name(f))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
let slot = hit_slot(
facing,
face,
BlockPos::new(pos[0], pos[1], pos[2]),
at,
2,
3,
)?;
let occupied = info
.properties
.get(&format!("slot_{slot}_occupied"))
.map(String::as_str)
== Some("true");
let book = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:bookshelf_books")
.is_some_and(|m| m.contains(&*s.item))
});
let centre = pos.map(|v| f64::from(v) + 0.5);
if let Some(book) = book
&& !occupied
{
// addBook: one book goes in (a creative player keeps theirs).
let book = book.clone();
self.stats.add("used", &book.item, 1);
let sound = if &*book.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.insert.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.insert"
};
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
count: 1,
..book.clone()
});
self.consume_held();
self.chiseled_bookshelf_update_state(pos, slot);
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.trigger_block("minecraft:item_used_on_block", pos, &book);
return Some(true);
}
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != horizon_client::first_person::Hand::Main {
return None;
}
if !occupied {
return Some(false);
}
// removeBook: the book goes to the inventory, or is dropped.
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
self.chiseled_bookshelf_update_state(pos, slot);
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.pickup"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.give_or_drop(retrieved);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
Some(true)
}
/// The bookshelf's six slots (made if the block has no block entity yet).
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
let entity = self
.world
.block_entities
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
BlockEntity::ChiseledBookshelf(Box::default())
});
match entity {
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
_ => None,
}
}
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
/// one touched, the block state takes the slots' occupancy
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
else {
return;
};
b.last_slot = slot as i32;
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
for (i, occupied) in occupied.into_iter().enumerate() {
state
.properties
.insert(format!("slot_{i}_occupied"), occupied.to_string());
}
let Some(new) = registry.id(&state) else {
return;
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
/// The books a hopper or dropper put in or took out: the slot whose
/// occupancy no longer matches the block state is the one `setItem` or
/// `removeItem` updated.
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
return;
};
let Some(info) = self
.pack
.assets
.registry
.state(self.world.block(pos.0, pos.1, pos.2))
else {
return;
};
let moved = (0..6).rev().find(|i| {
let shown = info
.properties
.get(&format!("slot_{i}_occupied"))
.map(String::as_str);
b.items[*i].is_some() != (shown == Some("true"))
});
if let Some(slot) = moved {
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
}
}
}
@@ -17,8 +17,10 @@ impl Game<'_> {
return;
}
let mut events = Vec::new();
let random = self.particles.level_random();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
ambient::client_particles(m, random, &mut events);
}
for event in events {
match event {
+24
View File
@@ -45,6 +45,13 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
pub(super) hive_emptying_blast: bool,
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
/// the mobs the blast hurts record it as their attacker.
pub(super) explosion_owner: Option<u32>,
/// Charged creepers whose blast just went off and that have not dropped
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
@@ -131,6 +138,19 @@ impl Game<'_> {
self.recheck_combat();
for e in events {
let (name, amount) = match e {
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
// Player.actuallyHurt (absorption hearts) and
// LivingEntity.hurtServer (the shield, only for an accepted
// hit), in tenths.
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
match *name {
"resisted" => self.stats.custom("damage_resisted", *amount),
"absorbed" => self.stats.custom("damage_absorbed", *amount),
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
_ => {}
}
continue;
}
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
let Some(d) = self.motion.health.damage.definition(*source) else {
continue;
@@ -177,6 +197,10 @@ impl Game<'_> {
direct,
});
self.combat.last_damage = now;
// InfestedMobEffect.onMobHurt.
if self.potions.effects.get("infested").is_some() {
self.player_infested();
}
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
+1 -2
View File
@@ -2049,8 +2049,7 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
/// RandomizableContainer.tryLoadLootTable for a tag a command gives a
+282 -64
View File
@@ -46,6 +46,9 @@ 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>,
/// 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>,
/// 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)>,
@@ -53,6 +56,8 @@ pub(super) struct Creatures {
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// The cat spawner's countdown (`CatSpawner.nextTick`).
pub cat_tick: i32,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
@@ -158,6 +163,8 @@ fn flame_geometry(
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
h -= 0.45;
@@ -199,6 +206,7 @@ impl Game<'_> {
off_hand: None,
head: self.armor[0].as_ref().map(|s| s.item.clone()),
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
invisible: self.potions.effects.get("invisibility").is_some(),
spectator: self.spectator(),
gold_armor: self.wearing_gold(),
hurt_by: self.player_hurt_by(),
@@ -415,6 +423,7 @@ impl Game<'_> {
world: &self.world,
registry: self.pack.assets.registry,
facts: self.res.facts(),
fluid_tables: &self.motion.fluids,
movement: &self.motion.blocks,
collision: self.collision,
data: self.res.mob_data(),
@@ -432,11 +441,13 @@ impl Game<'_> {
block_tags: &self.res.items().block_tags,
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
teams: std::sync::Arc::new(self.mob_teams()),
items: Some(self.res.items()),
lid_openers: &self.lid_openers,
difficulty: self.local_difficulty(
self.player
.as_ref()
@@ -493,6 +504,7 @@ impl Game<'_> {
if self.ui.rules.bool("spawn_mobs") {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
}
self.tick_raids();
self.tick_creature_entities()
@@ -525,6 +537,7 @@ impl Game<'_> {
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
@@ -619,6 +632,11 @@ impl Game<'_> {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
@@ -699,6 +717,16 @@ impl Game<'_> {
for m in dead {
self.mob_died(&m);
}
for mut m in corpses {
let names: Vec<&'static str> = m
.extra
.effects
.iter()
.map(|e| e.0.trim_start_matches("minecraft:"))
.collect();
let bounds = m.bounds();
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
}
self.handle_mob_events(events)?;
self.lan_guest_mob_events(guest_events);
self.tick_projectiles()?;
@@ -771,6 +799,7 @@ impl Game<'_> {
self.villager_murdered(m, by_player);
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
self.golem_drop_equipment(&mut dying);
@@ -901,6 +930,18 @@ impl Game<'_> {
}
}
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = [
m.position.x,
m.position.y + f64::from(m.height()) / 2.0,
m.position.z,
];
let look = m.view_vector();
self.infested_hurt(at, look, Some(mob));
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
@@ -965,6 +1006,9 @@ impl Game<'_> {
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -1046,7 +1090,7 @@ impl Game<'_> {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius) => {
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
@@ -1062,7 +1106,14 @@ impl Game<'_> {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
let result = self.explode(at, radius);
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind.
let result = if matches!(species, Species::Creeper | Species::Wither) {
self.with_hive_emptying_blast(|g| g.explode(at, radius))
} else {
self.explode(at, radius)
};
self.combat.explosion_source = None;
result?
}
@@ -1073,6 +1124,7 @@ impl Game<'_> {
} => {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
@@ -1081,20 +1133,27 @@ impl Game<'_> {
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
if let MobEvent::Sound(s, _, volume, pitch) = event {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
} else {
// Left for the end of the tick.
t.outbox.push(event);
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
@@ -1140,6 +1199,7 @@ impl Game<'_> {
}
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
MobEvent::Infested { mob } => self.mob_infested(mob),
MobEvent::Gift { target, item } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
&& m.saddle_item().is_none()
@@ -1190,9 +1250,9 @@ impl Game<'_> {
[0.0, 0.2, 0.0],
10,
),
MobEvent::SetBlock { pos, state } => {
self.world.set(pos[0], pos[1], pos[2], state)?
}
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
@@ -1303,12 +1363,17 @@ impl Game<'_> {
}
}
MobEvent::AroundSelf {
mob,
event,
kind,
feet,
width,
height,
burst,
} => self.particles_around(kind, feet, [width, height], burst),
} => {
self.lan_entity_event(mob as i32, event, feet);
self.particles_around(kind, feet, [width, height], burst)
}
MobEvent::ParticleAt { kind, at, velocity } => {
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
let options = horizon_client::particles::ParticleOptions::simple(ty);
@@ -1419,6 +1484,22 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::ShedScute { mob, at, pitch } => {
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
let table = "minecraft:gameplay/armadillo_shed";
let drops = self.sequence_loot(table, table, None);
for d in drops {
self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d);
}
self.audio.play(
"minecraft:entity.armadillo.scute_drop",
Some(at),
1.0,
pitch,
);
let ctx = self.who_context(Some(Who::Mob(mob)), None);
self.post_game_event("minecraft:entity_place", at, ctx);
}
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
@@ -1547,6 +1628,8 @@ impl Game<'_> {
q.extra.in_love = 0;
}
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
self.mob_event_particles(parent, 18);
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
if p.species() == Species::Sniffer {
self.lay_sniffer_egg(&p);
@@ -1819,6 +1902,10 @@ impl Game<'_> {
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
(from[0] - player.position.x, from[2] - player.position.z)
});
// dealDefaultKnockback's `indicateDamage`, unless blocked.
if o.blocked <= 0.0 {
player.health.indicate_damage(xd, zd, player.yaw);
}
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
@@ -1831,6 +1918,23 @@ impl Game<'_> {
self.thorns(a);
}
}
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
kind: damage_type.to_owned(),
direct: source.clone().filter(|_| !projectile),
is_direct: !projectile,
source,
original: amount,
actual: amount - outcome.blocked,
blocked: outcome.blocked > 0.0,
});
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
}
@@ -2063,6 +2167,21 @@ impl Game<'_> {
if super::remote_projectiles::has_smoke_trail(&p.kind) {
trails.push(p.position);
}
// ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn.
if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) {
let (at, h) = (p.position, 0.125);
horizon_client::motion::bubble_column_push(
w.world,
w.registry,
w.facts,
w.collision,
Aabb::new(
Vec3::new(at.x - h, at.y, at.z - h),
Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h),
),
&mut p.velocity,
);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -2488,14 +2607,46 @@ impl Game<'_> {
.is_some_and(|m| m.damage(amount, source));
if p.owner == Some(Who::Player) {
self.last_hurt_by_player.insert(id, self.lan_acting);
if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) {
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let spectral = p.arrow.as_ref().is_some_and(|a| {
&*a.pickup_item.item == "minecraft:spectral_arrow"
});
let direct = horizon_client::advancements::EntityView {
kind: if spectral {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
}
.into(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
}
}
hurt
}
};
// doKnockback: Punch pushes along the flight.
// doKnockback: Punch pushes along the flight, less the target's
// knockback resistance.
let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt();
if hurt && p.bonus.punch > 0 && flight > 1e-6 {
let k = f64::from(p.bonus.punch) * 0.6 / flight;
let resistance = match who {
Who::Player => self
.player
.as_mut()
.and_then(|pl| pl.attribute("minecraft:knockback_resistance").ok())
.unwrap_or(0.0),
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.attrs.knockback_resistance),
};
let push = f64::from(p.bonus.punch) * 0.6 * (1.0 - resistance).max(0.0);
if hurt && push > 0.0 && flight > 1e-6 {
let k = push / flight;
self.handle_mob_events(vec![MobEvent::Push {
target: who,
velocity: [p.velocity.x * k, 0.1, p.velocity.z * k],
@@ -2531,6 +2682,7 @@ impl Game<'_> {
};
m.damage(amount, source);
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
}
(ProjectileKind::Egg, hit) => {
@@ -2556,6 +2708,7 @@ impl Game<'_> {
}
}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:egg");
}
(ProjectileKind::EnderPearl, hit) => {
@@ -2659,7 +2812,10 @@ impl Game<'_> {
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:explosion", owner);
}
self.explode(atp, *power as f32)?;
self.combat.explosion_owner = self.combat.projectile_owner;
let result = self.explode(atp, *power as f32);
self.combat.explosion_owner = None;
result?;
}
(
ProjectileKind::Potion {
@@ -2805,10 +2961,17 @@ impl Game<'_> {
}
Ok(())
}
/// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the
/// projectile is discarded (the clients lose it when the tracker next runs).
fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) {
if id != 0 {
self.lan_entity_event(id as i32, 3, at);
}
}
/// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the
/// thrown item where it broke, at rest for a snowball, scattered by the
/// client entity's random (the particle engine's here) for an egg.
fn item_burst(&mut self, at: [f64; 3], item: &str) {
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
let egg = item != "minecraft:snowball";
for _ in 0..8 {
@@ -2844,6 +3007,10 @@ impl Game<'_> {
if self.name_mob(index) {
return Ok(true);
}
if self.spawn_egg_offspring(index) {
self.swing_arm();
return Ok(true);
}
{
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
if self.interact_leash(index, &item) {
@@ -2946,6 +3113,7 @@ impl Game<'_> {
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let acting = self.acting_player_uuid();
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
@@ -3004,6 +3172,7 @@ impl Game<'_> {
at: pos,
setup,
}])?;
self.damage_held_tool();
}
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
@@ -3050,6 +3219,7 @@ impl Game<'_> {
}
Species::Wolf
if m.extra.tame
&& m.extra.owner == Some(acting)
&& item.ends_with("_dye")
&& horizon_client::mobs::render::DYE_NAMES
.iter()
@@ -3072,7 +3242,9 @@ impl Game<'_> {
consume = true;
}
Species::Wolf
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
if m.extra.tame
&& m.extra.owner == Some(acting)
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
{
m.extra.sitting = !m.extra.sitting;
m.ai.jumping = false;
@@ -3092,20 +3264,14 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
if tamed {
horizon_client::mobs::tame::set_tame(m, true);
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
m.persistent = true;
m.nav_stop();
m.ai.target = None;
m.extra.sitting = true;
}
// TamableAnimal.spawnTamingParticles.
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3119,6 +3285,7 @@ impl Game<'_> {
// Cat.mobInteract: dye, heal or sit when tame; fish may tame a wild one.
Species::Cat
if m.extra.tame
&& m.extra.owner == Some(acting)
&& item.ends_with("_dye")
&& horizon_client::mobs::render::DYE_NAMES
.iter()
@@ -3141,7 +3308,12 @@ impl Game<'_> {
m.persistent = true;
consume = true;
}
Species::Cat if m.extra.tame && is_food && m.health < m.attrs.max_health => {
Species::Cat
if m.extra.tame
&& m.extra.owner == Some(acting)
&& is_food
&& m.health < m.attrs.max_health =>
{
let heal = self
.res
.items()
@@ -3154,7 +3326,9 @@ impl Game<'_> {
eat = eating_sound(m, &mut self.world.random);
}
Species::Cat
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
if m.extra.tame
&& m.extra.owner == Some(acting)
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
{
m.extra.sitting = !m.extra.sitting;
self.world.modified.insert((
@@ -3168,17 +3342,12 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
if tamed {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
m.extra.sitting = true;
}
eat = eating_sound(m, &mut self.world.random);
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3226,13 +3395,8 @@ impl Game<'_> {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
}
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3263,6 +3427,11 @@ impl Game<'_> {
Some(sound) => {
consume = true;
eat = sound;
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
self.mob_event_particles(self.mobs[index].id, 18);
}
}
// Animal.mobInteract still consumes the client click when
// an adult cannot breed, without swinging or taking food.
@@ -3382,23 +3551,9 @@ impl Game<'_> {
}
true
}
/// `ItemStack.hurtAndBreak(1, ...)` on the held tool: Unbreaking, break sound and stat.
pub(super) fn damage_held_tool(&mut self) {
if self.creative {
return;
}
let slot = self.held_slot();
let max_damage = self
.held()
.and_then(|s| self.res.items().get(&s.item))
.map_or(0, |i| i.max_damage);
if max_damage > 0
&& let Some(stack) = self.stack_in_mut(slot).as_mut()
{
stack.damage += 1;
if stack.damage >= max_damage {
*self.stack_in_mut(slot) = None;
}
}
self.damage_held(1);
}
/// Throwables and spawn eggs (SnowballItem/EggItem/EnderpearlItem/SpawnEggItem.use).
pub(super) fn throw_item(&mut self, stack: &ItemStack) -> Result<bool> {
@@ -3459,6 +3614,45 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob(&kind, at, true) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let Some(hit) = self.pick() else {
return Ok(false);
@@ -3590,8 +3784,10 @@ impl Game<'_> {
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
if mob.invisible {
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
if mob.invisible && outline.is_none() {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
@@ -3660,9 +3856,18 @@ impl Game<'_> {
out,
);
self.mob_block_geometry(mob, camera, alpha, packed, out);
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if !mob.glowing && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
self.cull_entity_vertices(out, entity_start);
}
// GuardianRenderer: the laser to the locked target.
@@ -3710,7 +3915,15 @@ impl Game<'_> {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0 && self.entity_glowing(a.id) {
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
let light = self.world.light(
@@ -3839,9 +4052,14 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
+121 -5
View File
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
const AMBIENT_INTERVAL: i32 = 80;
#[derive(Default)]
pub(super) struct Fight {
@@ -48,6 +50,9 @@ pub(super) struct Fight {
respawn: Option<(Respawn, i32)>,
/// The four crystals on the exit portal driving the respawn.
respawn_crystals: Vec<Vec3>,
/// The player behind the crystal blast under way, whose `explosion`
/// damage source names them (`EndCrystal.hurtServer`).
blast_by: Option<FightWho>,
dirty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -262,6 +267,9 @@ pub(super) struct Dragon {
/// only damage above the last hit lands, and only the excess.
invulnerable: i32,
last_hurt: f32,
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
/// the kill, and the dragon tick of their last hit.
hurt_by_player: Option<(FightWho, i32)>,
pub death_time: i32,
pub flap: f32,
pub old_flap: f32,
@@ -270,6 +278,8 @@ pub(super) struct Dragon {
flame_count: i32,
sitting_damage: f32,
growl: i32,
/// `Mob.ambientSoundTime`.
ambient_time: i32,
ticks: i32,
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
history: [(f64, f32); 64],
@@ -385,6 +395,7 @@ impl Dragon {
hurt_time: 0,
invulnerable: 0,
last_hurt: 0.0,
hurt_by_player: None,
death_time: 0,
flap: 0.0,
old_flap: 0.0,
@@ -397,6 +408,7 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -426,6 +438,10 @@ impl Dragon {
pub fn alive(&self) -> bool {
self.health > 0.0
}
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
fn down(&self) -> bool {
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
}
fn part_box(&self, i: usize) -> Aabb {
let (w, h) = PART_SIZES[i];
let p = self.parts[i].0;
@@ -893,8 +909,12 @@ impl Game<'_> {
.any(|p| p.distance_squared(c.position) < 0.01);
if damage == CrystalDamage::Direct {
let at = [c.position.x, c.position.y, c.position.z];
// The blast's damage source carries the player, so the dragon
// remembers them before the blast can kill it.
self.fight.blast_by = by;
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
self.fight.blast_by = None;
}
if respawn_crystal {
self.abort_respawn();
@@ -905,7 +925,7 @@ impl Game<'_> {
if let Some(d) = self.dragon.as_mut() {
if d.nearest_crystal == Some(index) {
d.nearest_crystal = None;
self.hurt_dragon(0, 10.0, true, false);
self.hurt_dragon(0, 10.0, true, by, false);
} else if let Some(n) = d.nearest_crystal.as_mut()
&& *n > index
{
@@ -977,18 +997,46 @@ impl Game<'_> {
self.destroy_crystal(index, by, CrystalDamage::Direct);
Hurt::Landed { full: true }
} else if id >= SPECIAL {
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
let by = by_player.then(|| self.acting_player());
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
} else {
Hurt::Refused
}
}
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
/// `by` is the player behind the hit, remembered for the kill credit.
fn hurt_dragon(
&mut self,
part: usize,
damage: f32,
counts: bool,
by: Option<FightWho>,
arrow: bool,
) -> Hurt {
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
let was_down = d.down();
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
let hurt = d.hurt(part, damage, counts, arrow);
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
// (100 ticks); a hit the damage cooldown absorbs returns before that.
if counts
&& matches!(hurt, Hurt::Landed { .. })
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
&& let Some(who) = by
{
d.hurt_by_player = Some((who, d.ticks));
}
if !was_down && d.down() {
self.credit_dragon_kill();
}
let Some(d) = self.dragon.as_mut() else {
return hurt;
};
if hurt == (Hurt::Landed { full: true }) {
// Mob.playHurtSound: the ambient sound clock starts again.
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
@@ -996,6 +1044,47 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
/// who hurt the dragon within the last 100 ticks. A guest's kill is
/// theirs: their statistics, scores and advancements.
fn credit_dragon_kill(&mut self) {
let Some(d) = self.dragon.as_ref() else {
return;
};
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
return;
};
let view = horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
};
let id = d.id;
let guest = match who {
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
_ => None,
};
let credited = guest.is_some_and(|conn| {
let Some(mut host) = self.lan.take() else {
return false;
};
let done = self
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
.is_some();
self.lan = Some(host);
done
});
if !credited {
self.award_dragon_kill(id, &view);
}
}
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
@@ -1661,6 +1750,24 @@ impl Game<'_> {
self.tick_dragon_death();
return;
}
// Mob.baseTick: the ambient sound, rarer right after one.
let roll = d.random.next_int_bound(1000).unwrap_or(999);
let waited = d.ambient_time;
d.ambient_time += 1;
if roll < waited {
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
// LivingEntity.getVoicePitch, from the dragon's own random.
let r = &mut d.random;
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.ender_dragon.ambient",
Some(at),
5.0,
pitch,
);
}
let d = self.dragon.as_mut().expect("dragon");
// Flapping, growls and the flap sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
@@ -2140,10 +2247,12 @@ impl Game<'_> {
c.at[1],
c.at[2] + f64::from(a.sin() * d),
];
self.particles.add(
self.particles.add_with(
&ParticleOptions::simple(ParticleType::DragonBreath),
at,
[0.0, 0.0, 0.0],
false,
true,
);
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
@@ -2512,6 +2621,8 @@ impl Game<'_> {
layer: 2,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2577,6 +2688,8 @@ impl Game<'_> {
layer: 6,
color: c,
light: wrap_light,
glint_uv: [0.0, 0.0],
});
}
(last_sin, last_cos, last_u) = (sin, cos, uu);
@@ -2622,6 +2735,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: 240 | 240 << 8,
glint_uv: [0.0, 0.0],
});
}
}
@@ -2747,8 +2862,9 @@ impl Game<'_> {
let (lo, hi) = (b.min_position(), b.max_position());
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
});
let by = self.fight.blast_by;
for (part, amount) in hits {
self.hurt_dragon(part, amount, true, false);
self.hurt_dragon(part, amount, true, by, false);
}
}
}
@@ -157,8 +157,10 @@ impl Game<'_> {
items: Some(self.res.items()),
..Default::default()
};
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
// minecraft:post_attack effects of the attacker's weapon on the victim:
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
@@ -168,24 +170,33 @@ impl Game<'_> {
.into_iter()
.flatten()
{
if effect["enchanted"] == "attacker"
&& effect["affected"] == "victim"
&& effect["effect"]["type"] == "minecraft:ignite"
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if ok {
ignite = ignite.max(horizon_client::enchant::level_value(
&effect["effect"]["duration"],
*level,
));
}
continue;
}
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
mob_effects.push((e.clone(), *level));
}
}
}
@@ -195,8 +206,41 @@ impl Game<'_> {
{
m.ignite_for_ticks((ignite * 20.0) as i32);
}
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
self.post_attack_bursts(&held, &kind);
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
use horizon_client::enchant::level_value;
let Some(m) = self.mobs.get_mut(mob) else {
return;
};
let ids: Vec<&str> = match &effect["to_apply"] {
serde_json::Value::String(id) => vec![id.as_str()],
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
_ => Vec::new(),
};
if ids.is_empty() {
return;
}
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
let mut between = |min: &str, max: &str| {
let (lo, hi) = (
level_value(&effect[min], level),
level_value(&effect[max], level),
);
m.random.next_float() * (hi - lo) + lo
};
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
m.add_effect(id, ticks, amplifier);
}
}
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
/// a burst at the player's feet that only knocks back and triggers
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
@@ -307,8 +351,10 @@ impl Game<'_> {
continue;
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
let loss = self.durability_loss(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
s.damage += loss;
if self
.res
.items()
+260 -33
View File
@@ -5,6 +5,7 @@
use super::{Dim, Game};
use horizon_client::{
Result,
containers::{BlockEntity, EndGateway},
environment::Environment,
items::ItemStack,
mesher::Vertex,
@@ -27,14 +28,64 @@ const GATEWAY_Y: i32 = 75;
const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
Find { gateway: [i32; 3] },
/// `exact` is the gateway's own `ExactTeleport`, read while its chunk is loaded.
Find { gateway: [i32; 3], exact: bool },
/// A known exit (exact, or findExitPosition around it).
Known { exit: [i32; 3], exact: bool },
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
gateway: [i32; 3],
entity: Option<&BlockEntity>,
legacy: Option<([i32; 3], bool)>,
) -> Self {
let configured = match entity {
Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)),
_ => None,
};
match configured.or(legacy) {
Some((exit, exact)) => Self::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known {
exit: END_SPAWN,
exact: true,
},
None => Self::Find {
gateway,
exact: exact_teleport(entity),
},
}
}
}
/// `TheEndGatewayBlockEntity.exactTeleport` of the block entity at a gateway
/// (false without one).
fn exact_teleport(entity: Option<&BlockEntity>) -> bool {
matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport)
}
/// Moves an exit that only the side file knew into the gateway's block entity, unless the block
/// entity has its own; true when it took the saved one.
fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool {
if entity.exit_portal.is_some() {
return false;
}
entity.exit_portal = Some(exit);
entity.exact_teleport = exact;
true
}
/// The end of `getPortalPosition`: the exit itself when the teleport is exact,
/// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`).
fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] {
if exact { exit } else { find(exit) }
}
#[derive(Default)]
pub(super) struct EndState {
/// Gateway position -> (exit, exact teleport); loaded lazily from the save.
@@ -247,6 +298,8 @@ impl Game<'_> {
layer: 2,
color,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
@@ -606,7 +659,9 @@ impl Game<'_> {
/// EnderDragonFight without a dragon: once the centre of the main island
/// is loaded, place the active exit portal (EndPodiumFeature) and the
/// first gateway (spawnNewGateway) if the portal is not there yet.
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
/// scanState and spawnExitPortal, only while the fight has no exit portal
/// location: a podium that is later broken stays broken.
fn ensure_exit_portal(&mut self) -> Result<()> {
if self.travelling.is_some()
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
@@ -616,8 +671,14 @@ impl Game<'_> {
}
let won = self.fight.previously_killed;
let gateway = self.first_gateway();
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
// The podium exists; the gateway may have been out of range when it was placed.
if self.exit_portal().is_none() {
// findExitPortal remembers the location of a podium that is already there.
if let Some(level) = self.podium_level() {
self.set_exit_portal([0, level, 0]);
}
}
if self.exit_portal().is_some() {
// The gateway may have been out of range when it was placed.
if won
&& self.world.loaded(gateway[0], gateway[2])
&& &*self.block_name(gateway) != "minecraft:end_gateway"
@@ -648,17 +709,40 @@ impl Game<'_> {
}
Ok(())
}
/// The portal level of the podium around the centre column, read from the
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
/// below it, the torch column) so a broken column does not hide it.
fn podium_level(&self) -> Option<i32> {
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
for z in -4..=4i32 {
for x in -4..=4i32 {
let flat = f64::from(x * x + z * z);
if (6.25..12.25).contains(&flat) {
offsets.push([x, 0, z]);
} else if flat + 1.0 < 6.25 {
offsets.push([x, -1, z]);
}
}
}
(1..253)
.map(|y| {
let score = offsets
.iter()
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
.count();
(score, y)
})
.filter(|&(score, _)| score >= 3)
.max()
.map(|(_, y)| y)
}
/// `spawnExitPortal(true)` after the dragon dies.
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
let origin = match self.exit_portal() {
Some(o) => o,
None => {
// The rim's level: the top of the bedrock column minus three.
let top = (0..256)
.rev()
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
.unwrap_or(64);
[0, top - 3, 0]
// The rim's level, found without assuming the centre column is intact.
[0, self.podium_level().unwrap_or(61), 0]
}
};
self.place_podium(origin, true)
@@ -761,6 +845,11 @@ impl Game<'_> {
}
}
}
self.world
.block_entities
.get_or_insert_with((origin[0], origin[1], origin[2]), || {
BlockEntity::EndGateway(Default::default())
});
Ok(())
}
@@ -782,8 +871,7 @@ impl Game<'_> {
self.end.exits.as_mut().expect("loaded exits")
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
self.gateway_exits().insert(gateway, (exit, exact));
fn save_gateway_exits(&mut self) -> Result<()> {
let list: Vec<_> = self
.gateway_exits()
.iter()
@@ -795,6 +883,35 @@ impl Game<'_> {
Ok(())
}
/// Drops a gateway's entry from the side file: its block entity is the source of truth.
fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> {
if self.gateway_exits().remove(&gateway).is_some() {
self.save_gateway_exits()?;
}
Ok(())
}
fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> {
// TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit).
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
// The gateway's chunk can be unloaded by now (travel waits for the destination): the
// exit then waits in the side file until the gateway is used again (`gateway_job`).
if self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
g.exit_portal = Some(exit);
g.exact_teleport = exact;
self.world.modified.insert(chunk);
return self.forget_gateway_exit(gateway);
}
self.gateway_exits().insert(gateway, (exit, exact));
self.save_gateway_exits()
}
/// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition:
/// travel within the End, waiting for the destination chunks to load.
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
@@ -814,25 +931,39 @@ impl Game<'_> {
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let known = self.gateway_exits().get(&gateway).copied();
let job = match known {
Some((exit, exact)) => GatewayJob::Known { exit, exact },
// Worldgen return gateways (end_gateway_return) lead exactly to the End spawn.
None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => {
GatewayJob::Known {
exit: END_SPAWN,
exact: true,
}
let mut known = self.gateway_exits().get(&gateway).copied();
// Once the gateway's chunk is loaded its block entity is the source of truth: an exit the
// side file kept for it moves there, and the entry is dropped, so removing or replacing
// the exit (`/data remove block`) is not undone by an old entry.
let key = (gateway[0], gateway[1], gateway[2]);
let chunk = (gateway[0] >> 4, gateway[2] >> 4);
if let Some(saved) = known
&& self.world.chunks.contains_key(&chunk)
&& let BlockEntity::EndGateway(g) = self
.world
.block_entities
.get_or_insert_with(key, || BlockEntity::EndGateway(Default::default()))
{
if adopt_saved_exit(g, saved) {
self.world.modified.insert(chunk);
}
None => GatewayJob::Find { gateway },
};
known = None;
let _ = self.forget_gateway_exit(gateway);
}
let job = GatewayJob::for_gateway(
gateway,
self.world
.block_entities
.get(&(gateway[0], gateway[1], gateway[2])),
known,
);
let destination = match job {
GatewayJob::Known { exit, .. } => Vec3::new(
f64::from(exit[0]) + 0.5,
f64::from(exit[1]),
f64::from(exit[2]) + 0.5,
),
GatewayJob::Find { gateway } => {
GatewayJob::Find { gateway, .. } => {
let d = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero()
* 1024.0;
@@ -889,12 +1020,10 @@ impl Game<'_> {
[p[0], p[1] + 1, p[2]]
};
match job {
GatewayJob::Known { exit, exact } => Ok(bottom_center(if exact {
exit
} else {
find_exit(self, exit)
})),
GatewayJob::Find { gateway } => {
GatewayJob::Known { exit, exact } => Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
}))),
GatewayJob::Find { gateway, exact } => {
// findExitPortalXZPosTentative over the loaded chunks.
let dir = glam::DVec2::new(f64::from(gateway[0]), f64::from(gateway[2]))
.normalize_or_zero();
@@ -954,8 +1083,11 @@ impl Game<'_> {
let exit = [tallest[0], tallest[1] + 10, tallest[2]];
self.place_gateway(exit)?;
self.set_gateway_exit(exit, gateway, false)?;
self.set_gateway_exit(gateway, exit, false)?;
Ok(bottom_center(find_exit(self, exit)))
// setExitPosition keeps the gateway's own exactTeleport.
self.set_gateway_exit(gateway, exit, exact)?;
Ok(bottom_center(exit_target(exit, exact, |e| {
find_exit(self, e)
})))
}
}
}
@@ -1247,8 +1379,103 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: full,
glint_uv: [0.0, 0.0],
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::containers::EndGateway;
#[test]
fn a_saved_exit_moves_into_a_gateway_without_one() {
let saved = ([8, 100, 0], true);
let mut bare = EndGateway::default();
assert!(adopt_saved_exit(&mut bare, saved));
assert_eq!(bare.exit_portal, Some([8, 100, 0]));
assert!(bare.exact_teleport);
// The block entity's own exit wins over a stale entry.
let mut own = EndGateway {
exit_portal: Some([1, 2, 3]),
..Default::default()
};
assert!(!adopt_saved_exit(&mut own, saved));
assert_eq!(own.exit_portal, Some([1, 2, 3]));
assert!(!own.exact_teleport);
}
#[test]
fn a_new_exit_keeps_the_gateways_exact_teleport() {
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
let plain = BlockEntity::EndGateway(EndGateway::default());
assert!(exact_teleport(Some(&exact)));
assert!(!exact_teleport(Some(&plain)));
assert!(!exact_teleport(None));
assert!(!exact_teleport(Some(&BlockEntity::DaylightDetector)));
let exit = [8, 100, 0];
// Exact: the exit itself, nothing is searched for.
assert_eq!(exit_target(exit, true, |_| panic!("searched")), exit);
assert_eq!(
exit_target(exit, false, |e| [e[0], e[1] + 11, e[2]]),
[8, 111, 0]
);
}
#[test]
fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() {
let gateway = [702, 100, 0];
let exit = [8, 100, 0];
for exact in [true, false] {
let entity = BlockEntity::EndGateway(EndGateway {
exit_portal: Some(exit),
exact_teleport: exact,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))),
GatewayJob::Known { exit, exact },
);
}
let empty = BlockEntity::EndGateway(EndGateway::default());
for entity in [None, Some(&empty)] {
assert_eq!(
GatewayJob::for_gateway(gateway, entity, Some((exit, false))),
GatewayJob::Known { exit, exact: false }
);
assert_eq!(
GatewayJob::for_gateway(gateway, entity, None),
GatewayJob::Known {
exit: END_SPAWN,
exact: true
}
);
let nearby = [96, 75, 0];
let exact = BlockEntity::EndGateway(EndGateway {
exact_teleport: true,
..Default::default()
});
assert_eq!(
GatewayJob::for_gateway(nearby, Some(&exact), None),
GatewayJob::Find {
gateway: nearby,
exact: true
}
);
assert_eq!(
GatewayJob::for_gateway(nearby, entity, None),
GatewayJob::Find {
gateway: nearby,
exact: false
}
);
}
}
}
+43 -1
View File
@@ -307,6 +307,12 @@ impl Game<'_> {
)
{
outcome = o;
// dealDefaultKnockback's `indicateDamage` points at the blast
// (the source position), not the no-direction fallback.
if o.knockback && o.blocked <= 0.0 {
p.health
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
}
}
if !spectator && !(creative && flying) {
p.velocity = Vec3::new(
@@ -394,7 +400,14 @@ impl Game<'_> {
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
// A projectile's owner (`getIndirectSourceEntity`), else
// the exploding mob itself.
attacker: self
.combat
.explosion_owner
.or(exploding)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -490,6 +503,15 @@ impl Game<'_> {
);
}
}
/// Runs an explosion whose source empties the hives it destroys
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
/// TNT minecart).
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
self.combat.hive_emptying_blast = true;
let result = blast(self);
self.combat.hive_emptying_blast = false;
result
}
/// `Level.explode` on the server: positions, entities, blocks, fire; then
/// the client's explosion sound.
pub(super) fn server_explode(
@@ -606,6 +628,22 @@ impl Game<'_> {
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
let key = (pos[0], pos[1], pos[2]);
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
// creeper, a wither skull, a wither or a TNT minecart come out.
if is_hive && self.combat.hive_emptying_blast {
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
self.empty_hive(
pos,
horizon_client::beehive::facing_step(facing.as_deref()),
horizon_client::beehive::Release::Emergency,
);
}
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let custom = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &name, custom);
@@ -638,6 +676,10 @@ impl Game<'_> {
}
self.pending.clear(pos);
self.level_set(pos, self.world.air)?;
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
if is_hive {
self.anger_nearby_bees(pos);
}
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
// getRandomShortFuse(80); no game event.
if is_tnt && self.ui.rules.bool("tnt_explodes") {
+57 -39
View File
@@ -62,11 +62,56 @@ impl Rocket {
item,
}
}
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
self.position = step(self.position);
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 2.0 * h,
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.velocity = if horizontal {
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -191,46 +236,14 @@ impl Game<'_> {
r.velocity = p.velocity;
}
} else {
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
);
let wanted = if r.angled {
r.velocity
} else {
Vec3::new(
r.velocity.x * 1.15,
r.velocity.y + 0.04,
r.velocity.z * 1.15,
)
};
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
if r.angled && blocked {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
let horizontal =
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
r.position = Vec3::new(
r.position.x + moved.x,
r.position.y + moved.y,
r.position.z + moved.z,
);
r.velocity = if horizontal {
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
} else {
wanted
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -383,7 +396,12 @@ impl Game<'_> {
/// lit at the rocket's light probe.
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let orientation = glam::Mat4::from_quat(self.camera_orientation());
for r in self.rockets.iter().filter(|r| !r.attached) {
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+25 -2
View File
@@ -2,6 +2,7 @@
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
@@ -77,7 +78,7 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
if self.hook.is_some() {
let damage = self.retrieve_hook();
let damage = self.retrieve_hook(stack);
self.damage_held(damage);
self.audio.play(
"minecraft:entity.fishing_bobber.retrieve",
@@ -143,7 +144,7 @@ impl Game<'_> {
true
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self) -> u32 {
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
let Some(hook) = self.hook.take() else {
return 0;
};
@@ -167,6 +168,15 @@ impl Game<'_> {
item.velocity = pulled(item.velocity);
hooked_item = true;
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| self.mob_entity(m));
if let Some(hooked) = hooked {
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
}
// `hookedIn instanceof ItemEntity ? 3 : 5`.
damage = if hooked_item { 3 } else { 5 };
} else if hook.nibble > 0 {
@@ -191,6 +201,7 @@ impl Game<'_> {
Some(hook.open_water),
);
self.world.random = random;
let mut caught = Vec::new();
for stack in items {
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
if self
@@ -222,6 +233,7 @@ impl Game<'_> {
if let Some(potion) = &stack.potion {
item = item.with_potion(potion);
}
caught.push(item.clone());
let (dx, dy, dz) = (
owner.x - hook.position.x,
owner.y - hook.position.y,
@@ -241,6 +253,15 @@ impl Game<'_> {
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
}
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
let bobber = EntityView {
kind: "minecraft:fishing_bobber".into(),
position: [hook.position.x, hook.position.y, hook.position.z],
on_ground: hook.on_ground,
fishing_open_water: Some(hook.open_water),
..Default::default()
};
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
damage = 1;
}
if hook.on_ground {
@@ -645,6 +666,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
+1 -1
View File
@@ -114,7 +114,7 @@ impl Game<'_> {
living: Some(HurtBob {
hurt_time: p.health.hurt_animation_time as f32 - partial,
hurt_duration: hurt.hurt_duration,
hurt_dir: 0.0,
hurt_dir: p.health.hurt_dir,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
+2
View File
@@ -378,6 +378,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
+53
View File
@@ -0,0 +1,53 @@
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
//! outline (`OutlineBufferSource`).
use super::{Game, Vertex};
use horizon_client::{mobs::Mob, scoreboard as sb};
/// `Entity.getTeamColor`: the team's colour, else white.
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
team.and_then(|t| t.color.as_deref())
.and_then(sb::team_color_rgb)
.unwrap_or(0xFF_FFFF)
}
impl Game<'_> {
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
/// its `Glowing` tag, or shared flag 6 as a server sent it.
pub(super) fn entity_glowing(&self, id: u32) -> bool {
self.remote_glowing(id).unwrap_or_else(|| {
self.persist_raw(id)
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
.is_some_and(|b| b != 0)
})
}
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
/// colour (`getTeamColor`; another player's team goes by name).
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
if !m.is_currently_glowing() {
return None;
}
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
};
Some(team_color(team))
}
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
let color = horizon_client::mobs::render::abgr(rgb);
self.glowing_vertices
.borrow_mut()
.extend(vertices.iter().map(|v| Vertex {
color,
normal: [0; 4],
..*v
}));
}
}
+59 -1
View File
@@ -3,7 +3,7 @@
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use super::{Game, Mining, Screen, minecarts::CartKind};
use horizon_client::{
Result,
attack_strength::HeldStack,
@@ -289,6 +289,64 @@ impl Game<'_> {
// Only alive mobs are hit, so a picked living entity is alive.
Some(true)
}
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
pub(super) fn spectator_crosshair(&self) -> bool {
let cart = self.cart_hit().map(|(i, d)| {
let kind = &self.carts[i].kind;
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
});
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
let others = [
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
self.cushion_hit().map(|h| h.1),
self.painting_hit().map(|h| h.1),
self.frame_hit().map(|h| h.1),
self.dragon_hit().map(|h| h.1),
];
let nearest = cart
.into_iter()
.chain(boat)
.chain(others.into_iter().flatten().map(|d| (d, false)))
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((distance, menu)) = nearest
&& self.before_block(distance)
{
return menu;
}
let Some(hit) = self.pick() else {
return false;
};
let name = &*self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
let name = name.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside).
"chest" | "trapped_chest" => {
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
let blocked = |p: (i32, i32, i32)| {
let above = self.world.block(p.0, p.1 + 1, p.2);
self.res.behaviours().facts(above).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
};
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
}
_ => name.ends_with("shulker_box"),
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
@@ -420,6 +420,8 @@ impl Game<'_> {
s.flags.published.store(0, Ordering::Relaxed);
}
if let Some(mut host) = self.lan.take() {
// PlayerList.removeAll: the owner and every guest leave the game.
host.award_leave_game_to_all();
for g in &host.hub.guests {
self.save_guest(&host, g);
}
+42 -9
View File
@@ -129,6 +129,7 @@ pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
sprites: &'a HashMap<String, Rect>,
skins: &'a RefCell<horizon_client::skins::Downloader>,
profiles: &'a RefCell<horizon_client::skins::Resolver>,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
@@ -144,14 +145,14 @@ impl SpecialHost for Host<'_> {
fn player_skin(&self, profile: &Value) -> Option<String> {
// SkullBlockRenderer.getPlayerSkin: SkinManager.getInsecureSkinInformation
// accepts a texture property without a signature, unlike the tab list.
let url = horizon_client::skins::profile_skin_url(profile)?;
let location =
horizon_client::skins::skin_location(&horizon_client::skins::texture_hash(&url)?);
let _ = self
.skins
.borrow_mut()
.state(horizon_client::skins::Kind::Skin, &url);
self.sprites.contains_key(&location).then_some(location)
// A `Dynamic` profile (a name or id only) resolves through the
// services (`PlayerSkinRenderCache.getOrDefault`).
Some(horizon_client::skins::head_skin(
profile,
&mut self.profiles.borrow_mut(),
&mut self.skins.borrow_mut(),
|location| self.sprites.contains_key(location),
))
}
}
@@ -194,6 +195,7 @@ impl Game<'_> {
banners: &self.res.stations().banners,
sprites: &self.gui.atlas.sprites,
skins: &self.skin_downloads,
profiles: &self.skin_profiles,
}
}
/// The client level as item models read it: worked out once a frame (a
@@ -296,8 +298,32 @@ impl Game<'_> {
x,
y,
self.started.elapsed().as_secs_f32(),
self.settings.file.double("glintSpeed") as f32,
self.settings.file.double("glintStrength") as f32,
);
}
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.gui
.atlas
.sprites
.get("misc/enchanted_glint_item")
.copied()
.map(|_| item_render::Glint {
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
millis: self.started.elapsed().as_secs_f32() * 1000.0,
scale: 0.5,
})
}
/// The glint scroll phase shared by world items and armour
/// (`item_render::Glint::scroll_ticks`).
pub(super) fn glint_ticks(&self) -> f32 {
item_render::Glint::scroll_ticks(
self.started.elapsed().as_secs_f32() * 1000.0,
self.settings.file.double("glintSpeed") as f32,
)
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
pub(super) fn submit_item_world(
@@ -311,6 +337,7 @@ impl Game<'_> {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -332,6 +359,7 @@ impl Game<'_> {
out,
light,
lights: None,
glint: self.glint(),
};
state.submit(
&self.pack.item_renderer,
@@ -405,7 +433,12 @@ impl Game<'_> {
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
self.cull_entity_vertices(out, entity_start);
if self.entity_glowing(item.id) {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
} else {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
@@ -91,6 +91,8 @@ impl Game<'_> {
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
+112 -6
View File
@@ -92,6 +92,33 @@ pub(super) struct LanHost {
states: lan::entity::States,
}
impl LanHost {
/// `PlayerList.remove`: `player.awardStat(Stats.LEAVE_GAME)` before the
/// player is saved. Once per guest, however often they are saved.
pub(super) fn award_leave_game(&mut self, conn: u32) {
if let Some(play) = self.play.get_mut(&conn)
&& !play.left_game
{
play.left_game = true;
play.stats.custom("leave_game", 1);
}
}
/// `PlayerList.removeAll`: every player who joined leaves with the world.
pub(super) fn award_leave_game_to_all(&mut self) {
let conns: Vec<u32> = self
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| g.conn)
.collect();
for conn in conns {
self.award_leave_game(conn);
}
}
}
/// The player who owns an integrated server's world: a guest whose state is
/// the world's own player (`players/data/<singleplayer_uuid>.dat`), as in
/// single player.
@@ -162,12 +189,22 @@ fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
game_time: i64,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
// Entity.DATA_SHARED_FLAGS_ID: on fire 0x01, glowing 0x40
// (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
// pose back at standing goes out by the entity's own default).
let pose = horizon_client::mobs::sync::pose_id(m);
if pose != 0 {
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
if let Some(name) = &m.custom_name {
meta.push((
@@ -398,6 +435,41 @@ fn mob_meta(
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
}
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
"minecraft:warden" => {
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
if anger != 0 {
meta.push((16, serializer::INT, Value::Int(anger)));
}
defaults.push((16, serializer::INT, Value::Int(0)));
}
// Sniffer.DATA_STATE (after AgeableMob's two).
"minecraft:sniffer" => {
if e.variant != 0 {
meta.push((
18,
serializer::SNIFFER_STATE,
Value::Id(i32::from(e.variant)),
));
}
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
}
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
// for none).
"minecraft:shulker" => {
if e.variant3 != 0 {
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
}
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
if e.counter != 0 {
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
if e.color != 16 {
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
}
defaults.push((18, serializer::BYTE, Value::Byte(16)));
}
// Enderman.DATA_CARRY_STATE.
"minecraft:enderman" => {
if let Some(state) = e.carried {
@@ -410,6 +482,18 @@ fn mob_meta(
}
_ => {}
}
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
if horizon_client::mobs::camel::is_camel(m.species()) {
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
if tick != 0 {
meta.push((20, serializer::LONG, Value::Long(tick)));
}
defaults.push((20, serializer::LONG, Value::Long(0)));
}
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
@@ -728,6 +812,7 @@ impl Game<'_> {
let Some(mut host) = self.lan.take() else {
return;
};
host.award_leave_game_to_all();
for g in &host.hub.guests {
self.save_guest(&host, g);
}
@@ -935,7 +1020,7 @@ impl Game<'_> {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
// host), another mob or a fence knot (game/lan_entities.rs).
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
@@ -944,7 +1029,7 @@ impl Game<'_> {
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => player_entity,
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
@@ -952,7 +1037,14 @@ impl Game<'_> {
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_meta(
m,
hub,
player_entity,
self.world.game_time,
&mut e.meta,
&mut e.defaults,
);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
@@ -964,6 +1056,10 @@ impl Game<'_> {
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
if self.entity_glowing(d.id) {
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
}
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
@@ -1073,6 +1169,8 @@ impl Game<'_> {
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
host.hub
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
// A server with several levels runs a pass for each
// (`tick_server`): the network is read once, before the first.
let pass = self.level_pass;
@@ -1143,6 +1241,9 @@ impl Game<'_> {
}
}
self.lan_crafting_left(&mut host, &mut guest);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
@@ -1620,7 +1721,12 @@ impl Game<'_> {
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
if !m.glowing {
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
} else {
self.cull_entity_vertices(out, entity_start);
}
}
+37 -2
View File
@@ -74,7 +74,12 @@ pub(super) struct GuestPlay {
potions: super::potions::BodyPotions,
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
/// `Stats.LEAVE_GAME` is counted: a guest is saved more than once on the
/// way out, the statistic is awarded once (`LanHost::award_leave_game`).
pub(super) left_game: bool,
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
levitation: Option<([f64; 3], u64)>,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
@@ -158,6 +163,7 @@ pub(super) struct GuestSwap {
host_ender: Option<(i32, i32, i32)>,
adv_loaded: bool,
last_inventory: u64,
levitation: Option<([f64; 3], u64)>,
death_cause: Option<String>,
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
@@ -386,6 +392,7 @@ impl Game<'_> {
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
let acting = (
self.lan_acting.replace(conn),
@@ -457,6 +464,7 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
acting,
packets,
@@ -516,6 +524,7 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
combat,
acting,
@@ -638,6 +647,7 @@ impl Game<'_> {
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
let stat_events = std::mem::take(&mut play.stats.events);
let name = g.name.clone();
self.potions.swap_player(&mut g.effects, &mut play.potions);
@@ -681,6 +691,13 @@ impl Game<'_> {
g.sprinting = false;
}
m.sprinting = false;
// ServerPlayer.indicateDamage: the hit's direction to the player.
if let Some(yaw) = m.health.indicated_dir.take() {
g.send(&C::HurtAnimation(play::HurtAnimation {
id: g.entity_id,
yaw,
}));
}
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
@@ -882,13 +899,17 @@ impl Game<'_> {
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// it no longer counts among the openers of the lid.
/// it no longer counts among the openers of the lid, and the lid's close
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
/// `playSound` with no player), the guest included, once the last opener
/// has left.
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
if let Some(g) = host.hub.guest(conn)
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
&& !g.spectator()
&& self.container_openers_changed(pos, false)
{
self.container_openers_changed(pos, false);
self.container_edge_sound(pos, false);
}
}
@@ -982,6 +1003,20 @@ impl Game<'_> {
// The pause between two blocks is the client's (MultiPlayerGameMode's
// destroyDelay); the server digs from the START it receives.
play.destroy_delay = 0;
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
// attacks the block at once, so a tap released in the same tick
// still plays a note. The dig is marked begun for tick_mining.
let pos = a.pos;
self.as_guest(host, conn, |g| {
if g.may_build() && !g.creative {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
progress: 0.0,
ticks: 0,
});
}
});
}
1 => {}
3 => {
+15 -4
View File
@@ -127,9 +127,18 @@ impl Game<'_> {
}
let dropped = std::mem::take(&mut menu.dropped);
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
menu.drops.clear();
menu.crafted.clear();
store_player(menu, &mut host.hub.guests[gi]);
if !brewed.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
for stack in &brewed {
g.trigger_brewed_potion(stack);
}
});
}
if !equipped.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
@@ -576,16 +585,18 @@ impl Game<'_> {
/// A guest left: what their crafting grids and workstation held goes back into their
/// inventory before it is saved (dropped where they stood if full).
pub(super) fn lan_crafting_left(&mut self, host: &mut LanHost, g: &mut Guest) {
let play = host.play.remove(&g.conn);
// The guest's play state stays: their statistics are awarded and saved
// after this (`HostEvent::Left`), which removes it.
let play = host.play.get_mut(&g.conn);
let mut grid: Vec<ItemStack> = g.craft_grid.iter_mut().filter_map(Option::take).collect();
g.craft_result = None;
if let Some(play) = play {
if let Some((_, _, menu, _)) = play.crafting.table {
if let Some((_, _, menu, _)) = play.crafting.table.take() {
grid.extend((1..10).filter_map(|s| menu.slot(s).cloned()));
}
// A workstation's inputs too.
if let Some(station) = &play.station {
grid.extend(super::lan_stations::station_inputs(station));
if let Some(station) = play.station.take() {
grid.extend(super::lan_stations::station_inputs(&station));
}
}
if grid.is_empty() {
@@ -225,6 +225,16 @@ impl Game<'_> {
}
out.push(e);
}
for (p, &id) in &self.knot_ids {
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
out.push(state(
id,
"minecraft:leash_knot",
p.map(f64::from),
0.0,
false,
));
}
for p in &self.paintings {
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
@@ -270,6 +280,24 @@ impl Game<'_> {
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
/// the same counter as the rest) before they are first tracked.
pub(super) fn lan_assign_ids(&mut self) {
// LeashFenceKnotEntity: one per fence post with a lead on it
// (`notifyLeasheeRemoved` discards it once the last lead is gone).
let knots: Vec<[i32; 3]> = self
.mobs
.iter()
.filter(|m| !m.removed)
.filter_map(|m| match m.extra.leash {
Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p),
_ => None,
})
.collect();
self.knot_ids.retain(|p, _| knots.contains(p));
for p in knots {
self.knot_ids.entry(p).or_insert_with(|| {
self.next_entity += 1;
self.next_entity
});
}
let next = &mut self.next_entity;
let mut give = |id: &mut u32| {
if *id == 0 {
+18 -1
View File
@@ -5,7 +5,7 @@
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
use super::{Game, lan::LanHost};
use horizon_client::{
mobs::{Mob, MobEvent, PlayerView, Who},
mobs::{Mob, MobEvent, PlayerView, Species, Who},
projectiles::{Hit, Projectile},
};
use horizon_core::geometry::{Aabb, Vec3};
@@ -28,6 +28,22 @@ pub(super) fn player_for(
) -> Option<u32> {
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
// a player: then `Who::Player` is that attacker, whoever it is, and the
// owner goals ignore it until the wolf lets go.
if !targeting
&& matches!(m.species(), Species::Wolf | Species::Cat)
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
if host.is_some_and(|v| v.alive && v.uuid == owner) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
return Some(*conn);
}
}
choose(m.position, targeting, host, guests, bound)
}
@@ -358,6 +374,7 @@ mod tests {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
@@ -247,14 +247,10 @@ impl Game<'_> {
return;
};
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
// (`force`) or the type overrides the limiter.
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
// option, unless the packet or the type overrides the limiter.
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
if p.override_limiter {
particles.create(&options, at, v);
} else {
particles.add(&options, at, v);
}
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
};
if p.count == 0 {
add(
+24
View File
@@ -267,6 +267,30 @@ impl Game<'_> {
} else {
Default::default()
};
// ServerPlayer.awardKillScore: the blow that kills a player.
if hurt {
let died = match victim {
// A totem that fired is applied on the next tick_totem, so the
// victim is still at zero health but is not dying.
Victim::Host => self
.player
.as_ref()
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref())
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
};
if died {
match s.attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
}
None => self.stats.custom("player_kills", 1),
}
}
}
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
+2
View File
@@ -386,6 +386,8 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
@@ -1,8 +1,9 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
//! consumable's teleport (2017), an enderman's (2018), electric sparks (3002),
//! waxing (3003), wax off (3004) and scraping (3005), plus the smoke, drips,
//! egg and dragon fireball particles of 1501, 1502, 1504, 2006, 2009, 2012
//! and 3009.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
@@ -12,19 +13,22 @@ use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
self.particles.block_teleportation(pos, data, [16, 8, 16]);
}
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
/// shell's old and new cells.
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
self.particles.block_teleportation(pos, data, [8, 8, 8]);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
.block_teleportation(pos, data, [127, 127, 127]);
}
/// Event 2018: an enderman's portal trail.
pub(super) fn enderman_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.enderman_teleportation(pos, data, self.settings.particles);
}
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
/// level's random.
+4
View File
@@ -66,6 +66,8 @@ pub(super) struct Parked {
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
mobs: Vec<Mob>,
/// The entity ids of the level's fence knots (`knot_ids`).
knot_ids: HashMap<[i32; 3], u32>,
projectiles: Vec<horizon_client::projectiles::Projectile>,
dropped: Vec<ItemEntity>,
orbs: Vec<ExperienceOrb>,
@@ -170,6 +172,7 @@ impl Game<'_> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mobs: Vec::new(),
knot_ids: HashMap::new(),
projectiles: Vec::new(),
dropped: Vec::new(),
orbs: Vec::new(),
@@ -223,6 +226,7 @@ impl Game<'_> {
swap(&mut self.tnts, &mut p.tnts);
swap(&mut self.hoppers, &mut p.hoppers);
swap(&mut self.mobs, &mut p.mobs);
swap(&mut self.knot_ids, &mut p.knot_ids);
swap(&mut self.creatures.projectiles, &mut p.projectiles);
swap(&mut self.dropped, &mut p.dropped);
swap(&mut self.orbs, &mut p.orbs);
+29 -1
View File
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
flashes: i32,
seed: i64,
visual_only: bool,
/// Mooshrooms switch colour once per bolt.
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
}
impl Game<'_> {
@@ -278,6 +278,13 @@ impl Game<'_> {
}
self.strike_rod(b.at);
}
// LightningBolt.tick runs clearCopperOnLightningStrike for
// every server bolt, a skeleton trap's visual one included;
// a client's copy of a server bolt (spawn_remote_bolt) only
// flashes.
if self.remote.is_none() {
self.bolt_clear_copper(b.at);
}
}
b.life -= 1;
if b.life < 0 {
@@ -334,6 +341,16 @@ impl Game<'_> {
self.level_event_at(3002, p, axis);
}
}
/// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck.
fn bolt_clear_copper(&mut self, at: [f64; 3]) {
let p = BlockPos::new(
at[0].floor() as i32,
(at[1] - 1.0e-6).floor() as i32,
at[2].floor() as i32,
);
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
let _ = self.process_level_events();
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
let at = b.at;
@@ -393,6 +410,17 @@ impl Game<'_> {
continue;
}
Species::Creeper => m.extra.charged = true,
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
// damage): once per bolt, an oxidized golem goes back one step
// and its weathering timer (or wax, -2) is reset to -1.
Species::CopperGolem if !b.converted.contains(&m.id) => {
b.converted.push(m.id);
if m.extra.variant > 0 {
m.extra.vector[0] = -1.0;
m.extra.variant =
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
}
}
_ => {}
}
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
+6
View File
@@ -25,11 +25,13 @@ impl Game<'_> {
};
let m = &mut self.mobs[index];
let mut used = false;
let mut loved = false;
if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0
{
m.extra.in_love = 600;
m.extra.love_player = true;
used = true;
loved = true;
}
if m.health < m.attrs.max_health {
m.health = (m.health + heal).min(m.attrs.max_health);
@@ -57,6 +59,10 @@ impl Game<'_> {
if !used {
return false;
}
// Animal.setInLove: entity event 18.
if loved {
self.mob_event_particles(self.mobs[index].id, 18);
}
self.llama_sound(index, "entity.llama.eat", 1.0);
self.consume_held();
true
+95 -29
View File
@@ -37,6 +37,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|| m.species() == Species::Allay && m.extra.tame
}
/// A smash's knockback on the players, put off while a guest's turn holds
/// the other players.
pub(super) struct SmashPush {
center: Vec3,
heavy: f64,
attacker: Option<u32>,
team: Option<horizon_client::scoreboard::Team>,
}
/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy`
/// being 2 after a fall of more than five blocks.
fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) {
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
return;
}
let length = distance_sqr.sqrt();
let power =
(KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance);
if power <= 0.0 {
return;
}
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let (x, z) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(d[0] / length * power, d[2] / length * power)
};
*velocity = Vec3::new(
velocity.x + x,
velocity.y + f64::from(KNOCKBACK_POWER),
velocity.z + z,
);
}
impl Game<'_> {
fn holds_mace(&self) -> bool {
self.inventory[self.selected]
@@ -220,44 +255,75 @@ impl Game<'_> {
];
self.level_event_at(2013, on_pos, 750);
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
// getKnockbackPower and the push of one entity at `at`.
let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| {
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
return;
}
let length = distance_sqr.sqrt();
let power = (KNOCKBACK_RADIUS - length)
* f64::from(KNOCKBACK_POWER)
* heavy
* (1.0 - resistance);
if power <= 0.0 {
return;
}
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let (x, z) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(d[0] / length * power, d[2] / length * power)
};
*velocity = Vec3::new(
velocity.x + x,
velocity.y + f64::from(KNOCKBACK_POWER),
velocity.z + z,
);
};
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
for m in self
.mobs
.iter_mut()
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
{
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
smash_push(
center,
heavy,
m.position,
&mut m.velocity,
m.attrs.knockback_resistance,
);
}
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
push(s.position, &mut s.velocity, 0.0);
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
}
// The host and the LAN guests are living entities too. A guest's turn
// holds the other players (`as_guest`): the push waits for it to end.
let push = SmashPush {
center,
heavy,
attacker: self.lan_acting,
team: self.player_team().cloned(),
};
if self.lan_acting.is_some() {
self.creatures.smash_pushes.push(push);
} else {
self.smash_push_players(&push);
}
}
/// `MaceItem.knockback` on the players but the attacker: the host unless
/// it attacked, and every guest but the attacker.
fn smash_push_players(&mut self, push: &SmashPush) {
if push.attacker.is_some() {
self.smash_push_player(push);
}
self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push));
}
/// The pushes guests' smashes put off.
pub(super) fn flush_smash_pushes(&mut self) {
for push in std::mem::take(&mut self.creatures.smash_pushes) {
self.smash_push_players(&push);
}
}
/// `MaceItem.knockback` on the player here (the host, or the guest swapped
/// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same
/// team) or a creative player flying.
fn smash_push_player(&mut self, push: &SmashPush) {
if self.spectator()
|| horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team())
{
return;
}
let creative = self.creative;
let Some(p) = self.player.as_mut() else {
return;
};
if creative && p.flying {
return;
}
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
smash_push(
push.center,
push.heavy,
p.position,
&mut p.velocity,
resistance,
);
}
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
/// the landing block's particles, 50 for every block fallen (200 at most).
+166 -41
View File
@@ -41,6 +41,8 @@ enum Kind {
Crafter,
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
Brewing,
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
Bookshelf,
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
/// without a block entity): input below 7, empty at 7, bone meal at 8.
Composter(i32),
@@ -82,7 +84,7 @@ impl View {
}
}
fn dir_from_name(name: &str) -> Option<Direction> {
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
Some(match name {
"down" => Direction::Down,
"up" => Direction::Up,
@@ -169,6 +171,7 @@ impl Containers<'_> {
(BlockEntity::Furnace(_), _) => Kind::Furnace,
(BlockEntity::Crafter(_), _) => Kind::Crafter,
(BlockEntity::Brewing(_), _) => Kind::Brewing,
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
(_, "minecraft:hopper") => Kind::Hopper,
_ => Kind::Generic,
};
@@ -250,6 +253,16 @@ impl Containers<'_> {
_ => false,
};
}
if view.kind == Kind::Bookshelf {
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
// and a free slot, as `getMaxStackSize` is 1.
return self
.items
.item_tags
.get("minecraft:bookshelf_books")
.is_some_and(|m| m.contains(&*stack.item))
&& self.get(view, slot).is_none();
}
if view.kind == Kind::Brewing {
// BrewingStandBlockEntity.canPlaceItem
return match slot {
@@ -399,6 +412,45 @@ pub(super) fn container_signal(
.map_or(0, |v| c.signal(&v))
}
/// `ChiseledBookShelfBlock.getAnalogOutputSignal` and
/// `ShelfBlock.getAnalogOutputSignal`, from the block entity Horizon keeps as
/// it was loaded; `None` for any other block. `direction` points from the
/// block to the comparator.
pub(super) fn shelf_signal(
world: &World,
registry: &'static horizon_world::registry::BlockRegistry,
state: BlockStateId,
pos: BlockPos,
direction: Direction,
) -> Option<i32> {
let info = registry.state(state)?;
let chiseled = info.name == "minecraft:chiseled_bookshelf";
if !chiseled && !info.name.ends_with("_shelf") {
return None;
}
if chiseled {
// The last slot touched, plus one (`lastInteractedSlot` starts at -1).
return Some(match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::ChiseledBookshelf(b)) => b.last_slot + 1,
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
Some(
items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
.sum(),
)
}
impl Game<'_> {
fn containers(&self) -> Containers<'_> {
Containers {
@@ -433,6 +485,7 @@ impl Game<'_> {
if pos.1 == super::minecarts::CART_KEY_Y {
return;
}
self.chiseled_bookshelf_synced(pos);
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
@@ -626,46 +679,8 @@ impl Game<'_> {
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
let above = (pos.0, pos.1 + 1, pos.2);
let c = self.containers();
if let Some(source) = c.view(above, false) {
if let Kind::Composter(level) = source.kind {
if level != 8 {
return false;
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return moved;
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return true;
}
Some(rest) => self.put_back(&source, slot, rest),
}
}
return false;
if let Some(took) = self.suck_from_container(above, hopper) {
return took;
}
let above_state = self.world.block(above.0, above.1, above.2);
let blocked = self.res.facts().get(above_state).full_collision
@@ -695,6 +710,56 @@ impl Game<'_> {
{
return false;
}
self.suck_item_entities(hopper, min, max)
}
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
/// container at `above`, through its bottom face. `None` when there is no
/// container there.
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
let c = self.containers();
let source = c.view(above, false)?;
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return Some(moved);
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return Some(true);
}
Some(rest) => self.put_back(&source, slot, rest),
}
}
Some(false)
}
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
/// adds the item entities that meet the box, until one is taken whole.
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
let mut i = 0;
while i < self.dropped.len() {
let b = self.dropped[i].bounds();
@@ -719,6 +784,66 @@ impl Game<'_> {
}
false
}
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
/// hopper minecart: one item from the container above, else the item
/// entities over it or touching it, each tick without a cooldown.
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
// The cart's slots are edited as a container under the placeholder key
// its menu uses; when that menu is open the placeholder is the live copy.
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
let open = self.world.block_entities.contains_key(&key);
if !open {
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
self.world.block_entities.insert(key, items);
}
let size = self
.world
.block_entities
.get(&key)
.map_or(0, |e| e.slots().len());
let view = View {
parts: vec![(key, size)],
kind: Kind::Generic,
};
let p = cart.position;
// getLevelY is the cart's y + 0.5, so the container is the block at
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
// above the cart.
let above = (
p.x.floor() as i32,
(p.y + 1.5).floor() as i32,
p.z.floor() as i32,
);
let took = match self.suck_from_container(above, &view) {
Some(took) => took,
None => self.suck_item_entities(
&view,
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
),
};
if !took {
// The cart's own box, inflated by 0.25 sideways.
let b = cart.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
self.suck_item_entities(
&view,
[lo.x - 0.25, lo.y, lo.z - 0.25],
[hi.x + 0.25, hi.y, hi.z + 0.25],
);
}
// The cart's own items stay the one source of truth: a menu copy that
// is still around (open, or left by a guest's close) gets the pulled
// items back into `cart.items`, so reopening never restores a stale copy.
let slots = if open {
self.world.block_entities.get(&key).cloned()
} else {
self.world.block_entities.remove(&key)
};
if let Some(BlockEntity::Chest(items)) = slots {
cart.items = items;
}
}
// ---- dispensers and droppers ----
+6
View File
@@ -1143,6 +1143,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -1194,6 +1196,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -1266,6 +1270,8 @@ impl Game<'_> {
layer: 0,
color: 0xFFFF_FFFF,
light,
glint_uv: [0.0, 0.0],
});
}
}
+18 -56
View File
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
pub yaw: f32,
pub previous_yaw: f32,
pub flipped: bool,
on_ground: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
fall_distance: f64,
pub hurt_time: i32,
@@ -558,7 +558,6 @@ impl Game<'_> {
// applyGravity
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
let pos = self.rail_block(c.position);
let fall_speed = c.velocity;
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
self.move_along_track(c, pos, &rail, intent);
if &*rail.name == "minecraft:activator_rail" {
@@ -600,22 +599,26 @@ impl Game<'_> {
}
}
CartKind::Tnt => {
let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z;
// MinecartTNT.tick: the fuse and the collision check are separate.
if c.fuse > 0 {
c.fuse -= 1;
let at = [c.position.x, c.position.y + 0.5, c.position.z];
self.particles
.add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]);
} else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) {
} else if c.fuse == 0 {
// explode(horizontalDistanceSqr()): a rising cart's
// vertical speed adds nothing.
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
let speed = if c.fuse == 0 && c.velocity.y > 0.0 {
c.velocity.y * c.velocity.y
} else {
speed
};
self.explode_cart(c, speed);
return false;
}
if c.horizontal_collision {
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
if speed >= f64::from(0.01_f32) {
self.explode_cart(c, speed);
return false;
}
}
}
_ => {}
}
@@ -659,51 +662,9 @@ impl Game<'_> {
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
self.carts.retain(|x| x.id != c.id);
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
}
/// `MinecartHopper.suckInItems`: items above, into the five slots.
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
if !c.enabled {
return;
}
let p = c.position;
let zone = Aabb::new(
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
);
let mut i = 0;
while i < self.dropped.len() {
let e = &self.dropped[i];
if e.pickup_delay > 0 || !zone.contains(e.position) {
i += 1;
continue;
}
let mut stack = e.stack.clone();
for slot in c.items.iter_mut() {
match slot {
None => {
*slot = Some(stack.clone());
stack.count = 0;
}
Some(s) if s.same_item_same_components(&stack) => {
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
let moved = stack.count.min(max.saturating_sub(s.count));
s.count += moved;
stack.count -= moved;
}
_ => {}
}
if stack.count == 0 {
break;
}
}
if stack.count == 0 {
self.dropped.remove(i);
} else {
self.dropped[i].stack = stack;
i += 1;
}
}
let _ = self.with_hive_emptying_blast(|g| {
g.server_explode(at, power, BlockInteraction::Destroy, false)
});
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
@@ -837,8 +798,8 @@ impl Game<'_> {
}
let mut c = self.carts[i].clone();
let alive = self.tick_cart(&mut c, [0.0; 2]);
if c.kind == CartKind::Hopper {
self.hopper_cart_pickup(&mut c);
if c.kind == CartKind::Hopper && c.enabled {
self.hopper_cart_suck(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
@@ -1329,6 +1290,7 @@ impl Game<'_> {
layer: 0,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
@@ -204,6 +204,8 @@ impl Game<'_> {
layer: overlay,
color,
light,
glint_uv: [0.0, 0.0],
});
}
}
+15 -6
View File
@@ -228,10 +228,12 @@ impl Game<'_> {
return false;
}
let mut used = false;
let mut loved = false;
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
m.extra.in_love = 600;
m.extra.love_player = true;
used = true;
loved = true;
}
if m.health < m.attrs.max_health && heal > 0.0 {
m.health = (m.health + heal).min(m.attrs.max_health);
@@ -251,6 +253,7 @@ impl Game<'_> {
// eating(): the mouth opens and the horse eats.
m.anim.v[2] = 1.0;
let at = m.center();
let id = m.id;
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
@@ -260,6 +263,10 @@ impl Game<'_> {
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
// Animal.setInLove: entity event 18.
if loved {
self.mob_event_particles(id, 18);
}
self.consume_held();
true
}
@@ -465,14 +472,16 @@ impl Game<'_> {
m.extra.owner = owner;
m.persistent = true;
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
self.particles_around_mob(
mob,
"heart",
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(mob, 7);
}
}
MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(),
MobEvent::Buck { mob } => {
if self.riding == Some(mob) {
self.dismount_mount();
}
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
self.mob_event_particles(mob, 6);
}
_ => {}
}
}
@@ -14,7 +14,9 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) {
let partial = alpha as f32;
for &(p, piston) in &self.world.moving_pistons {
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
for (p, piston) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
+1
View File
@@ -379,6 +379,7 @@ impl Game<'_> {
layer,
color,
light,
glint_uv: [0.0, 0.0],
});
}
};
@@ -504,7 +504,8 @@ impl<'a> Game<'a> {
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
})
.collect();
let players = players
+3 -6
View File
@@ -85,13 +85,8 @@ impl Game<'_> {
m.ai.target = None;
}
let sound = nautilus::eat_sound(m);
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.nautilus_sound(index, sound);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
@@ -145,6 +140,8 @@ impl Game<'_> {
let sound = nautilus::eat_sound(m);
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
// Animal.setInLove: entity event 18.
self.mob_event_particles(self.mobs[index].id, 18);
return true;
}
false
@@ -307,6 +307,8 @@ impl Game<'_> {
layer: 0,
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
};
@@ -452,6 +452,7 @@ impl Game<'_> {
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let start = out.len();
horizon_client::mobs::render::render_with(
m,
alpha as f32,
@@ -465,6 +466,15 @@ impl Game<'_> {
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
// in its team by its profile name.
if self.potions.effects.get("glowing").is_some() {
let team = match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
};
self.mark_outline(&out[start..], super::glowing::team_color(team));
}
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
@@ -719,6 +729,8 @@ mod preview_tests {
color: u32::MAX,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
};
// A shield and arm can intersect: neither average depth orders both ends.
@@ -0,0 +1,573 @@
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
//! the entities the game moves itself: the players and the mobs.
//!
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
//! then run through the block collision scan, so a pushed entity stops at a
//! wall instead of passing through it.
//!
use super::Game;
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
use horizon_core::geometry::{Aabb, Direction, Vec3, border::BorderGeometry};
use horizon_core::position::BlockPos;
use horizon_world::{
block_collision_scan::BlockCollisionWorld, level::pistons::MovingPiston,
piston_movement::PistonMovement, piston_push, world_movement::EntityMovementWorld,
};
/// What one piston asks for this tick: where it is, its state, and the progress
/// `moveCollidedEntities` runs at (before the tick stores it).
pub(super) type Push = (BlockPos, MovingPiston, f32);
/// Which entity was pushed, so its storage can be reached after the world
/// borrow ends.
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
enum Target {
Player,
Guest(u32),
Mob(usize),
Item(usize),
Boat(usize),
Cart(usize),
ArmorStand(usize),
}
/// `Entity.getStepHeight` of each body, read before the push.
struct StepHeights {
player: f32,
guests: std::collections::HashMap<u32, f32>,
}
/// What the push needs from one entity.
#[derive(Clone, Copy)]
struct Body {
bounds: Aabb,
position: Vec3,
on_ground: bool,
step_height: f32,
/// Standing on the piston's own block (`Entity.isSupportedBy`).
supported: bool,
}
impl Game<'_> {
/// Push whatever this tick's moving pistons ran into, in the order the
/// pistons ticked: vanilla pushes from each block entity's own tick.
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
let mut accumulators = std::collections::HashMap::new();
for push in pushes {
self.push_for_piston(push, &mut accumulators);
}
}
fn push_for_piston(
&mut self,
(pos, piston, new_progress): Push,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let delta_progress = f64::from(new_progress - piston.progress);
if delta_progress <= 0.0 {
return;
}
let Some(bindings) = self.collision.pistons() else {
return;
};
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
return;
};
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let Ok(shape) = self
.collision
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
else {
return;
};
if shape.is_empty() {
return;
}
let movement = piston.movement_direction();
let boxes: Vec<Aabb> = shape.boxes().to_vec();
let facts = self.res.facts();
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
// `moveStuckEntities` asks the moved state's own top for its box.
let sticky_top = if honey {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
.ok()
.and_then(|s| s.bounds())
.map_or(0.0, |b| b.max_position().y)
} else {
0.0
};
let steps = self.push_step_heights();
let search = shape.bounds().map(|bounds| {
piston_push::movement_search_area(
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
movement,
delta_progress,
)
});
let targets = self.push_targets();
for &target in &targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
let amount = piston_push::collided_amount(
&boxes,
pos,
piston.shape_state(),
new_progress,
movement,
body.bounds,
);
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
self.bounce(target, movement);
}
if amount > 0.0 {
let step = piston_push::step_vector(movement);
let before = body.bounds;
self.move_pushed(
target,
before,
[body.position.x, body.position.z],
body.step_height,
body.on_ground,
step.scale(amount),
movement,
accumulators,
);
// `fixEntityWithinPistonBase`: a retracting source piston drags
// an entity that is still inside its own block.
if !piston.extending
&& piston.source
&& let Some(after) = self.push_body(target, pos, &steps)
&& let Some(fix) = piston_push::fix_within_piston_base(
pos,
after.bounds,
movement,
delta_progress,
)
{
let back = piston_push::step_vector(movement.opposite());
self.move_pushed(
target,
after.bounds,
[after.position.x, after.position.z],
after.step_height,
after.on_ground,
back.scale(fix),
movement,
accumulators,
);
}
}
}
// Vanilla selects honey passengers after all collided entities have moved.
if honey && movement.normal()[1] == 0 {
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
for target in targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
if piston_push::sticky_entity_selected(
area,
body.bounds,
horizon_world::level::data::PushReaction::PushPull,
body.on_ground,
body.supported,
body.position,
) {
self.move_pushed(
target,
body.bounds,
[body.position.x, body.position.z],
body.step_height,
body.on_ground,
piston_push::step_vector(movement).scale(delta_progress),
movement,
accumulators,
);
}
}
}
}
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
/// needs the mutable body, and the push only needs the number.
fn push_step_heights(&mut self) -> StepHeights {
let player = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
let mut guests = std::collections::HashMap::new();
if let Some(host) = self.lan.as_mut() {
for g in &mut host.hub.guests {
let step = g
.motion
.as_mut()
.and_then(|m| m.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
guests.insert(g.conn, step);
}
}
StepHeights { player, guests }
}
/// The entities a moving block can run into: the window's player, the LAN
/// guests in the level, then the mobs.
fn push_targets(&self) -> Vec<Target> {
let mut targets = Vec::new();
if self.player.is_some() {
targets.push(Target::Player);
}
if let Some(host) = self.lan.as_ref() {
targets.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| Target::Guest(g.conn)),
);
}
targets.extend((0..self.mobs.len()).map(Target::Mob));
targets.extend((0..self.dropped.len()).map(Target::Item));
targets.extend((0..self.boats.len()).map(Target::Boat));
targets.extend((0..self.carts.len()).map(Target::Cart));
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
targets
}
/// What the push needs from one entity, or `None` when it is not pushed:
/// removed, dead, a spectator, or an armor-stand marker.
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
let at = [pos.x, pos.y, pos.z];
match target {
Target::Player => {
let p = self.player.as_ref()?;
if p.spectator {
return None;
}
Some(Body {
bounds: p.bounds(),
position: p.position,
on_ground: p.ground.on_ground,
step_height: steps.player,
supported: p.ground.supporting_block == Some(pos),
})
}
Target::Guest(conn) => {
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
if g.dead || g.game_mode == 3 {
return None;
}
let p = g.position;
Some(Body {
bounds: Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
),
position: Vec3::new(p[0], p[1], p[2]),
on_ground: g.on_ground,
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
supported: g
.motion
.as_ref()
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
})
}
Target::Mob(i) => {
let m = &self.mobs[i];
if m.removed || !m.alive() {
return None;
}
Some(Body {
bounds: m.bounds(),
position: m.position,
on_ground: m.on_ground,
step_height: m.kind.step_height as f32,
supported: m.supporting == Some(at),
})
}
Target::Item(i) => {
let entity = &self.dropped[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Boat(i) => {
let entity = &self.boats[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Cart(i) => {
let entity = &self.carts[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::ArmorStand(i) => {
let entity = &self.armor_stands[i];
if entity.marker {
return None;
}
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
}
}
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
/// per-tick piston limit, then the block and world border collision scan.
fn move_pushed(
&mut self,
target: Target,
before: Aabb,
at: [f64; 2],
step_height: f32,
on_ground: bool,
movement: Vec3,
piston_direction: Direction,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
return;
};
// `limitPistonMovement` returns zero once the tick's budget is spent.
if limited == Vec3::ZERO {
return;
}
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
},
self.border.geometry(),
at,
before,
limited,
step_height,
on_ground,
)
.unwrap_or(Vec3::ZERO)
});
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
return;
}
self.apply_push(target, resolved);
}
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
/// per game tick, and a move touches only its first non-zero axis.
fn limit_piston(
&mut self,
target: Target,
movement: Vec3,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) -> Option<Vec3> {
let time = self.world.game_time;
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
match target {
Target::Player => {
let p = self.player.as_mut()?;
limit(&mut p.piston)
}
Target::Guest(conn) => {
let host = self.lan.as_mut()?;
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
limit(&mut g.motion.as_deref_mut()?.piston)
}
Target::Mob(i) => limit(&mut self.mobs[i].piston),
_ => limit(accumulators.entry(target).or_default()),
}
}
fn apply_push(&mut self, target: Target, movement: Vec3) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(movement);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
// The body and the hub's copy of its position move together:
// `leave_guest` writes the body back over the hub's.
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
*slot += delta;
}
if let Some(m) = g.motion.as_deref_mut() {
m.position = m.position.add_vector(movement);
}
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.position = m.position.add_vector(movement);
}
Target::Item(i) => {
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
}
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
Target::ArmorStand(i) => {
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
}
}
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& let Some(motion) = guest.motion.as_deref_mut()
{
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
}
}
Target::Item(i) => {
self.dropped[i].velocity =
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
}
Target::Boat(i) => {
self.boats[i].velocity =
piston_push::bounce_velocity(movement, self.boats[i].velocity)
}
Target::Cart(i) => {
self.carts[i].velocity =
piston_push::bounce_velocity(movement, self.carts[i].velocity)
}
Target::ArmorStand(i) => {
self.armor_stands[i].velocity =
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
}
}
}
}
/// `Entity.move(MoverType.PISTON, movement)` for a pushed body: the movement
/// it makes. The world's blocks stop it, and so does the world border:
/// `Entity.collectCollidersIgnoringWorldBorder` adds the border's walls when
/// the body is inside close to the border, as ordinary movement does.
fn pushed_movement<W>(
blocks: W,
border: &BorderGeometry,
at: [f64; 2],
before: Aabb,
movement: Vec3,
step_height: f32,
on_ground: bool,
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
EntityCollisions::with_border(blocks, border, at).resolve(
before,
movement,
step_height,
on_ground,
)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::collisions::EmptyCollisions;
/// A border 10 wide around the origin: its wall's inner face is at x = 5.
fn border() -> BorderGeometry {
BorderGeometry::new([0.0, 0.0], 10.0, 29_999_984).expect("a border")
}
/// `Entity.collectCollidersIgnoringWorldBorder`: the border's walls stop a
/// pushed body that is inside close to the border.
#[test]
fn a_pushed_body_stops_at_the_world_border() {
// A body 0.6 wide, 0.4 blocks short of the wall, pushed east into it.
let before = Aabb::new(Vec3::new(4.0, 0.0, 0.0), Vec3::new(4.6, 1.8, 0.6));
let border = border();
let moved = pushed_movement(
EmptyCollisions,
&border,
[4.3, 0.3],
before,
Vec3::new(0.51, 0.0, 0.0),
0.0,
false,
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
assert!(
(moved.x - 0.4).abs() < 1.0e-9,
"the body was not stopped by the border: {moved:?}"
);
}
/// Away from the border, the same push moves the whole way.
#[test]
fn a_pushed_body_moves_whole_away_from_the_border() {
let before = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.6, 1.8, 0.6));
let border = border();
let moved = pushed_movement(
EmptyCollisions,
&border,
[0.3, 0.3],
before,
Vec3::new(0.51, 0.0, 0.0),
0.0,
false,
)
.expect("the push resolves");
assert!(
(moved.x - 0.51).abs() < 1.0e-9,
"the body did not move the whole push: {moved:?}"
);
}
}
+174 -5
View File
@@ -21,8 +21,8 @@ use horizon_client::{
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
geometry::{Aabb, Direction, Vec3},
random::{LegacyRandom, RandomSource},
};
/// A thrown splash or lingering potion in flight.
@@ -70,7 +70,7 @@ impl PotionCloud {
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let id = id.trim_start_matches("minecraft:");
@@ -120,6 +120,153 @@ fn mob_can_be_affected(
}
}
impl Game<'_> {
/// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason
/// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer
/// dies. `bearer` is the dying mob's id, `player` whether it is a player,
/// `random` the dying mob's own (`mob.getRandom()`; the world's for the
/// player).
pub(super) fn effects_on_death(
&mut self,
effects: &[&str],
bounds: Aabb,
player: bool,
bearer: Option<u32>,
mut random: Option<&mut LegacyRandom>,
) {
let feet = bounds.bottom_center();
if effects.contains(&"oozing") {
// OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the
// slimes close by already fill the cramming limit.
let limit = self.ui.rules.int("max_entity_cramming");
let mut count = 2;
if limit >= 1 {
let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0));
let nearby = self
.mobs
.iter()
.filter(|m| {
m.species() == horizon_client::mobs::Species::Slime
&& Some(m.id) != bearer
&& m.bounds().intersects(near)
})
.count()
.min(limit as usize) as i32;
count = (limit - nearby).clamp(0, count);
}
for _ in 0..count {
// spawnSlimeOffspring: a size 2 slime half a block up.
if let Some(id) = self.spawn_mob_for(
"minecraft:slime",
[feet.x, feet.y + 0.5, feet.z],
false,
true,
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
horizon_client::mobs::cube::set_size(m, 2);
}
}
}
if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) {
self.spawn_cobwebs_around(
[
feet.x.floor() as i32,
feet.y.floor() as i32,
feet.z.floor() as i32,
],
random.as_deref_mut(),
);
}
if effects.contains(&"wind_charged") {
// A burst centred at half the height, radius 3 to 5.
let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z];
let r = random.unwrap_or(&mut self.world.random);
let radius = 3.0 + r.next_float() * 2.0;
let _ = self.wind_burst(center, radius, 1.0, false, false);
}
}
/// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in
/// free spaces with a solid floor, picked among 15 random blocks within one
/// block of `pos`.
fn spawn_cobwebs_around(&mut self, pos: [i32; 3], mut random: Option<&mut LegacyRandom>) {
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
let wanted = 2 + r.next_int_bound(2).unwrap_or(0) as usize;
let mut chosen: Vec<[i32; 3]> = Vec::new();
for _ in 0..15 {
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
let at = [
pos[0] - 1 + r.next_int_bound(3).unwrap_or(0),
pos[1] - 1 + r.next_int_bound(3).unwrap_or(0),
pos[2] - 1 + r.next_int_bound(3).unwrap_or(0),
];
let facts = self.res.facts();
let here = facts.get(self.world.block(at[0], at[1], at[2]));
let below = facts.get(self.world.block(at[0], at[1] - 1, at[2]));
if !chosen.contains(&at)
&& here.replaceable
&& below.sturdy_full & (1 << Direction::Up as u8) != 0
{
chosen.push(at);
if chosen.len() >= wanted {
break;
}
}
}
for at in chosen {
if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() {
self.level_event_at(3018, at, 0);
}
}
}
/// `InfestedMobEffect.onMobHurt` for the player: from half its height up,
/// along where it looks.
pub(super) fn player_infested(&mut self) {
if let Some(p) = self.player.as_ref() {
let at = [
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
];
let look = super::view_vector(self.yaw, self.pitch);
self.infested_hurt(at, look, None);
}
}
/// A living mob's own random (`Entity.random`), else the world's.
fn bearer_random(&mut self, bearer: Option<u32>) -> &mut LegacyRandom {
match bearer.and_then(|id| self.mobs.iter().position(|m| m.id == id)) {
Some(i) => &mut self.mobs[i].random,
None => &mut self.world.random,
}
}
/// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at
/// `at` (half the bearer's height up), thrown along its look direction.
/// The draws are the bearer's (`mob.getRandom()`): `bearer` is the mob,
/// none for the player (the world's random).
pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3], bearer: Option<u32>) {
if self.bearer_random(bearer).next_float() > 0.1 {
return;
}
let count = 1 + self.bearer_random(bearer).next_int_bound(2).unwrap_or(0);
for _ in 0..count {
// Mth.randomBetween(-PI / 2, PI / 2), then the look direction
// scaled by (0.3, 0.45, 0.3) and turned about the y axis.
let half = std::f32::consts::FRAC_PI_2;
let angle = self.bearer_random(bearer).next_float() * (2.0 * half) - half;
let (sin, cos) = angle.sin_cos();
let (x, z) = (look[0] * 0.3, look[2] * 0.3);
let velocity = Vec3::new(
f64::from(x as f32 * cos + z as f32 * sin),
look[1] * 0.3 * 1.5,
f64::from(-x as f32 * sin + z as f32 * cos),
);
if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.velocity = velocity;
}
self.audio
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
}
}
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(
@@ -473,12 +620,34 @@ impl Game<'_> {
return;
};
if player.health.dead_or_dying() {
// LivingEntity.die → removeAllEffects.
// LivingEntity.die → removeAllEffects. Vanilla runs the effects
// that act on death (`onMobRemoved`) when the dead player is
// removed, up to 20 ticks later; here it is as the effects go.
let w = f64::from(player.dimensions.width) / 2.0;
let bounds = Aabb::new(
Vec3::new(
player.position.x - w,
player.position.y,
player.position.z - w,
),
Vec3::new(
player.position.x + w,
player.position.y + f64::from(player.dimensions.height),
player.position.z + w,
),
);
let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect();
self.potions.effects.clear();
if !ids.is_empty() {
self.effects_on_death(&ids, bounds, true, None, None);
}
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = player.health.absorption() <= 0.0;
let absorption_empty = self
.player
.as_ref()
.is_some_and(|p| p.health.absorption() <= 0.0);
let mut effects = std::mem::take(&mut self.potions.effects);
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
+25 -12
View File
@@ -762,7 +762,7 @@ impl Game<'_> {
}
positions.push((m.id, m.position, s.active_raid));
}
let banners: Vec<(usize, Vec3)> = self
let banners: Vec<(usize, u32, Vec3)> = self
.dropped
.iter()
.enumerate()
@@ -771,7 +771,7 @@ impl Game<'_> {
&& e.stack.data().is_some_and(|d| d.ominous_banner)
&& e.pickup_delay == 0
})
.map(|(i, e)| (i, e.position))
.map(|(i, e)| (i, e.id, e.position))
.collect();
let mut taken: Vec<usize> = Vec::new();
let mut collectors: Vec<(usize, u32)> = Vec::new();
@@ -858,20 +858,33 @@ impl Game<'_> {
&& raids.get(id).is_some_and(|r| r.leader(s.wave).is_none())
&& !leaders.contains(&(id, s.wave))
{
let in_reach = |(i, _, p): &&(usize, u32, Vec3)| {
!taken.contains(i)
&& (p.x - position.x).abs() <= range
&& (p.z - position.z).abs() <= range
&& (p.y - position.y).abs() <= 8.0
};
let by_distance = |a: &&(usize, u32, Vec3), b: &&(usize, u32, Vec3)| {
a.2.distance_squared(position)
.total_cmp(&b.2.distance_squared(position))
};
// The unreachable cache only picks the navigation target;
// a banner next to the raider is still picked up.
let near = banners
.iter()
.filter(|(i, p)| {
!taken.contains(i)
&& (p.x - position.x).abs() <= range
&& (p.z - position.z).abs() <= range
&& (p.y - position.y).abs() <= 8.0
.filter(in_reach)
.filter(|(_, item, _)| {
!s.unreachable_banners
.iter()
.any(|&(b, until)| b == *item && now < until)
})
.min_by(|a, b| {
a.1.distance_squared(position)
.total_cmp(&b.1.distance_squared(position))
});
if let Some((i, p)) = near {
.min_by(by_distance);
let nearest = banners.iter().filter(in_reach).min_by(by_distance);
let adjacent =
nearest.filter(|(_, _, p)| p.distance_squared(position) < 1.414 * 1.414);
if let Some((i, item, p)) = adjacent.or(near) {
s.banner_at = Some([p.x, p.y, p.z]);
s.banner_item = *item;
if p.distance_squared(position) < 1.414 * 1.414 {
// pickUpItem: the banner becomes the wave's leader.
taken.push(*i);
+164 -17
View File
@@ -186,6 +186,9 @@ pub(super) struct Remote {
online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
@@ -200,6 +203,8 @@ pub(super) struct Remote {
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
synced: Vec<Option<ItemStack>>,
@@ -483,6 +488,7 @@ impl Game<'_> {
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
glowing: Default::default(),
profile: 0,
profile_name: String::new(),
destroying: None,
@@ -492,6 +498,7 @@ impl Game<'_> {
stop_mining: false,
placing: None,
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 36],
decode_errors: 0,
@@ -1430,6 +1437,13 @@ impl Game<'_> {
{
return;
}
// So does a firework rocket: its own tick flies it on.
if path.is_some()
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
{
f.position = Vec3::new(position[0], position[1], position[2]);
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
@@ -1488,7 +1502,9 @@ impl Game<'_> {
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.lerps.remove(&id);
r.stepped.remove(&id);
@@ -1651,6 +1667,7 @@ impl Game<'_> {
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
r.show_death_screen = p.show_death_screen;
}
self.streaming.radius =
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
@@ -1805,6 +1822,11 @@ impl Game<'_> {
r.level_players.remove(&p.id);
}
}
// Entity.getTeam: a mob is on a team by its UUID.
self.persist.uuids.insert(
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
@@ -1896,7 +1918,8 @@ impl Game<'_> {
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
// lead (this player, another player or a mob), for the rope.
// lead (this player, another player, a mob or a fence knot), for
// the rope.
C::SetEntityLink(p) => {
let me = self.remote.as_ref().map(|r| r.entity_id);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
@@ -1904,7 +1927,12 @@ impl Game<'_> {
m.extra.leash = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
d => Some(Holder::Mob(d as u32)),
d => match self.remote_knots.get(&(d as u32)) {
Some(&block) => Some(Holder::Knot(block)),
// Not a known knot yet: kept by id until the knot
// arrives (game/remote_objects.rs).
None => Some(Holder::Mob(d as u32)),
},
};
}
}
@@ -1923,7 +1951,10 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
(0, Value::Byte(flags)) => {
m.invisible = flags & 0x20 != 0;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
@@ -1940,6 +1971,15 @@ impl Game<'_> {
}
}
}
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
&& let Some(r) = self.remote.as_mut()
{
if flags & 0x40 != 0 {
r.glowing.insert(p.id);
} else {
r.glowing.remove(&p.id);
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
@@ -2164,8 +2204,9 @@ impl Game<'_> {
}
C::HurtAnimation(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
// Player.animateHurt: the tilt turns around the yaw.
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
@@ -2202,6 +2243,10 @@ impl Game<'_> {
{
m.health = 0.0;
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
@@ -2212,6 +2257,37 @@ impl Game<'_> {
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
// Mob.handleEntityEvent 20: the spawn poof.
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
self.poof_particles(p.id as u32);
}
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
// 7, Animal and Allay 18: the particles about the mob.
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) {
self.entity_event_particles(p.id as u32, p.event);
}
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
// the projectile is (the server removes it right after).
if p.event == 3 {
let crumbs = self
.creatures
.projectiles
.iter()
.find(|pr| pr.id == p.id as u32)
.and_then(|pr| {
let item = match pr.kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
"minecraft:snowball"
}
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
_ => return None,
};
Some(([pr.position.x, pr.position.y, pr.position.z], item))
});
if let Some((at, item)) = crumbs {
self.item_burst(at, item);
}
}
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
@@ -2286,7 +2362,11 @@ impl Game<'_> {
pl.health.flash_health_change(p.health);
}
self.apply_remote_health();
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
// The death screen opens (or the respawn is asked for) with the
// death packet when the rule hides it.
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
{
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
@@ -2302,11 +2382,16 @@ impl Game<'_> {
r.death_message = Some(message);
}
// ClientPacketListener.handlePlayerCombatKill: the death
// screen, whatever health was last sent.
// screen, whatever health was last sent, or `respawn()` at
// once when it is hidden.
if me && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
} else {
self.respawn_player()?;
}
}
}
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
@@ -2382,6 +2467,12 @@ impl Game<'_> {
r.game_mode = mode;
}
}
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
11 => {
if let Some(r) = self.remote.as_mut() {
r.show_death_screen = p.param == 0.0;
}
}
7 => {
self.world.weather.rain_level = (p.param, p.param);
self.world.weather.raining = p.param > 0.0;
@@ -2899,7 +2990,7 @@ impl Game<'_> {
resolved.push((uuid, skin));
}
}
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.show_skin(skin));
resolved.retain(|(_, skin)| !skin.starts_with("skins/") || self.again_skin(skin));
let Some((level, skins, own)) = self.remote.as_mut().map(|r| {
r.skins = resolved.into_iter().collect();
(
@@ -2938,11 +3029,9 @@ impl Game<'_> {
.iter()
.filter(|m| &*m.kind.id == "minecraft:mannequin" && m.profile.is_some())
.map(|m| {
let t = m
.profile
.as_ref()
.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = t.map_or_else(Default::default, |t| {
let profile = m.profile.as_ref();
let t = profile.and_then(|p| p.properties.iter().find(|p| p.name == "textures"));
let textures = if let Some(t) = t {
let signature = if t.signature.is_some() {
horizon_client::skins::Signature::Signed
} else {
@@ -2953,7 +3042,30 @@ impl Game<'_> {
signature,
&horizon_client::skins::TEXTURE_URIS,
)
});
} else {
// `ResolvableProfile.resolveProfile`: no `textures` and
// only a name or an id resolves through the services.
let key = profile.and_then(|p| match (&p.name, p.uuid) {
(Some(name), None) => {
Some(horizon_client::skins::NameOrId::Name(name.clone()))
}
(None, Some(id)) => Some(horizon_client::skins::NameOrId::Id(id)),
_ => None,
});
key.and_then(|key| match self.skin_profiles.borrow_mut().state(&key) {
horizon_client::skins::Resolve::Done(Some(profile)) => {
profile.textures().map(|(value, signature)| {
horizon_client::skins::unpack(
&value,
signature,
&horizon_client::skins::TEXTURE_URIS,
)
})
}
_ => None,
})
.unwrap_or_default()
};
(m.id, textures)
})
.collect();
@@ -2968,7 +3080,7 @@ impl Game<'_> {
resolved.push((id, skin));
}
for (id, skin) in resolved {
let skin = skin.filter(|s| !s.starts_with("skins/") || self.show_skin(s));
let skin = skin.filter(|s| !s.starts_with("skins/") || self.again_skin(s));
if let Some(mob) = self
.mobs
.iter_mut()
@@ -3018,6 +3130,10 @@ impl Game<'_> {
self.skin_slots.push(e);
true
}
/// `show_skin`, or — its slot evicted — the image queued for upload again.
fn again_skin(&mut self, location: &str) -> bool {
self.show_skin(location) || self.skin_downloads.borrow_mut().again(location)
}
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
/// ChatType.Bound.decorate: the chat type's translation, with the
/// sender, the target and the message as its parameters, in its style.
@@ -3121,6 +3237,8 @@ impl Game<'_> {
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3234,6 +3352,15 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
// AbstractSignEditScreen.tick: the editor closes once its sign is removed.
if let Some(edit) = &self.sign_edit
&& !matches!(
self.world.block_entities.get(&edit.pos),
Some(BlockEntity::Sign(_))
)
{
self.close_sign_editor();
}
self.track_experience_display();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
@@ -3289,6 +3416,8 @@ impl Game<'_> {
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
@@ -3607,6 +3736,7 @@ impl Game<'_> {
m.fluids = fluids;
}
}
let game_time = self.world.game_time;
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3730,6 +3860,8 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
@@ -4331,6 +4463,16 @@ impl Game<'_> {
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
// no UseItem follows (the server does the harvest, see `use_beehive`).
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
&& self
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (true, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
[0, 1, 0] => Direction::Up,
@@ -5394,6 +5536,11 @@ impl Game<'_> {
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (from the
/// player list).
@@ -4,7 +4,7 @@
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_client::mobs::{Mob, Species, Who, bat, camel, sniffer, sync, tame};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::Builtin;
@@ -27,6 +27,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
// off hand 2. The flag starts and stops the use; the client ticks
// the count itself (a bow's pull follows it).
@@ -100,6 +102,29 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
(Species::Warden, 16, Value::Int(anger)) => {
if let Some(st) = m.extra.warden.as_mut() {
st.client_anger = *anger;
}
}
// Sniffer.DATA_STATE (after AgeableMob's two): the animation
// follows it in `sync::client_tick`.
(Species::Sniffer, 18, Value::Id(state)) => {
sniffer::set_state(m, (*state).clamp(0, 6) as u8);
}
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID.
(Species::Shulker, 16, Value::Id(face)) => m.extra.variant3 = (*face).clamp(0, 5) as u8,
(Species::Shulker, 17, Value::Byte(peek)) => m.extra.counter = i32::from(*peek),
(Species::Shulker, 18, Value::Byte(color)) => m.extra.color = *color as u8,
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's
// flags).
(Species::Camel | Species::CamelHusk, 19, Value::Bool(dashing)) => {
camel::set_dashing(m, *dashing);
}
(Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => {
camel::set_synced_pose_tick(m, *tick);
}
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
@@ -1,9 +1,10 @@
//! Boats, minecarts, armor stands, item frames and paintings on a server's
//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's
//! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`,
//! `handleSetEquipment`): the server simulates them, the client places them
//! where it says and interpolates their moves like a mob's.
use super::Game;
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
use horizon_client::mobs::leash::Holder;
use horizon_core::geometry::Vec3;
use horizon_protocol::entity_data::Value;
use std::sync::Arc;
@@ -24,7 +25,17 @@ impl Game<'_> {
let at = Vec3::new(position[0], position[1], position[2]);
let block = position.map(|v| v.floor() as i32);
self.remove_remote_object(id);
if kind == "minecraft:text_display" {
if kind == "minecraft:leash_knot" {
// A lead that came before its knot waits as a mob holder with the
// knot's id (handleEntityLinkPacket's delayed holder); now it can
// be tied to the fence.
self.remote_knots.insert(id, block);
for m in &mut self.mobs {
if m.extra.leash == Some(Holder::Mob(id)) {
m.extra.leash = Some(Holder::Knot(block));
}
}
} else if kind == "minecraft:text_display" {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
@@ -304,6 +315,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.remote_knots.clear();
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
@@ -314,6 +326,7 @@ impl Game<'_> {
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.remote_knots.remove(&id);
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
@@ -3,8 +3,11 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
collisions::SceneCollisions,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
@@ -71,7 +74,8 @@ pub(super) fn cloud_client_tick(
];
(particle.clone(), v)
};
particles.add(&options, at, velocity);
// `addAlwaysVisibleParticle`.
particles.add_with(&options, at, velocity, false, true);
}
}
@@ -444,31 +448,58 @@ impl Game<'_> {
let id = t.id;
t.remote_step(|at| step(id, at));
}
// FireworkRocketEntity.tick: a rocket attached to the gliding local
// player boosts it client-side too (attachedToEntity.isFallFlying).
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
// and boosts it client-side too (attachedToEntity.isFallFlying), and
// every rocket leaves its spark trail on a client.
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
for r in &mut self.rockets {
let id = r.id;
r.remote_step(|at| step(id, at));
if me.is_some()
&& r.attached_to == me
&& let Some(p) = self.player.as_mut()
&& p.fall_flying
{
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
r.position = p.position;
r.velocity = p.velocity;
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
r.remote_step(&mut SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
});
if let Some(target) = r.attached_to {
if me == Some(target) {
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
}
r.position = p.position;
r.velocity = p.velocity;
}
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
// Another player's motion is not known here: its step.
r.velocity = Vec3::new(
m.position.x - r.previous.x,
m.position.y - r.previous.y,
m.position.z - r.previous.z,
);
r.position = m.position;
}
}
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Firework),
[r.position.x, r.position.y, r.position.z],
v,
);
}
self.rockets = rockets;
for c in &mut self.crystals {
c.time += 1;
c.position = step(c.id, c.position);
+33 -3
View File
@@ -245,6 +245,7 @@ impl Game<'_> {
1.0,
);
}
self.particles.particle_level = self.settings.particles;
if let Some(e) = eye {
self.particles.camera = e;
}
@@ -288,13 +289,16 @@ impl Game<'_> {
e[2].floor() as i32,
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
// The Horizon Weather option off means no rain visuals at all.
// The Horizon Weather option off means no rain visuals or sounds at all.
if rain > 0.0 && self.settings.horizon.weather {
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
// ClientLevel.tickWeatherEffects: rain splashes within the weather
// radius, and the rain sound.
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = self.settings.weather_radius;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32
/ if self.settings.particles == 1 { 2 } else { 1 };
let mut rain_position = None;
for _ in 0..count {
let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius;
let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius;
@@ -307,6 +311,12 @@ impl Game<'_> {
continue;
}
let below = top - 1;
rain_position = Some([x, below, z]);
// The Minimal option stops at the first spot that rains (the
// rain sound still plays there).
if self.settings.particles >= 2 {
break;
}
let (bx, bz) = (random.next_double(), random.next_double());
let state = world.block(x, below, z);
let f = facts.get(state);
@@ -338,6 +348,26 @@ impl Game<'_> {
self.particles
.add(&ParticleOptions::simple(ty), p, [0.0; 3]);
}
// The last rain column of the tick makes the sound, more often the
// longer it has been quiet; indoors it is the quieter one.
if let Some(at) = rain_position {
let waited = self.rain_sound_time;
self.rain_sound_time += 1;
if random.next_int_bound(3).unwrap_or(0) < waited {
self.rain_sound_time = 0;
let roof =
motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT);
let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] {
("minecraft:weather.rain.above", 0.1, 0.5)
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
let p = at.map(|c| f64::from(c) + 0.5);
// playLocalSound: guests make their own rain, so a host
// must not capture it for them.
self.audio.play_unshared(name, Some(p), volume, pitch);
}
}
}
}
if let Some(r) = &self.renderer {
+53 -41
View File
@@ -322,6 +322,58 @@ impl Game<'_> {
);
}
}
/// LocalPlayer.respawn: the player comes back (the death screen's
/// button, or at once with `immediate_respawn`).
pub(super) fn respawn_player(&mut self) -> Result<()> {
if self.remote.is_none() {
// PlayerList.respawn removes the dead player, and Player.remove's
// inventoryMenu.removed drops what the cursor held, whatever
// keep_inventory says (the 20-tick branch handles a later respawn).
if let Some(stack) = self.carried.take()
&& let Some(eye) = self.eye_position()
{
self.throw_from_eyes(stack, eye, false);
}
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
self.experience.respawn(keep);
// The guests are sent the respawned host as a new entity, not the dead body.
if let Some(host) = self.lan.as_mut() {
let id = host.host_entity;
host.hub.respawned(id);
}
// Autosaves keep a login position; death must resolve the respawn point instead.
self.level.position = None;
}
if self.remote.is_some() {
self.ui.death_text = None;
self.toggles.respawn();
self.remote_respawn();
} else if self.respawn_config.is_some() {
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
self.toggles.respawn();
// PlayerList.respawn at the respawn anchor, once it is loaded.
self.begin_anchor_respawn()?;
self.screen = Screen::Playing;
self.ui.death_text = None;
self.spawn_player()?;
self.capture(true);
} else {
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
self.toggles.respawn();
self.respawn_out_of_end()?;
self.player = None;
self.screen = Screen::Playing;
self.ui.death_text = None;
self.spawn_player()?;
self.capture(true);
}
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.remote.is_none() && self.level.hardcore {
self.set_mode(GameMode::Spectator);
}
Ok(())
}
pub(super) fn death_click(&mut self) -> Result<()> {
if self
.ui
@@ -339,51 +391,11 @@ impl Game<'_> {
.into_iter()
.find(|d| ui::hovered(self.cursor, d.0, d.1, 200.0, 20.0))
.map(|d| d.3);
if hit == Some(0) && self.remote.is_none() {
let keep = self.ui.rules.bool("keep_inventory") || self.spectator();
self.experience.respawn(keep);
// The guests are sent the respawned host as a new entity, not the dead body.
if let Some(host) = self.lan.as_mut() {
let id = host.host_entity;
host.hub.respawned(id);
}
// Autosaves keep a login position; death must resolve the respawn point instead.
self.level.position = None;
}
match hit {
Some(0) if self.remote.is_some() => {
self.ui.death_text = None;
self.toggles.respawn();
self.remote_respawn();
}
Some(0) if self.respawn_config.is_some() => {
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
self.toggles.respawn();
// PlayerList.respawn at the respawn anchor, once it is loaded.
self.begin_anchor_respawn()?;
self.screen = Screen::Playing;
self.ui.death_text = None;
self.spawn_player()?;
self.capture(true);
}
Some(0) => {
// LocalPlayer.respawn: KeyMapping.resetToggleKeys.
self.toggles.respawn();
self.respawn_out_of_end()?;
self.player = None;
self.screen = Screen::Playing;
self.ui.death_text = None;
self.spawn_player()?;
self.capture(true);
}
Some(0) => self.respawn_player()?,
Some(1) => self.close_world()?,
_ => {}
}
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if hit == Some(0) && self.remote.is_none() && self.level.hardcore {
self.set_mode(GameMode::Spectator);
}
if hit.is_some() {
self.audio.play_button_click();
}
+36
View File
@@ -1515,6 +1515,16 @@ impl Game<'_> {
fn tick_entity_events(&mut self) {
self.tick_player_sculk_events();
self.tick_mob_sculk_events();
// Dropped items are entities too: Entity.applyEffectsFromBlocks.
let items: Vec<[f64; 3]> = self
.dropped
.iter()
.filter(|i| i.on_ground)
.map(|i| [i.position.x, i.position.y, i.position.z])
.collect();
for at in items {
self.step_on_redstone_ore(at, false);
}
}
/// Entity.move emits on the authoritative server too, for each ServerPlayer.
@@ -1530,6 +1540,9 @@ impl Game<'_> {
});
if let Some((at, on_ground, health, flying)) = player {
let crouch = self.keys.contains(&KeyCode::ShiftLeft);
if on_ground && !self.spectator() {
self.step_on_redstone_ore(at, crouch);
}
let in_water = self.in_water_at(at);
self.entity_motion_events(
Who::Player,
@@ -1548,6 +1561,26 @@ impl Game<'_> {
}
}
/// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the
/// entity is stepping carefully (`attack_block` only lights an unlit ore).
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) {
if carefully {
return;
}
// getOnPosLegacy: 0.2 below the feet.
let pos = [
at[0].floor() as i32,
(at[1] - 0.2).floor() as i32,
at[2].floor() as i32,
];
if matches!(
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
) {
let _ = self.attack_block(pos);
}
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
.mobs
@@ -1565,6 +1598,9 @@ impl Game<'_> {
})
.collect();
for (id, at, on_ground, in_water, species, damaged) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false);
}
let emits = !matches!(species, Species::Shulker);
if emits {
self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false);
+11 -6
View File
@@ -182,12 +182,17 @@ impl Game<'_> {
return;
};
let (mx, mz) = (m.position.x, m.position.z);
let disable = m
.extra
.main_hand
.as_deref()
.and_then(|item| self.res.items().get(item))
.map_or(0.0, |i| i.disable_blocking_seconds);
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value;
// Warden overrides it with a flat 5 seconds.
let disable = if m.species() == horizon_client::mobs::Species::Warden {
5.0
} else {
m.extra
.main_hand
.as_deref()
.and_then(|item| self.res.items().get(item))
.map_or(0.0, |i| i.disable_blocking_seconds)
};
// Ravager.blockedByItem: stunned half the time (the defender pushed
// apart), else the defender flung away; no roar in progress.
if m.species() == horizon_client::mobs::Species::Ravager {
+21 -9
View File
@@ -21,6 +21,9 @@ pub(super) struct SignEditor {
pub line: usize,
/// The line being edited (`AbstractSignEditScreen`'s text field).
edit: EditBox,
/// `AbstractSignEditScreen.messages`: the side's lines as edited, sent
/// when the editor closes, even if the sign is gone by then.
lines: [String; 4],
}
fn sign_box() -> EditBox {
@@ -185,11 +188,19 @@ impl Game<'_> {
}
/// `SignBlock.openTextEdit` for the front side after placement.
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
// Minecraft.setScreen: the editor already open is removed first.
self.close_sign_editor();
let lines = match self.world.block_entities.get(&pos) {
Some(BlockEntity::Sign(sign)) if front => sign.front.lines.clone(),
Some(BlockEntity::Sign(sign)) => sign.back.lines.clone(),
_ => Default::default(),
};
self.sign_edit = Some(SignEditor {
pos,
front,
line: 0,
edit: sign_box(),
lines,
});
self.sign_load();
self.keys.clear();
@@ -202,14 +213,11 @@ impl Game<'_> {
pub(super) fn close_sign_editor(&mut self) {
if let Some(edit) = self.sign_edit.take() {
if self.remote.is_some() {
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
let side = if edit.front { &sign.front } else { &sign.back };
self.send(S::SignUpdate(play::SignUpdate {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
lines: side.lines.clone(),
front: edit.front,
}));
}
self.send(S::SignUpdate(play::SignUpdate {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
lines: edit.lines,
front: edit.front,
}));
} else {
self.mark_block_entity_updated(edit.pos);
}
@@ -358,7 +366,8 @@ impl Game<'_> {
edit.edit.set_value(&text);
}
}
/// The box's text onto the sign, when it still fits the line.
/// The box's text onto the editor's lines and the sign
/// (`AbstractSignEditScreen.setMessage`), when it still fits the line.
fn sign_write(&mut self) {
let Some(edit) = self.sign_edit.as_ref() else {
return;
@@ -367,6 +376,9 @@ impl Game<'_> {
if !self.sign_fits(&value) {
return;
}
if let Some(edit) = self.sign_edit.as_mut() {
edit.lines[line].clone_from(&value);
}
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
return;
};
+10 -1
View File
@@ -257,8 +257,17 @@ impl Game<'_> {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
let at = m.position;
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
self.poof_particles(id);
}
/// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about
/// mob `id`.
pub(super) fn poof_particles(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
let options = ParticleOptions::simple(ParticleType::Poof);
for _ in 0..20 {
let r = self.particles.level_random();
+74
View File
@@ -3,6 +3,7 @@
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
use horizon_core::random::RandomSource;
pub(super) fn is_shulker_box(name: &str) -> bool {
name.ends_with("shulker_box")
@@ -156,6 +157,79 @@ impl Game<'_> {
);
paired.then_some((other, kind == "right"))
}
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
/// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of
/// both halves, a barrel from the face it opens on.
pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) {
let mut at = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
if let Some((other, _)) = self.chest_partner(key) {
at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5;
at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5;
} else if name == "minecraft:barrel"
&& let Some(state) = self
.pack
.assets
.registry
.state(self.world.block(key.0, key.1, key.2))
&& let Some(facing) = state.properties.get("facing")
{
let (dx, dy, dz) = match facing.as_str() {
"up" => (0.0, 0.5, 0.0),
"down" => (0.0, -0.5, 0.0),
f => {
let (x, z) = facing_step(f);
(f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0)
}
};
at = [at[0] + dx, at[1] + dy, at[2] + dz];
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(sound, Some(at), 0.5, pitch);
}
/// The open or close sound of the container block at `key`, if it has one.
/// Callers play it only when the opener count crosses zero
/// (`ContainerOpenersCounter.onOpen` / `onClose`).
pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) {
let name = self
.res
.facts()
.get(self.world.block(key.0, key.1, key.2))
.name
.clone();
let sound = if is_shulker_box(&name) {
if open {
"minecraft:block.shulker_box.open"
} else {
"minecraft:block.shulker_box.close"
}
} else if &*name == "minecraft:ender_chest" {
if open {
"minecraft:block.ender_chest.open"
} else {
"minecraft:block.ender_chest.close"
}
} else if name.contains("chest") {
if open {
"minecraft:block.chest.open"
} else {
"minecraft:block.chest.close"
}
} else if &*name == "minecraft:barrel" {
if open {
"minecraft:block.barrel.open"
} else {
"minecraft:block.barrel.close"
}
} else {
return;
};
self.container_sound(sound, key, &name);
}
/// The other half of a double chest at `key`, for neighbour updates.
pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> {
self.chest_partner(key).map(|p| p.0)
@@ -269,6 +269,8 @@ impl Game<'_> {
light,
layer: if through { 2 } else { 0 },
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
+5 -1
View File
@@ -178,7 +178,9 @@ impl Game<'_> {
];
self.tnts.remove(i);
count -= 1;
self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?;
self.with_hive_emptying_blast(|g| {
g.server_explode(center, 4.0, BlockInteraction::Destroy, false)
})?;
} else {
i += 1;
}
@@ -332,6 +334,8 @@ impl Game<'_> {
layer: if lit { 5 } else { 0 },
color,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
+61 -5
View File
@@ -6,6 +6,7 @@
use super::{Game, view_vector};
use horizon_client::{
collisions::SceneCollisions,
enchant::EffectContext,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind, Who, anim::Poses},
@@ -32,6 +33,8 @@ pub(super) struct ThrownTrident {
pub(super) no_physics: bool,
returning: u32,
creative_only: bool,
/// The guest that threw it (`Projectile.getOwner`); `None` for the host.
guest: Option<u32>,
}
impl ThrownTrident {
/// A trident a server sent (`handleAddEntity`): placed and moved by it.
@@ -51,6 +54,7 @@ impl ThrownTrident {
no_physics: false,
returning: 0,
creative_only: false,
guest: None,
}
}
/// One tick of a server's client: the previous position, then `step`.
@@ -302,11 +306,33 @@ impl Game<'_> {
no_physics: false,
returning: 0,
creative_only: self.creative,
guest: self.lan_acting,
});
self.player_projectile_shot();
self.audio
.play("minecraft:item.trident.throw", at, 1.0, 1.0);
}
/// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the
/// guest that threw it, and not at all once they have left.
fn thrower_hit(
&mut self,
guest: Option<u32>,
victim: &horizon_client::advancements::EntityView,
direct: horizon_client::advancements::EntityView,
damage: f32,
) {
let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else {
self.player_hit(victim, "minecraft:trident", Some(direct), damage);
return;
};
let Some(mut host) = self.lan.take() else {
return;
};
self.as_guest(&mut host, conn, |g| {
g.player_hit(victim, "minecraft:trident", Some(direct), damage)
});
self.lan = Some(host);
}
pub(super) fn tick_tridents(&mut self) {
let Some(eye) = self.eye_position() else {
return;
@@ -367,11 +393,29 @@ impl Game<'_> {
}
}
if let Some((id, _)) = hit_mob {
let impaling = t.stack.enchantment_level("minecraft:impaling") as f32;
let aquatic = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
m.kind.flags.aquatic_swimmer || m.kind.flags.breathes_water
});
let damage = 8.0 + if aquatic { 2.5 * impaling } else { 0.0 };
// ThrownTrident.getDamage: `EnchantmentHelper.modifyDamage` on 8
// (impaling hits `#sensitive_to_impaling`, the aquatic tag).
let victim = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone());
let ctx = EffectContext {
this: victim.as_deref(),
attacker: Some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
is_direct: true,
damage_type: Some("minecraft:trident"),
items: Some(self.res.items()),
..Default::default()
};
let damage = self.res.stations().enchantments.modify_value(
Some(&t.stack),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
);
t.dealt_damage = true;
let mut hurt_at = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
@@ -394,6 +438,18 @@ impl Game<'_> {
{
self.channel_lightning(at);
}
if hurt_at.is_some()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: "minecraft:trident".into(),
position: [t.position.x, t.position.y, t.position.z],
..Default::default()
};
self.thrower_hit(t.guest, &victim, direct, damage);
}
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
+2
View File
@@ -169,6 +169,8 @@ impl Game<'_> {
self.consume_held();
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &stack);
Ok(true)
}
fn vault_events(&mut self, key: Pos, events: Vec<VaultEvent>) -> Result<()> {
+27 -8
View File
@@ -148,7 +148,7 @@ impl Game<'_> {
// VillagerMakeLove: entity event 13 for the partner, then the parent.
for i in [ib, ia] {
let id = self.mobs[i].id;
self.villager_particles(id, "angry_villager");
self.mob_event_particles(id, 13);
}
return;
};
@@ -173,12 +173,31 @@ impl Game<'_> {
child.extra.bed = Some(bed);
}
// VillagerMakeLove.giveBirth: entity event 12 for the child.
self.villager_particles(id, "heart");
self.mob_event_particles(id, 12);
}
/// `Villager.handleEntityEvent` 12, 13, 14 and 42:
/// `AbstractVillager.addParticlesAroundSelf`.
pub(super) fn villager_particles(&mut self, id: u32, kind: &'static str) {
self.particles_around_mob(id, kind, horizon_client::particles::Burst::VILLAGER);
/// `Level.broadcastEntityEvent` of an event whose `handleEntityEvent`
/// draws particles about the mob (villager 12, 13 and 14, taming 6 and 7,
/// love 18): the guests get the byte, and this game draws it as its own
/// client would.
pub(super) fn mob_event_particles(&mut self, id: u32, event: i8) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
self.lan_entity_event(id as i32, event, at);
self.entity_event_particles(id, event);
}
/// `handleEntityEvent(event)`'s particles about mob `id`
/// (`Villager`, `TamableAnimal`, `AbstractHorse`, `Animal`, `Allay`).
pub(super) fn entity_event_particles(&mut self, id: u32, event: i8) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
if let Some((kind, burst)) =
horizon_client::particles::entity_event_burst(&m.kind.id, event as u8)
{
self.particles_around_mob(id, kind, burst);
}
}
/// `particles::around_self` about mob `id`.
pub(super) fn particles_around_mob(
@@ -315,7 +334,7 @@ impl Game<'_> {
}
// Villager.setLastHurtByMob: entity event 13.
for id in angry {
self.villager_particles(id, "angry_villager");
self.mob_event_particles(id, 13);
}
}
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
@@ -370,7 +389,7 @@ impl Game<'_> {
gossip::REPUTATION_CHANGE_PER_TRADE,
);
// Villager.customServerAiStep after a trade: entity event 14.
self.villager_particles(id, "happy_villager");
self.mob_event_particles(id, 14);
}
/// `Villager.getPlayerReputation` of the local player.
pub(super) fn villager_reputation(&self, id: u32) -> i32 {
+1
View File
@@ -470,6 +470,7 @@ impl Game<'_> {
}
/// The bursts guests' turns put off.
pub(super) fn flush_wind_bursts(&mut self) -> Result<()> {
self.flush_smash_pushes();
for b in std::mem::take(&mut self.creatures.wind_bursts) {
self.wind_burst(b.center, b.radius, b.multiplier, b.breeze, b.wind_charge)?;
}
+46 -1
View File
@@ -2,11 +2,53 @@
//! its boss bar, and its skulls hitting and exploding.
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Damage, DamageKind, Species, Who},
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::position::BlockPos;
impl Game<'_> {
/// `LivingEntity.createWitherRose`: a mob a wither killed leaves a wither
/// rose where it stood (with mobGriefing, on air that can hold it), else
/// drops one.
pub(super) fn create_wither_rose(&mut self, m: &horizon_client::mobs::Mob) {
// getKillCredit: the player who last hurt it, else the last mob (kept
// for 100 ticks).
let by_wither = m.ai.last_hurt_by_player <= 0
&& m.age as i32 - m.ai.last_hurt_by_time <= 100
&& matches!(m.ai.last_hurt_by, Some(Who::Mob(id))
if self.mobs.iter().any(|o| o.id == id && o.alive() && o.species() == Species::Wither));
if !by_wither {
return;
}
let pos = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let registry = self.pack.assets.registry;
let rose = registry.default_state("minecraft:wither_rose");
if self.ui.rules.bool("mob_griefing")
&& let Some(rose) = rose
&& self
.res
.facts()
.is_air(self.world.block(pos[0], pos[1], pos[2]))
&& self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]), rose)
})
{
let _ = self.level_set(pos, rose);
return;
}
self.spawn_item(
ItemStack::new("minecraft:wither_rose", 1),
[m.position.x, m.position.y, m.position.z],
super::dispenser::new_item_motion(),
0,
);
}
fn is_wither_skull(&self, p: [i32; 3]) -> bool {
let name = &*self
.res
@@ -130,7 +172,10 @@ impl Game<'_> {
if let Some(id) = owner {
self.note_attacker("minecraft:explosion", id);
}
self.explode(at, 1.0)
self.combat.explosion_owner = owner;
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
self.combat.explosion_owner = None;
result
}
}
/// Wither skulls drawn as the skull model, blue when dangerous.
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more