Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:24:26 +01:00
Ludovic Marie 6af60956ec Merge pull request #2637 from ludovic111/feat/launcher-language-home
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 08:01:36 +01:00
Ludovic Marie 8c1fa65f35 Merge pull request #2636 from ludovic111/feat/launcher-themes
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:51:55 +01:00
Ludovic Marie bc1549a438 Merge pull request #2635 from ludovic111/feat/launcher-language
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:42:16 +01:00
Ludovic Marie b6f18dc8f3 Merge pull request #2632 from ludovic111/feat/launcher-skins
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:32:21 +01:00
Ludovic Marie 504c9daf20 Merge pull request #2631 from ludovic111/feat/launcher-accounts
🤖 Agent: launcher-features-1: base main after #2627 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:21:08 +01:00
Ludovic Marie 5a6bf9705c Merge pull request #2696 from ludovic111/perf/parity-solo-window-harnesses
🤖 Agent: game-window harnesses run alone on 4-CPU machines (playhorizon runner); CI-like mix 8 of 8 on playhorizon.
2026-10-27 07:14:40 +01:00
Ludovic Marie 8f0797f258 Merge pull request #2627 from ludovic111/feat/launcher-linux-toggles
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:11:29 +01:00
Ludovic Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +01:00
Ludovic MarieandClaude Opus 5.5 83ee5c303b parity: game-window harnesses run alone on 4-CPU machines
On playhorizon's runner (CPUs 4-7, 4 harness slots, frames drawn on the CPU)
remote_fireworks still failed in #2215's job after #2589 and #2629: a
screenshot took ~1.5 s (58 ticks for two) beside three other harnesses. The
harnesses in parity_hosts.json's new solo list run alone there, after the
others; bigger machines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:49 +01:00
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +01:00
Ludovic Marie 7b11005688 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:37:26 +01:00
Ludovic MarieandClaude Opus 5.5 40f8b9600d Shulker canStayAt: only collidable entities block the shell
Level.noCollision(shulker, box) keeps the entities whose
canBeCollidedWith is true for the shulker: living shulkers and adult
happy ghasts (boats and minecarts too, outside the mob world). Players
and other mobs never stop a shulker from opening or landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 ab099b2701 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:28:26 +01:00
Ludovic MarieandClaude Opus 5.5 6cb5e4954e An occupied horse gives no inventory or feeding either
AbstractHorse.mobInteract (and Horse/ZombieHorse/AbstractChestedHorse)
hand an isVehicle horse to Mob.mobInteract before the inventory, the
feeding and the ride, so the passenger check goes before all three.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:21:51 +01:00
Ludovic MarieandClaude Opus 5.5 2ba33bd146 jumpOutOfFluid: only entities a mob collides with block the hop
EntityGetter.getEntityCollisions keeps the entities that canBeCollidedWith
the mob: among mobs that is a living shulker, not every mob nearby.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:20:42 +01:00
Ludovic MarieandClaude Opus 5.5 39c8a2aa0e launcher: Home and the sidebar in French and Russian
The second part of the launcher's translation: Home's banner, rows, crash notice and
Join a friend, the instance row and its actions, the sidebar's headings and instance
states, the partner label, play times and "last played" dates, and the resources line.
Play's label clears its icon in wider languages.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 17:13:36 +13:00
Ludovic MarieandClaude Opus 5.5 8beaa57331 launcher: an accent colour and a picture behind Home
Settings > Launcher picks an accent (violet by default, blue, teal, green, amber, red, pink)
and the player's own picture behind Home. An accent turns the violet tokens to its hue and
adjusts their lightness until text on the Play button and the accent on every background read
as well as the violet's. The picture is checked, copied under a name of its hash and shown
cropped under the banner's veils. CLI: settings accent NAME, settings background PATH|default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:07:38 +01:00
Ludovic MarieandClaude Opus 5.5 b3709b9766 launcher: French and Russian, following the system's language
A string table per language (launcher/lang/<code>.json, English text to its translation)
and tr()/trf() in i18n.rs. The window follows the system's language unless Settings >
Launcher > Language picks one; a missing string shows in English. This first part
translates the top bar, the account switch and the Settings page's account, update and
launcher rows; buttons, segments, section titles and page headers translate through their
widgets. CLI: settings language system|en|fr|ru.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:58:31 +01:00
Ludovic MarieandClaude Opus 5.5 b2199f5a0e launcher: a skin and a cape for each offline name
Settings > Account's Skin... shows the front of an offline name's skin and cape, takes a
skin PNG (64x64 or 64x32) and a cape PNG (64x32 or 22x17), and picks classic or slim arms.
The launcher keeps copies in skins/ and gives the game HORIZON_OFFLINE_SKIN,
HORIZON_OFFLINE_SKIN_MODEL and HORIZON_OFFLINE_CAPE when that name plays. CLI and MCP:
account skin.

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

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

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

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

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:36:53 +01:00
Ludovic MarieandClaude Opus 5.5 bad95e4f6a horizon-mod-lua: string.rep and table.concat keep bytes that are not UTF-8
Lua strings are byte strings. Both converted their arguments with
to_str_lossy, so a byte such as \255 (binary values a mod keeps in
horizon.storage, for one) came back as U+FFFD's three bytes.

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 02:31:11 +01:00
Ludovic Marie b03d1411d1 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-27 02:24:19 +01:00
Devin 42ef865c34 Give shulkers vanilla placement, push and look rules
A shulker used to check only that its cell was air and the support
block had a full collision box. canStayAt now mirrors vanilla: a moving
piston in the shell's own cell does not force a teleport, the support's
face toward the shell must be sturdy, and the fully opened shell's box
must be clear of blocks and entities. teleportSomewhere likewise checks
the target cell with noCollision and refuses faces tagged
minecraft:shulker_does_not_teleport_to, emits level event 2016 (whose
particles now render), and forces a position sync.

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-27 01:27:41 +01:00
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

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

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

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

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

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

Fixes #2471

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:50:24 +01:00
Ludovic Marie a24a6fa26c Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 23:40:35 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
FusselTVandClaude Opus 5.5 a55c825467 Minecarts save HasTicked and Fire 0
AbstractMinecart saves Entity.firstTick as HasTicked, and a cart's fire
stays 0: Entity.move sets it to -getFireImmuneTicks(), which is 0 for
everything but players. A mineshaft's chest minecart was saved without
HasTicked and with Fire -1; vanilla's generated chunk saves it with
HasTicked 1 and Fire 0, and a cart that has ticked with HasTicked 0 and
Fire 0.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:22:02 +01:00
Ludovic Marie 6d9bb375c5 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 21:43:18 +01:00
Kacpherek ae3737b1c2 Mod API: render.set-far-terrain, and the official Far View mod
A mod can now turn on the game's distant terrain (LOD) with
render.set-far-terrain: a distance of 64 to 1024 chunks and Far Detail's
Reduced. The game builds and draws the tiles as it does for the LOD
Distance setting, which wins while it is on. One far terrain shows at a
time, the first loaded mod's. API level 4.

mods/official/far-view is the official mod on it: Distance (256 chunks by
default) and Far detail on its settings page, kept in its storage.
2026-10-26 20:34:08 +01:00
mahirsn 766bfbcdfa fix(client): answer the server's held slot like vanilla
ClientboundSetHeldSlotPacket only changes the selected hotbar slot (and ignores slots outside the hotbar), so the next tick sends ServerboundSetCarriedItemPacket as vanilla does. Each login starts again from slot 0, as handleLogin's new MultiPlayerGameMode and LocalPlayer do.
2026-10-26 21:48:55 +03:00
Ludovic Marie 10c9d8440c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 19:13:17 +01:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

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

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

Fixes #2273

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

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

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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 17:51:54 +01:00
Eshayat Al-WasiuandCodex 2a98f584df Send cushion breaking particles to nearby multiplayer clients
Co-Authored-By: Codex <[email protected]>
2026-10-26 22:40:40 +06:00
Ludovic MarieandClaude Opus 5.5 7f6d556e68 docs: Lua mods' page goes to horizon-site with the rest of website/
docs/mods.md keeps the reference, plus what a capability missing from the features does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:09:10 +01:00
Ludovic MarieandClaude Opus 5.5 d33f547bf7 Merge origin/main into feat/mods-lua
website/ moved to its own repo (#2286): this branch's changes to make-a-mod.md and nav.json go there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:08:55 +01:00
SenjinandClaude Opus 5.5 cfd2dc8392 HDR: present HDR10 (PQ) where the surface offers it, scRGB otherwise
HDR10 is what games usually present and what the display receives: a
compositor composes in it or converts to it anyway. The NVIDIA driver
offers it on Wayland next to scRGB. A 10-bit swapchain also halves the
last pass's bandwidth; the frame before it is 8-bit, so nothing is lost.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
Ludovic Marie 2e7283d01c Merge remote-tracking branch 'origin/main' into feat/mods-lua 2026-10-26 16:10:40 +01:00
rdbtCVSandOpenAI GPT-6.1 Sol 9e71837df9 Keep server logging from panicking on a closed stdout pipe
Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 14:56:15 +00:00
Ludovic Marie 9a7f955a77 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
#	docs/api/stability.md
2026-10-26 11:54:53 +01:00
Ludovic Marie 6b7617c277 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 09:44:13 +01:00
Ludovic MarieandClaude Opus 5.5 7c5efff307 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 08:31:10 +01:00
Ludovic MarieandClaude Opus 5.5 1f6e498238 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 07:48:21 +01:00
Ludovic MarieandClaude Opus 5.5 32f6079134 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 06:03:23 +01:00
Ludovic MarieandClaude Opus 5.5 ce1db1b0c3 Merge main into feat/mods-lua
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:20:16 +01:00
Ludovic Marie ad3ddeb43f Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/horizon-mods/tests/sandbox.rs
2026-10-26 01:43:14 +01:00
Ludovic Marie 34f4d3b451 Merge remote-tracking branch 'origin/main' into feat/mods-lua
# Conflicts:
#	Cargo.toml
2026-10-26 00:25:27 +01:00
Ludovic MarieandClaude Opus 5.5 27de895fdb Mods in Lua: horizon-mod-lua, a Lua 5.4 interpreter built into the mod
A mod's code can be a Lua script. The crate horizon-mod-lua embeds
piccolo (pure Rust Lua, MIT) in the wasm guest with the script, so a Lua
mod is an .hmod like any other: same sandbox, capabilities, fuel and
checks. `horizon_mod_lua::lua_mod!("main.lua")` is the whole Rust side.

- The script runs once at load and registers commands with
  horizon.command; hooks are globals (on_tick, on_frame, on_command,
  on_chat). Capabilities are tables of `horizon` (game, chat, hud,
  storage), each behind a cargo feature so unused imports aren't linked.
- piccolo's stdlib lacks them: string.format (C flags, width,
  precision), string.rep, table.insert/remove/concat.
- The sandbox has no entropy: getrandom 0.2 and 0.3 get a custom
  SplitMix64 backend (cfg getrandom_backend="custom", set by
  cargo horizon-mod build).
- Example coords-hud-lua: coords-hud line for line. The sandbox tests
  run the same checks on both, and measure a tick of each.
- cargo horizon-mod new --template lua.
- Docs: docs/mods.md, website "Write it in Lua", changelog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 20:32:50 +01:00
223 changed files with 8800 additions and 4934 deletions

No files matched your search

+3
View File
@@ -72,6 +72,9 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+1
View File
@@ -66,6 +66,7 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
Generated
+122 -2
View File
@@ -477,6 +477,13 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -930,6 +937,13 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "far-view"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1093,6 +1107,30 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1257,6 +1295,16 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1461,6 +1509,16 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2737,6 +2795,21 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2864,6 +2937,15 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -2964,6 +3046,27 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3443,6 +3546,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3560,6 +3669,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -5221,7 +5341,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
@@ -5262,7 +5382,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
"synstructure 0.14.0",
]
[[package]]
+3
View File
@@ -30,15 +30,18 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+54 -8
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,6 +26,8 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -42,7 +44,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -83,18 +85,26 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let sdk = match flag(args, "--sdk-path") {
let (sdk, lua) = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
format!("{{ path = {:?} }}", p.display().to_string())
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
}
None => SDK_GIT.to_string(),
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -146,10 +156,19 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
other => return Err(format!("no template {other}: code or content")),
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
};
for &(file, text) in files {
let file = fill(file);
@@ -215,6 +234,13 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -386,6 +412,26 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -424,7 +470,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"lua".into()
"python".into()
])
.is_err()
);
@@ -0,0 +1,17 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -0,0 +1,12 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -0,0 +1,12 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -0,0 +1,2 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
particles::along_axis(&mut random, axis as usize, p, |p, v| {
lines.push(line(p, v))
});
}
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
let name = i.map_or("classic", |i| {
reg.names.get(i as usize).map_or("?", |n| n.as_str())
)
});
format!("minecraft:{name}")
};
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
match s {
@@ -75,10 +75,6 @@ pub fn main() -> Result<()> {
registry: assets.registry,
raining_at: &|_| false,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -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,7 +1050,6 @@ 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,
@@ -1062,7 +1061,6 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1100,7 +1098,6 @@ 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,
@@ -5,6 +5,10 @@
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -93,8 +97,23 @@ pub fn main() -> horizon_client::Result<()> {
_ => BookType::Smoker,
}
.index();
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
let packet = RecipeBookChangeSettings {
book_type: i as i32,
open: op["open"].as_bool().unwrap_or(false),
filtering: op["filtering"].as_bool().unwrap_or(false),
};
let mut wire = Writer::new();
RecipeBookChangeSettings::write(&packet, &mut wire);
extra = format!(
" packet={}",
wire.data
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
book.open[decoded.book_type as usize] = decoded.open;
book.filtering[decoded.book_type as usize] = decoded.filtering;
}
"save" => {
let tag = book.write();
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
muffled: true,
in_water: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
@@ -353,7 +353,6 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
+22 -153
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
collections::{BTreeMap, HashMap},
path::Path,
};
@@ -437,8 +437,6 @@ 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>,
@@ -460,7 +458,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
pub fn range_i(v: &Value, x: i64) -> bool {
fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -475,51 +473,6 @@ 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() {
@@ -661,18 +614,6 @@ 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() {
@@ -688,7 +629,21 @@ 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" => distance(p, e.position, origin),
"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: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)
@@ -723,16 +678,11 @@ 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)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"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/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
@@ -753,87 +703,6 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
/// 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>,
+1 -235
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
world::World,
};
use horizon_core::{
@@ -73,22 +73,6 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -103,7 +87,6 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -184,38 +167,6 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -286,8 +237,6 @@ pub struct AnimateTables {
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -353,25 +302,8 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
@@ -403,14 +335,6 @@ pub struct AnimateContext<'a> {
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -459,9 +383,6 @@ impl AnimateContext<'_> {
],
))
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -1106,99 +1027,6 @@ fn block_animate_tick(
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -1454,68 +1282,6 @@ fn block_animate_tick(
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
-3
View File
@@ -159,8 +159,6 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -210,7 +208,6 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
@@ -5,11 +5,7 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use crate::{hud::Rect, mesher::Vertex, world::World};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -936,30 +932,6 @@ impl BlockEntityRenderer {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
}
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
/// at `pos`: the faces its portal is drawn on.
pub fn gateway_faces(
&self,
world: &World,
state: BlockStateId,
pos: [i32; 3],
) -> Vec<Direction> {
Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(state, neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect()
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
@@ -1413,7 +1385,6 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1431,11 +1402,6 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1466,8 +1432,22 @@ impl BlockEntityRenderer {
out,
);
}
// AbstractEndPortalRenderer.extractRenderState.
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
let faces: Vec<Direction> = Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(BlockStateId(*state), neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect();
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
+8 -23
View File
@@ -40,8 +40,6 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -59,7 +57,6 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -120,12 +117,11 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -183,22 +179,18 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None, "clean_armor");
return changed(lowered(cauldron), None);
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
changed(state("minecraft:cauldron", None), Some(sound))
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound), "fill_cauldron")
changed(block, Some(sound))
};
match item {
"minecraft:lava_bucket" => {
@@ -207,7 +199,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -230,7 +221,6 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -251,7 +241,6 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -266,11 +255,7 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -281,7 +266,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
changed(next, Some("minecraft:item.bottle.empty"))
}
("water", name)
if name
@@ -298,7 +283,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None, "clean_banner")
changed(lowered(cauldron), None)
}
("water", name)
if name
@@ -307,7 +292,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None, "clean_shulker_box")
changed(lowered(cauldron), None)
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+11 -8
View File
@@ -76,14 +76,11 @@ impl<'a> IntoIterator for &'a ChunkMap {
}
}
impl crate::light::LightWorld for ChunkMap {
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
self.columns.state(x, y, z)
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
}
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
self.columns.light(x, y, z)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
self.columns.set_light(x, y, z, packed);
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
}
}
@@ -106,7 +103,13 @@ mod tests {
for column in map.values_mut() {
Arc::make_mut(column).light_loaded = true;
}
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
crate::light::LightWorld::set_section_light(
&mut map,
0,
0,
0,
crate::chunk::Light::Uniform(0),
);
assert_eq!(inserted, map.membership_revision());
map.remove(&(0, 0));
map.insert((1, 0), chunk);
-12
View File
@@ -728,18 +728,6 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+9 -13
View File
@@ -129,16 +129,15 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
_ => None,
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
/// the amount rounds to the nearest whole point).
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -214,9 +213,8 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -226,7 +224,6 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -249,7 +246,6 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -469,13 +465,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert, false);
let (_, added) = fx.tick_with(&mut convert);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -490,9 +486,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch, false);
fx.tick_with(&mut watch);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+17 -9
View File
@@ -44,8 +44,6 @@ 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>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(11) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -94,10 +92,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
if normal.w != 0.0 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -203,7 +198,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
if color.a < 0.1 { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
@@ -224,9 +219,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
+204 -34
View File
@@ -244,7 +244,51 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
let species = m.species();
if let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
map.insert("variant".into(), string(&variant));
}
if species == crate::mobs::Species::Chicken {
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
}
let sounds = crate::mobs::finalize::sound_variants(species);
if !sounds.is_empty() {
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
let sound = sound.copied().unwrap_or("classic");
map.insert(
"sound_variant".into(),
string(&format!("minecraft:{sound}")),
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
@@ -260,10 +304,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:wolf" => {
if let Some(owner) = m.extra.owner {
@@ -273,10 +313,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
@@ -311,6 +347,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -487,10 +527,6 @@ 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));
@@ -804,6 +840,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -939,20 +981,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -963,14 +1030,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
.iter()
.position(|(n, _)| *n == v)
.unwrap_or(0) as u8;
}
}
if matches!(
m.species(),
@@ -1069,12 +1128,6 @@ 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;
@@ -2577,6 +2630,123 @@ mod sheep_nbt_tests {
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
#[test]
fn farm_animals_keep_their_variant_and_sound_variant() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:pig"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
);
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:classic")
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
types["minecraft:chicken"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
let registries = finalize::SpawnRegistries::load(&root);
for (species, registry) in [
(Species::Pig, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.wolf_sound),
] {
assert_eq!(
finalize::sound_variants(species),
registry.names,
"{species:?}"
);
}
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
};
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
assert_eq!(number(tag.get("RabbitType")), Some(4));
assert_eq!(back.extra.variant, 4);
let mut goat = new("minecraft:goat");
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
let (back, tag) = round_trip(&goat);
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
assert_eq!(
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
[false; 2]
);
}
}
#[cfg(test)]
+188 -284
View File
@@ -157,6 +157,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -508,6 +509,12 @@ enum Screen {
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
ServerLinks,
/// ConfirmLinkScreen for a server link (game/server_links.rs).
ConfirmLink,
/// DialogScreen.WarningScreen: the note about custom screens.
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
@@ -564,8 +571,6 @@ struct GameLevel<'w> {
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -644,37 +649,6 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
/// minecart on it, a command block cart first (its success count, which
/// is 0 here), else the first container cart's fullness.
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
if !powered {
return 0;
}
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let area = Aabb::new(
Vec3::new(x + 0.2, y, z + 0.2),
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
continue;
}
match kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -735,14 +709,6 @@ impl LevelAccess for GameLevel<'_> {
state: BlockStateId,
_direction: Direction,
) -> i32 {
if let Some(s) = self.registry.state(state)
&& s.name == "minecraft:detector_rail"
{
return self.detector_rail_signal(
pos,
s.properties.get("powered").is_some_and(|v| v == "true"),
);
}
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
return signal;
}
@@ -1517,8 +1483,6 @@ 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.
@@ -1526,8 +1490,6 @@ struct Game<'a> {
sleep_timer: u32,
dim: Dim,
world_root: PathBuf,
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
world_flat: bool,
/// A player carried through a portal, re-placed once the destination loads.
travelling: Option<(PlayerMotion, Vec3)>,
/// A server's players on their way to another level, each waiting for
@@ -1603,12 +1565,6 @@ 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).
@@ -1726,6 +1682,7 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
@@ -1749,6 +1706,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -1783,6 +1743,8 @@ struct Game<'a> {
restore_player_state: bool,
stats_ui: stats_screen::StatsUi,
adv_ui: advancements_screen::AdvUi,
/// The Server Links screens' state (game/server_links.rs).
links_ui: server_links::LinksUi,
camera_type: perspective::CameraType,
fangs: fangs::FangState,
/// The local player as a model (third person, the inventory).
@@ -2613,14 +2575,8 @@ impl Game<'_> {
pickups::Taken::Item(Box::new(item.clone())),
pickups::Collector::Player,
);
// handleTakeItemEntity: the random pitch of a full pickup.
let pitch = ((self.world.random.next_float()
- self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
}
item.stack = rest;
}
@@ -2677,7 +2633,6 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2720,7 +2675,6 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -2775,13 +2729,9 @@ impl Game<'_> {
self.process_level_events()
}
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
let note = self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]))
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).
@@ -3082,6 +3032,11 @@ impl Game<'_> {
pos,
data,
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2016,
pos,
data,
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2017,
pos,
@@ -3116,34 +3071,6 @@ impl Game<'_> {
pos,
data,
} => self.composter_fill_event(pos, data == 1),
// LevelEventHandler 1504: the stalactite's drip particle.
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
// LevelEventHandler 2006: the dragon fireball's burst.
LevelEvent::LevelEvent {
id: 2006,
pos,
data,
} => self.dragon_fireball_burst(pos, data),
// LevelEventHandler 2009: a wet sponge drying.
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
LevelEvent::LevelEvent {
id: 2014,
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
}
LevelEvent::LevelEvent {
id: id @ 3011..=3021,
pos,
@@ -3159,16 +3086,6 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
)),
1047 => Some((
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
2.0,
)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
@@ -3190,13 +3107,6 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
// LevelEventHandler draws these from the client
// level's random, before 1501's and 1502's smoke.
1501 | 1502 => {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
@@ -3215,15 +3125,6 @@ impl Game<'_> {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
match id {
1501 => self.puffs_above_block(
pos,
horizon_client::particles::ParticleType::LargeSmoke,
),
1502 => self.torch_burnout_smoke(pos),
_ => {}
}
}
LevelEvent::Sound {
pos,
@@ -3290,8 +3191,6 @@ impl Game<'_> {
// Popped as the behaviour emits it (GameLevel::emit).
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -3425,21 +3324,20 @@ impl Game<'_> {
self.open_command_block(key);
return;
}
// SignBlock.useWithoutItem: the text editor, not a container.
if horizon_client::signs::sign_kind(&name).is_some() {
self.use_sign_empty_hand(pos);
return;
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
}
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);
} 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()))
@@ -3455,7 +3353,7 @@ impl Game<'_> {
];
self.resolve_container_maps(key, centre);
self.container = Some(key);
let first = self.container_opener_event(key, true);
self.container_opener_event(key, true);
self.post_block_game_event(
"minecraft:container_open",
[key.0, key.1, key.2],
@@ -3479,8 +3377,26 @@ impl Game<'_> {
if self.guarded_by_piglins(&name) {
self.anger_piglins(true);
}
if first {
self.container_edge_sound(key, 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,
);
}
} else if self.open_station(pos, &name) {
// Workstation menus: game/stations.rs.
@@ -3804,21 +3720,6 @@ impl Game<'_> {
{
self.hive_broken_by_player(pos, hive, honey, facing);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
let xp = if silk && &*facts.name != "minecraft:spawner" {
0
@@ -3940,14 +3841,6 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_sign_applicator(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_golem_statue(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_jukebox(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -3968,10 +3861,6 @@ 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);
@@ -4011,20 +3900,6 @@ impl Game<'_> {
)
});
self.process_level_events()?;
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
let name = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
_ => {}
}
}
if result.consumes_action() {
self.swing_arm();
return Ok(true);
@@ -4328,8 +4203,6 @@ 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;
@@ -4357,6 +4230,7 @@ impl Game<'_> {
if &*stack.item == "minecraft:spyglass" {
// SpyglassItem.USE_DURATION, then finishUsingItem.
if ticks + 1 >= 1200 {
self.send_use_item_complete();
self.stop_spyglass();
self.using = None;
} else {
@@ -4378,6 +4252,7 @@ impl Game<'_> {
}
if ticks >= duration {
self.using = None;
self.send_use_item_complete();
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
@@ -4847,11 +4722,13 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission { state, muffled, .. } if !crouching => {
MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water.
let (volume, pitch) = if muffled {
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
@@ -4917,10 +4794,6 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// Player.causeFallDamage: a fall of 2 blocks or more counts.
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
self.stats.custom("fall_one_cm", cm)
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
@@ -5260,6 +5133,9 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
| Screen::CommandBlock
) || self.book.is_some()
|| self.win_screen_pauses())
@@ -5743,6 +5619,9 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
@@ -6163,15 +6042,41 @@ 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);
let last = self.container_opener_event(key, false);
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,
);
if last {
self.container_edge_sound(key, false);
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,
);
}
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
@@ -6691,7 +6596,6 @@ 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] {
@@ -6707,9 +6611,6 @@ 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())
@@ -8029,15 +7930,13 @@ impl Game<'_> {
}
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
);
self.particles.add(&sweep, at, [0.0; 3]);
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
@@ -8192,6 +8091,11 @@ impl Game<'_> {
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
let player = self.player.as_ref();
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
if knockback_attack {
@@ -8206,15 +8110,6 @@ 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);
@@ -8280,14 +8175,6 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too.
self.lan_send_particles(
&indicator,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
);
}
self.last_hurt_by_player
.insert(self.mobs[index].id, self.lan_acting);
@@ -8308,32 +8195,16 @@ 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 {
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 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);
}
}
if infested {
let id = self.mobs[index].id;
self.mob_infested(id);
}
self.weapon_hit(index);
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
// (inside `if (wasHurt)`), after damageStatsAndHearts.
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
} else {
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
@@ -8594,7 +8465,6 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
self.clear_entities();
@@ -8898,10 +8768,6 @@ impl Game<'_> {
}
self.lan_close(false);
self.save_game()?;
if let Some(mut remote) = self.remote.take() {
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
remote.session.disconnect();
}
self.saved_for_exit = true;
Ok(())
}
@@ -9183,9 +9049,6 @@ impl Game<'_> {
if result.outcome == Outcome::Pass {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
@@ -9295,8 +9158,6 @@ 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);
@@ -9569,8 +9430,6 @@ 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(
@@ -10284,7 +10143,37 @@ impl Game<'_> {
if self.flash > 0 {
self.flash -= 1;
}
// The rain sounds come with the rain splashes (`tick_particles`).
let (rain, _) = self.world.weather.levels(1.0);
let Some(eye) = self.eye_position() else {
return;
};
// WeatherEffectRenderer.tickRainParticles: occasional rain sounds.
if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 {
let r = &mut self.world.random;
let (dx, dz) = (
r.next_int_bound(21).unwrap_or(10) - 10,
r.next_int_bound(21).unwrap_or(10) - 10,
);
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
let top = self.rain_height(x, z);
if self.precipitation(x, top, z) == Some(false)
&& (top - eye[1] as i32).abs() < 10
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
{
let above = f64::from(top) > eye[1] + 1.0;
let (name, volume, pitch) = if above {
("minecraft:weather.rain.above", 0.1, 0.5)
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
fn weather_geometry(
@@ -11527,6 +11416,10 @@ impl Game<'_> {
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::Dead => {}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
Screen::Advancements => self.close_advancements(),
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
@@ -11595,8 +11488,10 @@ impl Game<'_> {
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
// (the server swaps the hands and sends both slots back; spectators can't).
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
if !self.spectator() {
self.remote_swap_hands();
if !self.spectator()
&& let Some(r) = self.remote.as_mut()
{
r.pending_keys.push(key);
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
@@ -11693,11 +11588,28 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
/// Distance when it is on, else a mod's far terrain
/// (`render.set-far-terrain`).
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
if g.lod_distance > 0 {
return (g.lod_distance, reduced);
}
match self.mod_far_terrain() {
Some(f) => (f.distance, reduced || f.reduced),
None => (0, reduced),
}
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
let (distance, _) = self.far_terrain_choice();
match &self.renderer {
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
None => self.settings.horizon.lod_distance.min(1024),
Some(renderer) => {
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
}
None => distance.min(1024),
}
}
/// Only enabled LOD worlds extend the renderer's range, never simulation.
@@ -11718,7 +11630,7 @@ impl Game<'_> {
}
}
fn prepare_lod(&mut self) {
let enabled = self.settings.horizon.lod_distance > 0
let enabled = self.far_terrain_choice().0 > 0
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
@@ -11815,6 +11727,7 @@ impl Game<'_> {
{
log::warn!("Horizon: failed to save unloaded chunks: {e}");
}
let (far_distance, far_reduced) = self.far_terrain_choice();
if let Some(renderer) = self.renderer.as_mut() {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
@@ -11868,12 +11781,10 @@ impl Game<'_> {
}
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
let lod_distance =
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
lod.set_near_loading(self.streaming.busy());
lod.set_reduced_detail(
self.settings.horizon.far_detail
== horizon_client::graphics::FarDetail::Reduced,
);
lod.set_reduced_detail(far_reduced);
// The covered columns change only with the complete ones and
// the render area: rebuilt then, not every frame.
let at = (
@@ -12207,6 +12118,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::WorldOptions && d != 0.0 {
self.world_options_scroll(d);
}
if self.screen == Screen::ServerLinks && d != 0.0 {
self.server_links_scroll(d);
}
if self.screen == Screen::Advancements {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
@@ -12398,6 +12312,15 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::ServerLinks if pressed && button == MouseButton::Left => {
self.server_links_click()?
}
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
self.confirm_link_click()
}
Screen::DialogWarning if pressed && button == MouseButton::Left => {
self.dialog_warning_click()?
}
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
@@ -12829,16 +12752,6 @@ impl ApplicationHandler for Game<'_> {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
{
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
if let Err(error) = self.begin_closing_save(true) {
self.error = Some(error);
event_loop.exit();
return;
}
}
if self.pending_save.is_some() {
match self.poll_closing_save() {
Ok(true) => event_loop.exit(),
@@ -13129,13 +13042,11 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
dim: Dim::Overworld,
world_root: PathBuf::new(),
world_flat: false,
travelling: None,
server_trips: Vec::new(),
level_teleports: Vec::new(),
@@ -13185,8 +13096,6 @@ 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()
@@ -13259,6 +13168,7 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
@@ -13267,6 +13177,7 @@ impl<'a> Game<'a> {
lan_acting: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
guest_entity_events: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
chat_capture: None,
@@ -13293,6 +13204,7 @@ impl<'a> Game<'a> {
restore_player_state: true,
stats_ui: Default::default(),
adv_ui: Default::default(),
links_ui: Default::default(),
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
@@ -14329,11 +14241,6 @@ impl Loading {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0 {
// No world is open yet: nothing to save.
event_loop.exit();
return;
}
if let Err(error) = self.poll_menu_resources() {
self.error = Some(error);
event_loop.exit();
@@ -14611,9 +14518,6 @@ pub fn run() -> Result<()> {
{
horizon_client::heartbeat::start();
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
@@ -9,7 +9,6 @@ use horizon_client::{
text::{self, Style, Text},
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -83,8 +82,6 @@ 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.
@@ -183,14 +180,6 @@ 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,
}
}
@@ -658,28 +647,6 @@ 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| {
@@ -714,136 +681,6 @@ 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))
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
});
}
/// `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();
@@ -871,101 +708,6 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
});
}
/// `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)
});
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["item"], &it, w)
&& adv::range_i(&c["levels"], i64::from(levels))
});
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& adv::item(&c["item"], &it, w)
});
}
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
/// and the items it caught.
pub(super) fn trigger_fishing_rod_hooked(
&mut self,
rod: &ItemStack,
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<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 {
+6
View File
@@ -419,6 +419,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -707,9 +710,12 @@ impl Game<'_> {
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+2 -3
View File
@@ -131,8 +131,7 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
@@ -161,7 +160,7 @@ impl Game<'_> {
}
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.mob_event_particles(id, 18);
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.consume_held();
-24
View File
@@ -164,35 +164,11 @@ impl Game<'_> {
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
}
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
let Some(bb) = self
.player
.as_ref()
.map(|pl| pl.dimensions.bounds(pl.position))
else {
return;
};
let (lo, hi) = (bb.min_position(), bb.max_position());
let [x, y, z] = p.map(f64::from);
if lo.x < x + 10.0
&& hi.x > x - 10.0
&& lo.y < y + 5.0
&& hi.y > y - 9.0
&& lo.z < z + 10.0
&& hi.z > z - 10.0
{
self.trigger_construct_beacon(levels);
}
}
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
+5 -138
View File
@@ -3,10 +3,8 @@
//! 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::{
@@ -196,139 +194,19 @@ 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 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]) {
/// without a target go for a player in the same box.
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));
let mut players: Vec<Option<u32>> = Vec::new();
if self
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())
@@ -341,20 +219,9 @@ impl Game<'_> {
.collect();
bees.retain(|&i| self.mobs[i].ai.target.is_none());
for i in bees {
// Util.getRandom over the players.
let pick = self
.world
.random
.next_int_bound(players.len() as i32)
.unwrap_or(0) as usize;
// Util.getRandom over the one player.
let _ = self.world.random.next_int_bound(1);
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,24 +76,23 @@ 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`). 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 {
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
// Spectators never count as openers.
!self.spectator() && self.container_openers_changed(key, open)
if !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) -> bool {
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: 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) => {
let edge = self.lid_openers_changed(key, open);
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
@@ -103,27 +102,26 @@ 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, edge) = if open {
let (count, _) = 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. True when the count crossed zero.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
@@ -141,15 +139,12 @@ 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`).
/// True when the count crossed zero.
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
let (before, count) = self.count_opener(key, open);
let edge = (before == 0) != (count == 0);
if edge {
if (before == 0) != (count == 0) {
self.set_bool_prop(
key,
"open",
@@ -157,7 +152,6 @@ 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`).
+1 -1
View File
@@ -862,7 +862,7 @@ impl Game<'_> {
let a = alpha as f32;
let range = super::entity_culling::default_range(
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
self.settings.entity_distance,
self.view_scale(),
);
for b in &self.boats {
let entity_start = out.len();
+6 -3
View File
@@ -1009,9 +1009,12 @@ impl Game<'_> {
}
};
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
let text = tr("book.pageIndicator", "Page %s of %s")
.replacen("%s", &(page + 1).to_string(), 1)
.replacen("%s", &count.max(1).to_string(), 1);
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
// positional ("Page %1$s of %2$s").
let text = horizon_protocol::text::format_java(
&tr("book.pageIndicator", "Page %1$s of %2$s"),
&[(page + 1).to_string(), count.max(1).to_string()],
);
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
-5
View File
@@ -789,11 +789,6 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
-24
View File
@@ -36,13 +36,6 @@ 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.
@@ -97,19 +90,6 @@ 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;
@@ -156,10 +136,6 @@ 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();
@@ -907,10 +907,12 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
));
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
+10 -11
View File
@@ -19,7 +19,17 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
impl Game<'_> {
/// `ServerPlayer.completeUsingItem`: the player's own client is told
/// first, and completes the use itself (eating the food there too).
pub(super) fn send_use_item_complete(&mut self) {
if self.lan_acting.is_some() {
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
}
}
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
pub(super) fn emit_consume(
&mut self,
@@ -57,17 +67,6 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -878,6 +878,9 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
@@ -204,64 +204,6 @@ impl Game<'_> {
}
}
// ---- interactions -----------------------------------------------------
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
/// wearing the axe. Weathered statues are scraped by the axe's own
/// transform instead.
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
return false;
}
let registry = self.pack.assets.registry;
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
return false;
};
if state.name != "minecraft:copper_golem_statue" {
return false;
}
// `Direction.toYRot` of the statue's facing.
let yaw = match state.properties.get("facing").map(String::as_str) {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
};
let waterlogged = state
.properties
.get("waterlogged")
.is_some_and(|w| w == "true");
// initCopperGolem: at the block's feet, facing as the statue does.
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
return false;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
m.body_yaw = yaw;
m.previous_body_yaw = yaw;
m.head_yaw = yaw;
m.previous_head_yaw = yaw;
}
self.damage_held(1);
self.audio
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
// Level.removeBlock(pos, false): air, or the water the statue stood in.
let replacement = registry.default_state(if waterlogged {
"minecraft:water"
} else {
"minecraft:air"
});
if let Some(replacement) = replacement {
let _ = self.level_set(pos, replacement);
}
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
true
}
/// `CopperGolem.mobInteract`; true when handled.
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
+59 -306
View File
@@ -46,22 +46,10 @@ 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>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
horizon_client::particles::ParticleOptions,
[f64; 3],
[f64; 3],
f64,
u32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -199,7 +187,6 @@ 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(),
@@ -211,7 +198,6 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
passenger: self.riding.is_some(),
})
}
/// Biome facts at a position for finalizeSpawn and variant rules.
@@ -433,7 +419,6 @@ 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 },
@@ -527,7 +512,6 @@ 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).
@@ -622,11 +606,6 @@ 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 {
@@ -706,16 +685,6 @@ 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()?;
@@ -788,7 +757,6 @@ 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);
@@ -919,18 +887,6 @@ 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
@@ -1024,6 +980,21 @@ impl Game<'_> {
}
}
}
MobEvent::Shift { target, delta } => {
let d = Vec3::new(delta[0], delta[1], delta[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(d);
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = m.position.add_vector(d);
}
}
}
}
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -1043,7 +1014,7 @@ impl Game<'_> {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius, species) => {
MobEvent::Explode(at, radius) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
@@ -1059,14 +1030,7 @@ impl Game<'_> {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
// 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)
};
let result = self.explode(at, radius);
self.combat.explosion_source = None;
result?
}
@@ -1077,7 +1041,6 @@ 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;
@@ -1086,27 +1049,20 @@ impl Game<'_> {
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
if 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);
}
}
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
@@ -1152,7 +1108,6 @@ 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()
@@ -1316,17 +1271,12 @@ impl Game<'_> {
}
}
MobEvent::AroundSelf {
mob,
event,
kind,
feet,
width,
height,
burst,
} => {
self.lan_entity_event(mob as i32, event, feet);
self.particles_around(kind, feet, [width, height], burst)
}
} => 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);
@@ -1437,22 +1387,6 @@ 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")
@@ -1581,8 +1515,6 @@ 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);
@@ -1855,10 +1787,6 @@ 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()
@@ -1871,23 +1799,6 @@ 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);
}
@@ -2189,7 +2100,6 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
self.flush_lan_explosions();
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
@@ -2541,35 +2451,6 @@ 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);
}
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
// (piercing arrows are not modelled).
if hurt
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
&& !m.alive()
{
let victim = self.mob_entity(m);
let weapon =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
self.trigger_killed_by_arrow(&[victim], weapon);
}
}
hurt
}
@@ -2613,7 +2494,6 @@ impl Game<'_> {
};
m.damage(amount, source);
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
}
(ProjectileKind::Egg, hit) => {
@@ -2639,7 +2519,6 @@ impl Game<'_> {
}
}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:egg");
}
(ProjectileKind::EnderPearl, hit) => {
@@ -2743,10 +2622,7 @@ impl Game<'_> {
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:explosion", owner);
}
self.combat.explosion_owner = self.combat.projectile_owner;
let result = self.explode(atp, *power as f32);
self.combat.explosion_owner = None;
result?;
self.explode(atp, *power as f32)?;
}
(
ProjectileKind::Potion {
@@ -2892,17 +2768,10 @@ 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.
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
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 {
@@ -2938,10 +2807,6 @@ 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) {
@@ -3003,14 +2868,6 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_armadillo(index, &item) {
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
if self.interact_copper_golem(index, &item) {
self.swing_arm();
return Ok(true);
@@ -3066,16 +2923,6 @@ impl Game<'_> {
consume = true;
sound = Some("entity.cow.milk");
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
});
}
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
give = Some("minecraft:mushroom_stew");
consume = true;
@@ -3196,8 +3043,14 @@ impl Game<'_> {
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.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3264,8 +3117,13 @@ impl Game<'_> {
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.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3313,42 +3171,18 @@ 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.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
consume = true;
let left = 24_000 - m.extra.age;
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
// One happy villager particle at rest about the body.
self.particles_around_mob(
self.mobs[index].id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
// its `getBeeInteractionEffect` instead of breeding the bee.
Species::Bee
if is_food
&& matches!(
&*item,
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
) =>
{
if &*item == "minecraft:wither_rose" {
m.add_effect("minecraft:wither", 40, 0);
} else {
m.add_effect("minecraft:poison", 25, 0);
}
consume = true;
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3361,11 +3195,6 @@ 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.
@@ -3441,50 +3270,6 @@ impl Game<'_> {
}
Ok(true)
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:brush"
|| m.species() != Species::Armadillo
|| !m.alive()
|| m.is_baby()
{
return false;
}
let (id, at) = (m.id, m.position);
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, 0.0, d);
}
self.audio
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:entity_interact", feet, ctx);
// ItemStack.hurtAndBreak(16).
if !self.creative
&& let Some(stack) = tool
{
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
let loss = self.durability_loss(&stack, 16);
let slot = self.held_slot();
if let Some(held) = self.stack_in_mut(slot).as_mut() {
held.damage += loss;
if held.damage >= max {
*self.stack_in_mut(slot) = None;
}
}
}
}
true
}
pub(super) fn damage_held_tool(&mut self) {
if self.creative {
return;
@@ -3562,45 +3347,6 @@ 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);
@@ -3675,7 +3421,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = f64::from(self.settings.entity_distance);
let scale = self.view_scale();
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
@@ -3684,9 +3430,16 @@ impl Game<'_> {
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the entity distance option.
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let range = (w + h + w) / 3.0 * 64.0 * scale;
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
@@ -219,7 +219,6 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
@@ -236,11 +235,6 @@ impl Game<'_> {
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
self.damage_held(if rocket { 3 } else { 1 });
}
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
}
}
}
+12 -15
View File
@@ -348,11 +348,15 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -361,14 +365,8 @@ impl Game<'_> {
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
@@ -455,8 +453,7 @@ impl Game<'_> {
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+127 -1
View File
@@ -24,6 +24,7 @@ use horizon_client::{
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
@@ -89,7 +90,9 @@ fn log_from(thread: &str, level: &str, message: &str) {
}
});
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
println!(
// The launcher can close its stdout pipe while the server keeps running.
let _ = writeln!(
std::io::stdout().lock(),
"[{}] [{thread}/{level}]: {message}",
chrono::Local::now().format("%H:%M:%S")
);
@@ -618,6 +621,22 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
vec![horizon_protocol::packets::configuration::ServerLink {
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
link: props.bug_report_link.clone(),
}]
} else {
log(
"WARN",
&format!("Failed to parse bug link {}", props.bug_report_link),
);
Vec::new()
},
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -1311,3 +1330,110 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
fn assert_log_lines(text: &str) {
let expected = [
format!("] [Server thread/INFO]: {LOGIN}"),
format!("] [User Authenticator #1/WARN]: {WARNING}"),
];
assert_eq!(text.lines().count(), expected.len());
for (line, suffix) in text.lines().zip(expected) {
assert!(line.starts_with('['));
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
assert_eq!(&line[9..], suffix);
}
}
#[test]
fn console_logging_survives_closed_stdout() {
if let Some(home) = std::env::var_os(CHILD_HOME) {
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
// Let the parent close the real stdout pipe only after libtest's startup output.
io::stdout().write_all(READY.as_bytes()).unwrap();
io::stdout().flush().unwrap();
io::stdin().read_exact(&mut [0]).unwrap();
std::thread::Builder::new()
.name("Server thread".into())
.spawn(|| {
let captured = capture_log(|| {
log("INFO", LOGIN);
log_from("User Authenticator #1", "WARN", WARNING);
});
assert_eq!(captured, [LOGIN, WARNING]);
horizon_client::logging::flush();
})
.unwrap()
.join()
.unwrap();
// Libtest's final status also writes to stdout; only the game logger is under test.
std::process::exit(0);
}
for closed_stdout in [false, true] {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!(
"horizon-console-log-test-{}-{unique}",
std::process::id()
));
fs::create_dir(&home).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"game::dedicated::tests::console_logging_survives_closed_stdout",
"--nocapture",
])
.env(CHILD_HOME, &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert!(
stdout.read_line(&mut line).unwrap() > 0,
"child was not ready"
);
if line.ends_with(READY) {
break;
}
}
if closed_stdout {
drop(stdout); // No reader remains: the next console write gets EPIPE.
} else {
child.stdout = Some(stdout.into_inner());
}
child.stdin.take().unwrap().write_all(&[1]).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"closed_stdout={closed_stdout}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !closed_stdout {
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
}
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
fs::remove_dir_all(home).unwrap();
}
}
}
@@ -120,11 +120,6 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+14 -64
View File
@@ -27,8 +27,6 @@ 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 {
@@ -272,8 +270,6 @@ 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],
@@ -295,11 +291,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
creative: bool,
spectator: bool,
}
@@ -401,7 +397,6 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -698,7 +693,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -727,7 +722,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -992,8 +987,6 @@ impl Game<'_> {
};
let hurt = d.hurt(part, damage, counts, arrow);
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
@@ -1573,7 +1566,6 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -1666,24 +1658,6 @@ 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;
@@ -2104,18 +2078,12 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
);
return false;
}
@@ -2142,10 +2110,6 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2293,22 +2257,8 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
duration: i32,
from_dragon: bool,
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
+1 -1
View File
@@ -19,7 +19,7 @@ use horizon_core::{
use std::{collections::HashMap, sync::Arc};
/// ServerLevel.END_SPAWN_POINT.
const END_SPAWN: [i32; 3] = [100, 50, 0];
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
@@ -60,12 +60,27 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
/// at 20 or more. The render distance is the client setting, capped by
/// the server's view distance when connected
/// (Options.getEffectiveRenderDistance).
pub(super) fn view_scale(&self) -> f64 {
let distance = match &self.remote {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
}
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the entity distance option.
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
let edge = (size.x + size.y + size.z) / 3.0;
let edge = if edge.is_nan() { 1.0 } else { edge };
edge * 64.0 * f64::from(view_scale)
edge * 64.0 * view_scale
}
impl Game<'_> {
+1 -43
View File
@@ -307,12 +307,6 @@ 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(
@@ -400,14 +394,7 @@ impl Game<'_> {
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
// 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),
attacker: exploding.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -503,15 +490,6 @@ 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(
@@ -628,22 +606,6 @@ 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);
@@ -676,10 +638,6 @@ 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") {
+40 -58
View File
@@ -23,7 +23,7 @@ pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
pub(super) position: Vec3,
pub(super) previous: Vec3,
previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
@@ -62,56 +62,11 @@ impl Rocket {
item,
}
}
/// 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) {
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
}
/// `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
self.position = step(self.position);
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -236,14 +191,46 @@ 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,
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
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
};
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -396,12 +383,7 @@ 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());
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
for r in self.rockets.iter().filter(|r| !r.attached) {
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+2 -25
View File
@@ -2,7 +2,6 @@
//! 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,
@@ -78,7 +77,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(stack);
let damage = self.retrieve_hook();
self.damage_held(damage);
self.audio.play(
"minecraft:entity.fishing_bobber.retrieve",
@@ -144,7 +143,7 @@ impl Game<'_> {
true
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
fn retrieve_hook(&mut self) -> u32 {
let Some(hook) = self.hook.take() else {
return 0;
};
@@ -168,15 +167,6 @@ 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 {
@@ -201,7 +191,6 @@ 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
@@ -233,7 +222,6 @@ 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,
@@ -253,15 +241,6 @@ 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 {
@@ -454,8 +433,6 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
+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: p.health.hurt_dir,
hurt_dir: 0.0,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
+3 -2
View File
@@ -311,8 +311,9 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
for f in &self.item_frames {
let entity_start = out.len();
let map = f.item.as_ref().and_then(super::maps::map_id);
@@ -79,7 +79,6 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -91,8 +90,6 @@ 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.
+33 -22
View File
@@ -21,6 +21,7 @@ use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use std::{
collections::HashMap,
@@ -338,14 +339,6 @@ fn mob_meta(
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
}
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
"minecraft:happy_ghast" => {
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
"minecraft:villager" | "minecraft:zombie_villager" => {
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
@@ -660,8 +653,7 @@ impl Game<'_> {
let host_uuid = host.unwrap_or(0);
self.next_entity += 1;
let host_entity = self.next_entity as i32;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
let hub = Hub::new(lan, sync, encoder, config);
let port = hub.lan.port;
self.world.block_log = Some(Vec::new());
self.world.light_log = Some(Default::default());
@@ -724,6 +716,7 @@ impl Game<'_> {
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -919,7 +912,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), another mob or a fence knot (game/lan_entities.rs).
// host) or another mob. A fence knot isn't an entity here.
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
@@ -928,7 +921,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(p) => self.knot_ids.get(&p).map(|&id| id as i32),
Holder::Knot(_) => None,
});
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];
@@ -937,9 +930,6 @@ impl Game<'_> {
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);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -1016,9 +1006,6 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
@@ -1179,11 +1166,8 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
fn lan_guest_joined(
@@ -1349,6 +1333,33 @@ impl Game<'_> {
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:block")
{
host.hub.broadcast_near(
at,
32.0,
&C::LevelParticles(play::LevelParticles {
particle: horizon_protocol::entity_data::Particle {
id,
data: Some(horizon_protocol::entity_data::ParticleData::State(
state.0 as i32,
)),
},
override_limiter: false,
always_show: false,
pos: at,
dist: [0.25, 0.0625, 0.25],
max_speed: [0.05; 3],
count: 10,
randomization: 0,
}),
None,
);
}
}
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
+44 -70
View File
@@ -75,8 +75,6 @@ pub(super) struct GuestPlay {
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
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
@@ -160,7 +158,6 @@ 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,
@@ -168,6 +165,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
/// How many projectiles there were: those added since are the guest's.
@@ -268,10 +267,8 @@ impl Game<'_> {
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
// A new body (joined, respawned, changed level): nothing on it yet.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -387,7 +384,6 @@ 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),
@@ -395,6 +391,7 @@ impl Game<'_> {
self.lan_acting_uuid.replace(g.uuid),
);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let modifiers = std::mem::replace(
@@ -408,12 +405,6 @@ impl Game<'_> {
self.apply_equipment();
play.equipped = Some(equipped);
}
// The player's effects are on the new body before anything it does
// (the movement that brought it, a landing): vanilla keeps the same
// entity through a level change, with its attributes and effects.
if fresh {
self.apply_effect_status();
}
self.update_totem_ready();
Some(GuestSwap {
conn,
@@ -458,10 +449,10 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -517,11 +508,11 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -543,6 +534,17 @@ impl Game<'_> {
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
// ServerPlayer.connection.send: the guest's own entity events.
let entity_id = host.hub.guests[i].entity_id;
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
host.hub.send_to(
conn,
&C::EntityEvent(play::EntityEvent {
id: entity_id,
event,
}),
);
}
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
host.hub.send_to(
@@ -628,7 +630,6 @@ 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);
@@ -672,13 +673,6 @@ 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));
@@ -880,17 +874,13 @@ impl Game<'_> {
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// 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.
/// it no longer counts among the openers of the lid.
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_edge_sound(pos, false);
self.container_openers_changed(pos, false);
}
}
@@ -984,20 +974,6 @@ 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 => {
@@ -1564,13 +1540,9 @@ impl Game<'_> {
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
}
return;
}
@@ -1875,6 +1847,10 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -1956,21 +1932,8 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -2007,6 +1970,24 @@ impl Game<'_> {
play.digging = None;
}
}
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl), after
// LivingEntity.tick's use: an entry a finished use starts has 20
// ticks left when the next tick's packets are handled, as the
// guest's own client counts.
let play = host.play.entry(conn).or_default();
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
@@ -2351,13 +2332,6 @@ impl Game<'_> {
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
// Player.causeFallDamage: a fall of 2 blocks or more counts.
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
}
}
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
@@ -127,18 +127,9 @@ 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| {
+3 -36
View File
@@ -225,16 +225,6 @@ 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
@@ -280,24 +270,6 @@ 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 {
@@ -370,7 +342,6 @@ impl Game<'_> {
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
e.needs_sync = p.needs_sync;
// Projectile.getAddEntityPacket: the owner's id.
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
@@ -495,7 +466,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -513,14 +484,10 @@ impl Game<'_> {
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.waiting() {
if b.age < 20 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
if let Some(p) = &breath {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
@@ -363,7 +363,6 @@ mod tests {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
@@ -375,7 +374,6 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
passenger: false,
}
}
@@ -318,86 +318,16 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(
&mut self,
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
@@ -411,15 +341,6 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+8 -39
View File
@@ -86,11 +86,9 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
self.stop_sprint_after_extra_knockback();
}
self.lan = Some(host);
true
@@ -108,18 +106,8 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset and the swing.
/// in): the damage, the charge reset, the exhaustion and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
@@ -128,6 +116,11 @@ impl Game<'_> {
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
@@ -267,30 +260,6 @@ 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)),
@@ -317,8 +317,6 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
game.trigger_enchanted_item(&enchanted, need);
});
host.hub.guests[gi].xp_seed = self.world.random.next_int();
let env = self.menu_env();
@@ -277,12 +277,7 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
self.drop_guest_items(host, gi, dropped);
self.lan_send_offers(host, conn, &mut gm, false);
self.lan_send_merchant_content(host, conn, &mut gm);
+22 -191
View File
@@ -1,20 +1,24 @@
//! 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.
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
/// shell's old and new cells.
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
@@ -36,7 +40,7 @@ impl Game<'_> {
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
match usize::try_from(data) {
Ok(axis) if id == 3002 && axis < 3 => {
particles::along_axis(random, axis, p, (10, 19), |p, v| spawned.push((p, v)));
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
}
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
}
@@ -68,42 +72,24 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
(at, count, 0.5, self.block_shape_top(at), true)
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
let options = ParticleOptions::simple(ParticleType::HappyVillager);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -122,161 +108,6 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
-4
View File
@@ -66,8 +66,6 @@ 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>,
@@ -168,7 +166,6 @@ impl Game<'_> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mobs: Vec::new(),
knot_ids: HashMap::new(),
projectiles: Vec::new(),
dropped: Vec::new(),
orbs: Vec::new(),
@@ -222,7 +219,6 @@ 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);
+1 -29
View File
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
flashes: i32,
seed: i64,
visual_only: bool,
/// Mooshrooms and copper golems change once per bolt.
/// Mooshrooms switch colour once per bolt.
converted: Vec<u32>,
}
impl Game<'_> {
@@ -278,13 +278,6 @@ 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 {
@@ -341,16 +334,6 @@ 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;
@@ -410,17 +393,6 @@ 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,13 +25,11 @@ 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);
@@ -59,10 +57,6 @@ 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
+40 -112
View File
@@ -626,8 +626,46 @@ impl Game<'_> {
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
let above = (pos.0, pos.1 + 1, pos.2);
if let Some(took) = self.suck_from_container(above, hopper) {
return took;
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;
}
let above_state = self.world.block(above.0, above.1, above.2);
let blocked = self.res.facts().get(above_state).full_collision
@@ -657,56 +695,6 @@ 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();
@@ -731,66 +719,6 @@ 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 ----
+107 -67
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::{ItemStack, Items},
items::ItemStack,
mesher::Vertex,
mobs::{Part, anim::Poses},
particles::{ParticleOptions, ParticleType},
@@ -90,6 +90,8 @@ pub(super) struct Minecart {
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
pub first_tick: bool,
}
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
@@ -186,6 +188,7 @@ impl Minecart {
fuse: -1,
enabled: true,
loot: None,
first_tick: true,
}
}
pub fn bounds(&self) -> Aabb {
@@ -198,17 +201,6 @@ impl Minecart {
fn horizontal(&self) -> f64 {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
total += s.count as f32 / max as f32;
}
total /= slots.len().max(1) as f32;
// Mth.lerpDiscrete(total, 0, 15)
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
}
}
pub(super) struct CartModels {
pub models: HashMap<CartKind, Part>,
@@ -547,6 +539,7 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.hurt_time = (c.hurt_time - 1).max(0);
@@ -554,6 +547,7 @@ 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" {
@@ -595,26 +589,22 @@ impl Game<'_> {
}
}
CartKind::Tnt => {
// MinecartTNT.tick: the fuse and the collision check are separate.
let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z;
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 {
// explode(horizontalDistanceSqr()): a rising cart's
// vertical speed adds nothing.
} else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) {
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;
}
}
}
_ => {}
}
@@ -658,9 +648,51 @@ 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.with_hive_emptying_blast(|g| {
g.server_explode(at, power, BlockInteraction::Destroy, false)
});
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;
}
}
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
@@ -799,8 +831,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 && c.enabled {
self.hopper_cart_suck(&mut c);
if c.kind == CartKind::Hopper {
self.hopper_cart_pickup(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
@@ -918,7 +950,7 @@ impl Game<'_> {
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
pub(super) fn dismount_cart(&mut self, i: usize) {
self.riding = None;
let c = &self.carts[i];
@@ -928,25 +960,17 @@ impl Game<'_> {
c.position.z.floor() as i32,
);
let mut target = None;
// DismountHelper.offsetsForDirection of the cart's facing
// (Direction.fromYRot of its yaw): right, left, back right, back left,
// forward right, forward left, back, forward.
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
let (fx, fz) = step(facing);
let (rx, rz) = (-fz, fx);
let offsets = [
(rx, rz),
(-rx, -rz),
(rx - fx, rz - fz),
(-rx - fx, -rz - fz),
(fx + rx, fz + rz),
(fx - rx, fz - rz),
(-fx, -fz),
(fx, fz),
];
'search: for dy in [0, 1, -1] {
for (dx, dz) in offsets {
for (dx, dz) in [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
let (x, y, z) = (bx + dx, by + dy, bz + dz);
let below = self.res.facts().get(self.world.block(x, y - 1, z));
let here = self.res.facts().get(self.world.block(x, y, z));
@@ -1030,22 +1054,6 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1063,8 +1071,10 @@ impl Game<'_> {
}
self.riding = Some(self.carts[i].id);
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
if sneaking {
return false;
}
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
@@ -1334,9 +1344,8 @@ impl Game<'_> {
return;
};
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
// edge, scaled by the entity distance option.
let range =
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
@@ -1503,6 +1512,10 @@ impl Minecart {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
@@ -1552,6 +1565,7 @@ impl Minecart {
_ => 0.0,
};
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
@@ -1576,4 +1590,30 @@ mod tests {
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
#[test]
fn a_cart_saves_as_never_ticked_until_it_ticks() {
use horizon_world::nbt::Tag;
let generated = Tag::compound([
("id", Tag::String("minecraft:chest_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("HasTicked", Tag::Byte(1)),
("Fire", Tag::Short(0)),
]);
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
cart.first_tick = false;
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
}
}
@@ -31,6 +31,9 @@ impl Game<'_> {
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
@@ -51,6 +54,13 @@ pub(super) struct ModMinimap {
pub(super) round: bool,
}
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModFarTerrain {
pub(super) distance: u32,
pub(super) reduced: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
@@ -190,4 +200,12 @@ impl Game<'_> {
round: m.round,
})
}
/// The far terrain the first mod drawing one asks for.
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
+19 -15
View File
@@ -126,6 +126,19 @@ impl Game<'_> {
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
return false;
}
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
// seat, inventory or feeding to give. Camels seat two and check their
// passengers themselves.
let id = m.id;
if !matches!(species, Species::Camel | Species::CamelHusk)
&& self
.mobs
.iter()
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
{
return false;
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
@@ -215,12 +228,10 @@ 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);
@@ -240,7 +251,6 @@ 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",
@@ -250,10 +260,6 @@ 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
}
@@ -459,16 +465,14 @@ impl Game<'_> {
m.extra.owner = owner;
m.persistent = true;
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
self.mob_event_particles(mob, 7);
self.particles_around_mob(
mob,
"heart",
horizon_client::particles::Burst::TAMING,
);
}
}
MobEvent::Buck { mob } => {
if self.riding == Some(mob) {
self.dismount_mount();
}
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
self.mob_event_particles(mob, 6);
}
MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(),
_ => {}
}
}
+6 -3
View File
@@ -85,8 +85,13 @@ 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.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.nautilus_sound(index, sound);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
@@ -140,8 +145,6 @@ 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
+295 -91
View File
@@ -2,7 +2,9 @@
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
//! `LocalPlayer.handlePortalTransitionEffect`.
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
//! the player's End portals are game/end.rs's.
//!
//! Only one dimension is loaded at a time. The player's destination is found
//! once the other dimension has loaded around it. An item or mob that goes
@@ -12,7 +14,7 @@ use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::nether_portal::PortalState;
use horizon_core::{
geometry::{Axis, Vec3},
geometry::{Aabb, Axis, Vec3},
position::BlockPos,
random::RandomSource,
};
@@ -40,6 +42,32 @@ pub(super) struct Plan {
offset: Vec3,
width: f32,
height: f32,
/// Through an End portal: `approximate` is the End's spawn point, or the
/// Overworld's shared spawn.
end: bool,
}
impl Plan {
/// `EndPortalBlock.getPortalDestination` for an entity other than a
/// player in `from`: the End's spawn point, or out of the End the
/// Overworld's shared spawn `world_spawn`.
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
let (dim, approximate) = if from == Dim::End {
(Dim::Overworld, world_spawn)
} else {
let [x, y, z] = super::end::END_SPAWN;
(Dim::End, BlockPos::new(x, y, z))
};
Self {
dim,
approximate,
entry_x_axis: true,
offset: Vec3::ZERO,
width,
height,
end: true,
}
}
}
/// An entity on its way to a dimension that isn't loaded.
@@ -81,6 +109,89 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
/// entity-inside shape, y 6/16..12/16 of the block.
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
let half = f64::from(width) / 2.0;
let start = Aabb::new(
Vec3::new(from.x - half, from.y, from.z - half),
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
);
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let shape = Aabb::new(
Vec3::new(x, y + 0.375, z),
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
);
start.collided_along_vector(to.subtract(from), &[shape])
}
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
let (yaw, pitch) = if into_end {
// Direction.WEST.toYRot().
(90.0, pitch)
} else {
(yaw + spawn[0], pitch + spawn[1])
};
[yaw, pitch.clamp(-90.0, 90.0)]
}
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
fn arrival_tag(a: &Arrival) -> Tag {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("end", Tag::Int(i32::from(p.end))),
("entity", a.tag.clone()),
])
}
/// The waiting entity `arrival_tag` wrote.
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
else {
return None;
};
Some(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
end: int("end").unwrap_or(0) != 0,
},
tag: tag.clone(),
})
}
impl Game<'_> {
/// The world border's extent.
@@ -146,8 +257,54 @@ impl Game<'_> {
offset,
width,
height,
end: false,
}
}
/// The plan of an item or mob going through the portal block at `entry`:
/// an End portal's fixed destination, else the Nether portal's.
fn entity_plan(
&self,
entry: BlockPos,
at: Vec3,
width: f32,
height: f32,
reset_forward: bool,
) -> Plan {
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
return self.portal_plan(entry, at, width, height, reset_forward);
}
let spawn = self.level.world_spawn.unwrap_or_else(|| {
self.spawn.map_or([0, 64, 0], |s| {
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
})
});
Plan::end_portal(
self.dim,
BlockPos::new(spawn[0], spawn[1], spawn[2]),
width,
height,
)
}
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
&*self.res.facts().get(state).name == "minecraft:end_portal"
}
/// Where an item or mob through an End portal lands once `plan`'s level
/// has loaded around it: the bottom centre of the End's spawn point,
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
/// out of the End, the top of the shared spawn's column
/// (`Entity.adjustSpawnLocation`).
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
let p = plan.approximate;
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
if plan.dim == Dim::End {
if let Err(e) = self.end_arrival(centre) {
log::error!("End platform: {e}");
}
return centre;
}
let y = self.motion_top(p.x, p.z);
Vec3::new(centre.x, f64::from(y), centre.z)
}
/// The nether portal blocks of the chunks within `radius` of `center`
/// (the points of interest `PortalForcer` searches): loaded chunks as
/// they are, the others as saved; chunks never generated have none.
@@ -520,9 +677,10 @@ impl Game<'_> {
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs and empty boats and minecarts that moved this
/// tick: the portal blocks their box swept through (an End portal's
/// transition time is 0 too).
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -570,7 +728,7 @@ impl Game<'_> {
.set_inside(pos, ENTITY_DELAY);
}
}
/// The first nether portal block an entity box swept through
/// The first nether or End portal block an entity box swept through
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
fn swept_portal(
&self,
@@ -604,11 +762,9 @@ impl Game<'_> {
bounds,
1_000_000,
|p, _| {
if self
.res
.behaviours()
.portal_axis(self.world.block(p.x, p.y, p.z))
.is_some()
let state = self.world.block(p.x, p.y, p.z);
if self.res.behaviours().portal_axis(state).is_some()
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
{
found = Some(p);
return false;
@@ -618,8 +774,8 @@ impl Game<'_> {
);
found
}
/// Whether a section `reach` touches has a nether portal state in its
/// palette: without one, no block there is a portal. Palette answers are
/// Whether a section `reach` touches has a nether or End portal state in
/// its palette: without one, no block there is a portal. Palette answers are
/// kept per chunk across ticks (`portal_sections`); a box over many
/// sections is not checked.
fn may_hold_portal(
@@ -656,9 +812,8 @@ impl Game<'_> {
(z0..=z1).any(|cz| {
chunks.get(&(cx, cz)).is_some_and(|chunk| {
sections.get((cx, cz), chunk, |v| {
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
let state = horizon_world::registry::BlockStateId(v);
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
}) & y_mask
!= 0
})
@@ -670,7 +825,7 @@ impl Game<'_> {
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let e = &self.dropped[i];
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -686,7 +841,7 @@ impl Game<'_> {
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
if !self.may_enter(plan.dim) {
return;
}
@@ -716,7 +871,7 @@ impl Game<'_> {
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
let plan = self.entity_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -741,7 +896,7 @@ impl Game<'_> {
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
let plan = self.entity_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -754,8 +909,8 @@ impl Game<'_> {
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
/// their exit portal (found or built), position, turned yaw and motion,
/// and portal cooldown; they join the world with the chunk entities.
/// their exit portal (found or built) or End destination, position,
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
pub(super) fn resolve_arrivals(&mut self) {
if self.remote.is_some() {
return;
@@ -772,21 +927,28 @@ impl Game<'_> {
}
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let (at, motion, [yaw, pitch]) = if plan.end {
// Relative.DELTA: the motion is kept.
let at = self.end_portal_exit(&plan);
let into_end = plan.dim == Dim::End;
let turn =
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
(at, motion, turn)
} else {
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
};
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
c.insert(
"Rotation".into(),
Tag::List(vec![
Tag::Float((yaw + rotation).to_bits()),
Tag::Float(pitch.to_bits()),
]),
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
);
// setPortalCooldown before the teleport, copied by restoreFrom.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
@@ -823,75 +985,117 @@ impl Game<'_> {
let Some(Tag::List(list)) = root.get("arrivals") else {
return;
};
for a in list {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
Some("minecraft:the_nether") => Dim::Nether,
Some(_) => Dim::Overworld,
None => continue,
};
let (Some(x), Some(y), Some(z), Some(tag)) =
(int("x"), int("y"), int("z"), a.get("entity"))
else {
continue;
};
self.portals.arrivals.push(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
},
tag: tag.clone(),
});
}
self.portals
.arrivals
.extend(list.iter().filter_map(arrival_from_tag));
}
/// Write the waiting entities when they changed.
pub(super) fn save_portal_arrivals(&mut self) {
if !self.portals.dirty || self.remote.is_some() {
return;
}
let list = self
.portals
.arrivals
.iter()
.map(|a| {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("entity", a.tag.clone()),
])
})
.collect();
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
let root = Tag::compound([("arrivals", Tag::List(list))]);
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
self.portals.dirty = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
for from in [Dim::Overworld, Dim::Nether] {
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
assert_eq!(plan.dim, Dim::End);
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
assert!(plan.end);
}
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
assert_eq!(plan.dim, Dim::Overworld);
assert_eq!(plan.approximate, spawn);
}
#[test]
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
let portal = BlockPos::new(0, 100, 3);
let at = |y| Vec3::new(0.5, y, 3.5);
// A dropped item falling through the slab, or in it.
assert!(touches_end_portal(
at(100.6),
at(100.56),
0.25,
0.25,
portal
));
// One tick's fall across the whole slab, in it at neither end.
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
// Resting below the slab (on the floor) or above it.
assert!(!touches_end_portal(
at(100.0),
at(100.0),
0.25,
0.25,
portal
));
assert!(!touches_end_portal(
at(100.8),
at(100.8),
0.25,
0.25,
portal
));
// A cow standing in the portal block.
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
// Beside it.
let beside = Vec3::new(2.0, 100.0, 3.5);
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
}
#[test]
fn end_portals_turn_entities_like_their_teleport_transition() {
// Into the End: facing west, pitch kept.
assert_eq!(
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
[90.0, 20.0]
);
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
assert_eq!(
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
[15.0, 30.0]
);
assert_eq!(
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
[0.0, 90.0]
);
}
#[test]
fn waiting_arrivals_keep_their_end_destination_when_saved() {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
assert_eq!(back.plan.dim, dim);
assert_eq!(back.plan.approximate, arrival.plan.approximate);
assert!(back.plan.end);
assert_eq!(back.tag, tag);
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
if let Tag::Compound(c) = &mut old {
c.remove(&"end".into());
}
assert!(!arrival_from_tag(&old).unwrap().plan.end);
}
}
+4
View File
@@ -34,6 +34,10 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
+1 -2
View File
@@ -267,8 +267,7 @@ impl Game<'_> {
// measured from the box's centre (HangingEntity's position).
let (lo, hi) = p.bounds();
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+2 -11
View File
@@ -218,20 +218,11 @@ impl Game<'_> {
_ => None,
})
});
// Camera.setup: the living mount's scale and camera distance
// attributes (the happy ghast's is 8, the default 4).
// A living mount at the default scale and camera distance.
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| {
let attribute = |name: &str, default: f64| {
m.kind.attributes.get(name).copied().unwrap_or(default)
};
(
attribute("minecraft:scale", 1.0) as f32,
attribute("minecraft:camera_distance", 4.0),
)
});
.map(|_| (1.0, 4.0));
// AbstractBoat.positionRider turns a passenger by the boat's
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
// interpolates a passenger's yaw: the boat's turn is spread over the
+62 -494
View File
@@ -2,12 +2,7 @@
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
//! filling glass bottles (`BottleItem`), the per-tick effect application,
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use super::{Game, Screen, item_draw::owner_at};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -17,13 +12,9 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -40,35 +31,6 @@ pub(super) struct ThrownPotion {
/// for a dispenser's).
owner: Option<Text>,
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
enum Victim {
Mob(u32),
Player(FightWho),
}
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
/// (at the lingering item's 0.25 duration scale) and who it has affected.
pub(super) struct PotionCloud {
effects: &'static [(&'static str, i32, i32)],
/// The potion's colour (`PotionContents.getColor`).
color: u32,
/// Each victim and the tick it can be affected again.
victims: Vec<(Victim, i32)>,
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
pub(super) fn particle(&self) -> ParticleOptions {
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
@@ -86,203 +48,7 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
.get(id)
.copied()
}
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
/// decide first, then the tag of mobs that ignore poison and regeneration.
fn mob_can_be_affected(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
effect: &str,
) -> bool {
let effect = effect.trim_start_matches("minecraft:");
let kind = kind.trim_start_matches("minecraft:");
let overridden = match kind {
"wither" | "wither_skeleton" => effect == "wither",
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
"parched" => effect == "weakness",
_ => false,
};
if overridden {
return false;
}
let in_tag = |tag: &str| {
tags.get(tag)
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
};
if in_tag("minecraft:immune_to_infested") {
effect != "infested"
} else if in_tag("minecraft:immune_to_oozing") {
effect != "oozing"
} else {
!in_tag("minecraft:ignores_poison_and_regen")
|| (effect != "regeneration" && effect != "poison")
}
}
/// `ThrownSplashPotion.onHitAsPotion`: the squared distance from the potion's
/// box at the hit point to an entity's box grown by the hit margin
/// (`ProjectileUtil.computeMargin`), for entities that meet the potion's box
/// grown by (4, 2, 4); none when it is 16 or more.
fn splash_distance_sqr(at: Vec3, age: u32, bounds: Aabb) -> Option<f64> {
let potion = Aabb::new(
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
);
if !potion.inflate(Vec3::new(4.0, 2.0, 4.0)).intersects(bounds) {
return None;
}
let margin = ((f64::from(age) - 2.0) / 20.0).clamp(0.0, 0.3);
let d2 = potion.distance_squared_to_box(bounds.inflate(Vec3::new(margin, margin, margin)));
(d2 < 16.0).then_some(d2)
}
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(
@@ -547,6 +313,7 @@ impl Game<'_> {
return true;
}
self.using = None;
self.send_use_item_complete();
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
if ominous {
self.drink_ominous_bottle(slot, stack);
@@ -624,7 +391,6 @@ impl Game<'_> {
self.tick_mob_effects();
if running {
self.tick_thrown_potions();
self.tick_potion_clouds();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
@@ -635,37 +401,13 @@ impl Game<'_> {
return;
};
if player.health.dead_or_dying() {
// 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();
// LivingEntity.die → removeAllEffects.
self.potions.effects.clear();
if !ids.is_empty() {
self.effects_on_death(&ids, bounds, true, None, None);
}
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = self
.player
.as_ref()
.is_some_and(|p| p.health.absorption() <= 0.0);
let mut effects = std::mem::take(&mut self.potions.effects);
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
self.potions.effects = effects;
if raid_omen_added {
self.raid_omen_added();
@@ -806,176 +548,29 @@ impl Game<'_> {
let state =
self.world
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let mob = self.mobs.iter().find(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
});
if self.res.facts().get(state).full_collision || mob.is_some() {
hit = Some((p, mob.map(|m| m.position)));
if self.res.facts().get(state).full_collision
|| self.mobs.iter().any(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
})
{
hit = Some(p);
break;
}
}
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
if let Some(p) = hit {
landed.push((t.stack.clone(), p, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, mob, age, owner) in landed {
self.splash(&stack, at, mob, age, owner);
}
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
/// tick's particles, then the server tick.
fn tick_potion_clouds(&mut self) {
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
return;
}
let players: Vec<FightPlayer> = self
.fight_players()
.into_iter()
.filter(|p| p.alive)
.collect();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
self.breath_clouds.append(&mut clouds);
}
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
/// wait it shrinks, and every 5 ticks it affects the living entities in
/// its box and radius that were not affected in the last 20 ticks, each
/// taking some of its radius. False once it is gone.
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
c.age += 1;
let Some(potion) = c.potion.as_mut() else {
return true;
};
if c.age - LINGERING_WAIT >= c.duration {
return false;
}
let waiting = c.age < LINGERING_WAIT;
cloud_client_tick(
&mut self.particles,
c.at,
c.radius,
waiting,
&potion.particle(),
);
if waiting {
return true;
}
// setRadiusPerTick(-radius / duration).
c.radius += -3.0 / 600.0;
if c.radius < 0.5 {
return false;
}
if c.age % 5 != 0 {
return true;
}
potion.victims.retain(|(_, until)| c.age < *until);
let r = f64::from(c.radius);
let area = Aabb::new(
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
);
let mut inside = Vec::new();
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
}
let effects = potion.effects;
let tags = &self.res.stations().enchantments.entity_tags;
// Only mobs that at least one of the cloud's effects can affect are
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
// the others neither become victims nor use up its radius.
for m in self.mobs.iter().filter(|m| {
m.alive()
&& m.bounds().intersects(area)
&& effects
.iter()
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
}) {
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
}
for (victim, x, z) in inside {
let (dx, dz) = (x - c.at[0], z - c.at[2]);
if potion.victims.iter().any(|(v, _)| *v == victim)
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
{
continue;
}
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
self.cloud_affect(victim, effects, c.at, &potion.source);
// setRadiusOnUse(-0.5).
c.radius -= 0.5;
if c.radius < 0.5 {
return false;
}
}
true
}
/// What a cloud puts on one entity: instant effects at half strength
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
fn cloud_affect(
&mut self,
victim: Victim,
effects: &'static [(&'static str, i32, i32)],
at: [f64; 3],
source: &Text,
) {
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
match victim {
Victim::Player(who) => self.as_fight_player(who, |g| {
for (id, duration, amp) in effects {
let Some(e) = brewing::effect(id) else {
continue;
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
}
} else {
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
}
}
}),
Victim::Mob(mob) => {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
// Undead invert healing and harming.
let tags = &self.res.stations().enchantments.entity_tags;
let inverted = tags
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
for (id, duration, amp) in effects {
let m = &mut self.mobs[i];
match *id {
"instant_damage" | "instant_health" => {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
}
_ => {
// `LivingEntity.addEffect` refuses what the mob
// cannot be affected by.
if mob_can_be_affected(tags, &m.kind.id, id)
&& let Some(name) = mob_effect_id(id)
{
m.add_effect(name, scaled(*duration), *amp);
}
}
}
}
}
for (stack, at, owner) in landed {
self.splash(&stack, at, owner);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -1077,46 +672,44 @@ impl Game<'_> {
self.submit_item_world(&state, pose, vertex_light(light), out);
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
/// A splash potion's effects on the player in place, by their distance.
fn splash_on_player(
&mut self,
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
age: u32,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
let w = f64::from(p.dimensions.width) / 2.0;
let bounds = Aabb::new(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
),
let center = Vec3::new(
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
);
if let Some(d2) = splash_distance_sqr(at, age, bounds) {
let d2 =
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
let intensity = 1.0 - d2.sqrt() / 4.0;
for (id, duration, amp) in effects {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_indirect_effect_action(a, source);
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
} else {
let d = (intensity * f64::from(*duration) + 0.5) as i32;
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -1127,16 +720,8 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
fn splash(
&mut self,
stack: &ItemStack,
at: Vec3,
mob: Option<Vec3>,
age: u32,
owner: Option<Text>,
) {
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
@@ -1160,48 +745,31 @@ impl Game<'_> {
let color = brewing::potion_color(stack.potion());
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = potion else {
return;
};
if lingering {
// The cloud sits at the mob it hit (its feet), else the impact;
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
self.breath_clouds.push(Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
duration: 600,
from_dragon: false,
potion: Some(PotionCloud {
effects: def.effects,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
});
}
return;
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
// Instant damage is indirectMagic(potion or cloud, owner), which the
// death message names by the owner, else by the potion or cloud.
let source = owner.unwrap_or_else(|| {
let entity = if lingering {
"entity.minecraft.area_effect_cloud"
} else {
"entity.minecraft.splash_potion"
};
Text::translate(entity, &[])
});
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
for (i, m) in self.mobs.iter().enumerate() {
if m.alive()
&& let Some(d2) = splash_distance_sqr(at, age, m.bounds())
{
let d2 = (m.position.x - at.x).powi(2)
+ (m.position.y + m.kind.height / 2.0 - at.y).powi(2)
+ (m.position.z - at.z).powi(2);
if m.alive() && d2 < 16.0 {
let inverted = undead
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&m.kind.id));
@@ -1216,7 +784,7 @@ impl Game<'_> {
_ => {
// `Potion.applySplash`: timed effects last in
// proportion to how close the mob was.
let d = (intensity * f64::from(*duration) + 0.5) as i32;
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
if d > 20
&& let Some(name) = mob_effect_id(id)
{
@@ -1228,12 +796,12 @@ impl Game<'_> {
let m = &mut self.mobs[i];
if harm {
m.hurt(
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
(intensity * f64::from(6 << amp)) as i32 as f32,
Some([at.x, at.y, at.z]),
&mut self.world.random,
);
} else {
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
m.health = (m.health + (intensity * f64::from(4 << amp)) as i32 as f32)
.min(m.kind.max_health);
}
}
@@ -609,6 +609,7 @@ impl Game<'_> {
if inside(cursor, bx, by, 20.0, 18.0) {
let open = !self.book_open(book_type);
self.level.recipe_book.open[book_type.index()] = open;
self.send_book_settings(book_type);
if !open {
self.recipe_ui.overlay = None;
}
@@ -694,6 +695,7 @@ impl Game<'_> {
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
let filtering = !self.book_filtering(book_type);
self.level.recipe_book.filtering[book_type.index()] = filtering;
self.send_book_settings(book_type);
self.recipe_ui.page = 0;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
@@ -714,6 +716,22 @@ impl Game<'_> {
}
false
}
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
fn send_book_settings(&self, book_type: BookType) {
self.send(
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
book_type: book_type.index() as i32,
open: self.book_open(book_type),
filtering: self.book_filtering(book_type),
},
),
);
}
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
let book = self.active_book()?;
Some((self.book_open(book), self.book_filtering(book)))
}
/// How many displays the client's book holds.
pub(super) fn recipe_book_known(&self) -> usize {
self.recipe_ui.client.displays().len()
+177 -161
View File
@@ -117,13 +117,17 @@ pub(super) struct Remote {
joined: bool,
status: String,
pub(super) entity_id: i32,
pub(super) dimension: DimensionType,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
dimension: DimensionType,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
view_distance: i32,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
@@ -156,9 +160,6 @@ pub(super) struct Remote {
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
@@ -192,10 +193,12 @@ pub(super) struct Remote {
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
pub(super) flat: bool,
death_message: Option<String>,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
@@ -217,6 +220,9 @@ pub(super) struct Remote {
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -399,6 +405,9 @@ impl Game<'_> {
self.carried = None;
self.using = None;
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
@@ -433,6 +442,7 @@ impl Game<'_> {
joined: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
@@ -459,7 +469,6 @@ impl Game<'_> {
lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
@@ -479,9 +488,9 @@ impl Game<'_> {
destroying: None,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
flat: false,
death_message: None,
player_type,
synced: vec![None; 36],
@@ -493,6 +502,7 @@ impl Game<'_> {
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
@@ -869,6 +879,11 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
@@ -996,7 +1011,6 @@ impl Game<'_> {
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
// A replacement LocalPlayer has a fresh sendPosition baseline.
r.position_sender = Default::default();
@@ -1416,13 +1430,6 @@ 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) {
@@ -1636,7 +1643,11 @@ impl Game<'_> {
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
self.selected = 0;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
@@ -1654,8 +1665,10 @@ impl Game<'_> {
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects: the server sends them again.
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
@@ -1883,8 +1896,7 @@ 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, a mob or a fence knot), for
// the rope.
// lead (this player, another player or a mob), 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) {
@@ -1892,12 +1904,7 @@ impl Game<'_> {
m.extra.leash = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
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)),
},
d => Some(Holder::Mob(d as u32)),
};
}
}
@@ -2130,9 +2137,8 @@ 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_dir(p.yaw);
pl.health.animate_hurt();
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
@@ -2179,37 +2185,6 @@ 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);
}
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
@@ -2227,6 +2202,11 @@ impl Game<'_> {
self.honey_slide_particles(at);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.remote_complete_using();
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
@@ -2389,10 +2369,11 @@ impl Game<'_> {
}
self.apply_remote_abilities();
}
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
// carried index stays, so the next tick answers with SetCarriedItem.
C::SetHeldSlot(p) => {
self.selected = p.slot.clamp(0, 8) as usize;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(self.selected);
if (0..9).contains(&p.slot) {
self.selected = p.slot as usize;
}
}
C::ContainerSetContent(p) => {
@@ -3110,8 +3091,6 @@ 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()
@@ -3134,37 +3113,6 @@ impl Game<'_> {
p.health.food.saturation = saturation;
}
}
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
/// simulates the ghast it rides, so its copy is where the server has it;
/// this window's copy trails the server's by the stepped interpolation and
/// would rise into the rider's feet, which lets the rider fall through.
/// Put the copy under the landing spot.
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
else {
return;
};
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
// Only a teleport that is that dismount: right above the ghast.
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|| (at.y - m.position.y).abs() > 5.0
{
return;
}
m.position = at;
m.previous = at;
if let Some(handler) = self
.remote
.as_mut()
.and_then(|r| r.stepped.get_mut(&(id as i32)))
{
handler.cancel();
}
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
@@ -3224,9 +3172,6 @@ impl Game<'_> {
self.previous_position = position;
self.yaw = yaw;
self.pitch = pitch;
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
self.place_ghast_under(id, position);
}
if let Some(r) = self.remote.as_mut() {
// handleMovePlayer does not change LocalPlayer.sendPosition's
// baseline: the next tick must still report the corrected state.
@@ -3260,12 +3205,6 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
// for a server's client (multiplayer and the default single player),
// so the entries advance here or the slot stays shaded until the
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
@@ -3301,6 +3240,14 @@ impl Game<'_> {
// Window callbacks only queue clicks, so an interaction cannot leak
// between CLIENT_TICK_END and a later pong.
self.remote_handle_input();
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
// handleKeybinds: this path replaces `tick_world`'s for a server's
// client (multiplayer and the default single player), so the entries
// advance here or the slot stays shaded until the server says the
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
@@ -3441,12 +3388,12 @@ impl Game<'_> {
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission { state, muffled, .. }
if !crouching =>
{
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if muffled {
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
@@ -4328,16 +4275,6 @@ 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,
@@ -4447,13 +4384,27 @@ impl Game<'_> {
let Some(info) = self.res.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem runs the use here too (inside
// startPrediction): the `use_cooldown` component starts its entry, and
// the server's ClientboundCooldownPacket confirms it.
self.apply_use_cooldown(&held);
// Food starts being eaten (Consumable.startConsuming), a book and
// quill opens.
self.with_hand(hand, |g| g.start_eating(&info));
// MultiPlayerGameMode.useItem's prediction: an item cooling down
// passes (its packet still goes).
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
// no cooldown or use animation starts.
let eating = self.with_hand(hand, |g| g.start_eating(&info));
if info.food.is_some() && !eating {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
// finishUsingItem (remote_complete_using).
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
self.apply_use_cooldown(&held);
}
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
}
@@ -4525,14 +4476,25 @@ impl Game<'_> {
self.remote_use_item(Hand::Off);
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a
/// consumable's eating animation and its crumbs. The server eats it and
/// sends the stack, the hunger and the sounds.
/// use (`held_until_released`), and a consumable's eating animation and
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
/// stack, its hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
return;
};
let stack = self.stack_in(slot).cloned();
// The use stops when the hand no longer holds the item it began with
// (ItemStack.isSameItem).
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
if let Some(r) = self.remote.as_mut() {
if ticks == 0 {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
@@ -4557,20 +4519,51 @@ impl Game<'_> {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
if consumable.particles && consumable.should_emit(remaining as i32) {
// Consumable.startConsuming eats one with no use time at once, without
// a use (nor a USE_ITEM_COMPLETE).
if consumable.ticks() <= 0 {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.particles && consumable.should_emit(remaining) {
self.eat_particles(&stack, 5);
}
if ticks + 1 >= duration {
self.using = None;
if consumable.particles {
self.eat_particles(&stack, 16);
}
} else {
self.using = Some((slot, ticks + 1));
self.using = Some((slot, ticks + 1));
}
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
/// which runs on the client too (Consumable.onConsume calls its
/// ConsumableListeners on both sides): the client's FoodData eats at once,
/// so a held right click finds `Player.canEat` false instead of starting
/// another bite before the server's ClientboundSetHealth arrives.
fn remote_complete_using(&mut self) {
let Some((slot, _)) = self.using.take() else {
return;
};
let Some(stack) = self.stack_in(slot).cloned() else {
return;
};
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return;
};
if info.consumable.as_ref().is_some_and(|c| c.particles) {
self.eat_particles(&stack, 16);
}
if let Some((nutrition, saturation)) = info.food
&& let Some(r) = self.remote.as_mut()
{
// FoodData.eat(nutrition, saturation), as finish_consuming does;
// the next ClientboundSetHealth replaces it.
let (_, food, sat) = &mut r.health;
*food = (*food + nutrition as i32).clamp(0, 20);
*sat = (*sat + saturation).clamp(0.0, *food as f32);
self.apply_remote_health();
}
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
}
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
@@ -4635,6 +4628,31 @@ impl Game<'_> {
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.pending_keys))
.unwrap_or_default();
for key in pending {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
let all = self.keys.contains(&KeyCode::ControlLeft);
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
}
_ => {}
}
}
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
@@ -4746,22 +4764,8 @@ impl Game<'_> {
if self.spectator() {
return Ok(true);
}
let all = self.keys.contains(&KeyCode::ControlLeft);
// MultiPlayerGameMode.dropItem: predict removal before sending
// the action, then swing only if a nonempty stack was removed.
let dropped = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
false,
);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
@@ -4769,12 +4773,9 @@ impl Game<'_> {
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
Ok(true)
}
KeyCode::KeyE => {
@@ -4821,6 +4822,21 @@ impl Game<'_> {
}));
}
}
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
fn remote_drop(&mut self, all: bool) {
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
@@ -97,10 +97,6 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER):
// `isOnStillTimeout` on a client, which keeps the ghast solid to
// the player (`canBeCollidedWith`).
(Species::HappyGhast, 19, Value::Bool(still)) => m.extra.counter = i32::from(*still),
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
m.extra.variant2 = (*spell).max(0) as u8;
@@ -66,11 +66,8 @@ impl Game<'_> {
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Axolotl => item == "minecraft:water_bucket",
// Tadpole.mobInteract: frog food, else the bucket.
Species::Tadpole => {
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
}
| Species::Axolotl
| Species::Tadpole => item == "minecraft:water_bucket",
Species::Armadillo => item == "minecraft:brush",
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
@@ -1,10 +1,9 @@
//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's
//! Boats, minecarts, armor stands, item frames and paintings 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;
@@ -25,17 +24,7 @@ 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: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" {
if kind == "minecraft:text_display" {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
@@ -315,7 +304,6 @@ 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();
@@ -326,7 +314,6 @@ 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,11 +3,8 @@
//! 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;
@@ -26,58 +23,6 @@ fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
/// near the middle; then about one per square block of the disc, drifting
/// (an entity effect stays still).
pub(super) fn cloud_client_tick(
particles: &mut horizon_client::particles::ParticleEngine,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: &ParticleOptions,
) {
if waiting && particles.random().next_boolean() {
return;
}
let (count, spread) = if waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * radius * radius).ceil() as i32,
radius,
)
};
for _ in 0..count {
let r = particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
at[0] + f64::from(angle.cos() * distance),
at[1],
at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
let default = waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
particle.clone()
};
(options, [0.0; 3])
} else if waiting {
(particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(particle.clone(), v)
};
particles.add(&options, at, velocity);
}
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -415,58 +360,31 @@ impl Game<'_> {
let id = t.id;
t.remote_step(|at| step(id, at));
}
// 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.
// FireworkRocketEntity.tick: a rocket attached to the gliding local
// player boosts it client-side too (attachedToEntity.isFallFlying).
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
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;
}
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 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);
@@ -496,10 +414,52 @@ impl Game<'_> {
}
}
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
}
}
@@ -128,17 +128,6 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
// HappyGhast.removePassenger: a still timeout of 10 (the server's
// STAYS_STILL confirms it), and the rider lands on top of the ghast
// (`place_ghast_under`).
if let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == vehicle_id && m.species() == horizon_client::mobs::Species::HappyGhast
}) {
m.extra.counter = 10;
if let Some(r) = self.remote.as_mut() {
r.ghast_dismount = Some(vehicle_id);
}
}
}
}
+6 -63
View File
@@ -92,8 +92,9 @@ impl Game<'_> {
distant_terrain: self.lod.is_some(),
cloud_range_chunks: self.settings.cloud_range,
camera_y: camera.y,
min_y: self.level_min_y(),
flat: self.level_flat(),
// level.getMinY(): the Nether and the End start at y 0.
min_y: if self.dim == Dim::Overworld { MIN_Y } else { 0 },
flat: false,
forward: forward.map(|c| c as f32),
rain,
thunder,
@@ -107,30 +108,9 @@ impl Game<'_> {
},
)
}
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
fn level_flat(&self) -> bool {
match self.remote.as_ref() {
Some(r) => r.flat,
None => self.world_flat,
}
}
/// `Level.getMinY`: the current dimension type's minimum y.
fn level_min_y(&self) -> i32 {
match self.remote.as_ref() {
Some(r) => r.dimension.min_y,
None => self.dim.min_y(),
}
}
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
/// water. `ClientLevelData.getHorizonHeight`: the bottom of a flat world,
/// otherwise y 63.
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
let horizon = if self.level_flat() {
f64::from(self.level_min_y())
} else {
63.0
};
camera.y < horizon
camera.y < 63.0
&& fog_type(
&self.world,
self.res.facts(),
@@ -292,16 +272,6 @@ impl Game<'_> {
&& motion_blocking(world, facts, p[0], p[2], p[1] + 64) <= p[1]
&& rains_at(world, biomes, p[0], p[1], p[2])
};
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
let gateway_particles = |state, p| {
self.res
.block_entity_render()
.gateway_faces(world, state, p)
.len()
};
// Level.isThundering: a thunder level above 0.9 where the sky is open.
let thundering = self.dim == Dim::Overworld && world.weather.levels(1.0).1 > 0.9;
let ctx = AnimateContext {
world,
facts,
@@ -310,10 +280,6 @@ impl Game<'_> {
registry: self.pack.assets.registry,
raining_at: &raining_at,
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
model_offset: &model_offset,
gateway_particles: &gateway_particles,
thundering,
ultrawarm: self.dim == Dim::Nether,
};
if let Some(e) = eye {
let block = [
@@ -323,13 +289,11 @@ impl Game<'_> {
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10)
// and the rain sound.
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = 10;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
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;
@@ -342,7 +306,6 @@ impl Game<'_> {
continue;
}
let below = top - 1;
rain_position = Some([x, below, z]);
let (bx, bz) = (random.next_double(), random.next_double());
let state = world.block(x, below, z);
let f = facts.get(state);
@@ -374,26 +337,6 @@ 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 {
+74 -17
View File
@@ -19,6 +19,9 @@ impl Screen {
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
| Self::Mod => true,
Self::Playing
| Self::GameModeSwitcher
@@ -35,20 +38,35 @@ impl Screen {
}
/// PauseScreen buttons: (x, y, width, label key, action id, active).
/// `links`: the `minecraft:server_links` dialog's button, which the
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
/// server sent links — a full-width row that pushes the last two rows down.
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
fn pause_buttons(
w: f32,
h: f32,
remote: bool,
links: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
let (grid_w, grid_h) = (212.0, 166.0);
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
let x0 = ((w - grid_w) * 0.5).floor();
let y0 = ((h - grid_h) * 0.25).floor();
vec![
let down = if links { 24.0 } else { 0.0 };
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
];
if links {
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
(
x0 + 110.0,
y0 + 122.0,
y0 + 122.0 + down,
98.0,
"options.worldOptions.button",
9,
@@ -56,7 +74,7 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
),
(
x0 + 4.0,
y0 + 146.0,
y0 + 146.0 + down,
204.0,
if remote {
"menu.disconnect"
@@ -66,16 +84,17 @@ fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static s
2,
true,
),
]
]);
v
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the others stay inactive.
const FRIENDS_ICON: usize = 2;
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
let y = ((h - 166.0) * 0.25).floor() + 98.0;
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
[
(x0, y, "gui/sprites/pause_menu/bug"),
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
@@ -120,7 +139,9 @@ impl Game<'_> {
u32::MAX,
true,
);
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
let links = !self.server_links().is_empty();
let mut tooltip = None;
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -133,9 +154,12 @@ impl Game<'_> {
active,
hover,
);
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
if id == 11 && hover {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
let mut tooltip = None;
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
let active = i == FRIENDS_ICON && friends;
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
// The icon has no label: name it for controller focus and the
@@ -168,7 +192,13 @@ impl Game<'_> {
}
// SpriteIconButton.withTootip: the label as a tooltip.
if let Some(tip) = tooltip {
ui::tooltip(batch, atlas, &[tip], self.cursor);
let lines: Vec<Text> = atlas
.font
.split_lines(&tip, batch.width)
.into_iter()
.map(|(line, _)| line)
.collect();
ui::tooltip(batch, atlas, &lines, self.cursor);
}
}
pub(super) fn pause_click(&mut self) -> Result<()> {
@@ -180,8 +210,9 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let friends = pause_icons(w, h)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server())
let links = !self.server_links().is_empty();
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server(), links)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -200,6 +231,11 @@ impl Game<'_> {
Some(4) => self.open_advancements(true),
Some(9) => self.open_world_options(),
Some(10) => self.screen = Screen::Friends,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
Some(11) => {
self.links_ui.scroll = 0.0;
self.screen = Screen::ServerLinks;
}
Some(2) => self.close_world()?,
_ => {}
}
@@ -357,7 +393,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, pause_buttons};
use super::{Screen, pause_buttons, pause_icons};
#[test]
fn menu_blur_matches_vanilla_screen_backgrounds() {
@@ -390,11 +426,32 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote);
let buttons = pause_buttons(427.0, 240.0, remote, false);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
}
}
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true);
let without = pause_buttons(427.0, 240.0, true, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
for b in &with {
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
let same = without.iter().find(|o| o.4 == b.4).unwrap();
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
}
}
}
}
@@ -0,0 +1,487 @@
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
//! list of the server's links, each opened through `ConfirmLinkScreen`
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
//! DialogScreen.WarningScreen ("Note about custom screens").
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
use super::{Game, Screen, packs::open_uri};
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
/// HeaderAndFooterLayout's header and footer heights; the content wants to
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
const HEADER: f32 = 33.0;
const FOOTER: f32 = 33.0;
const CONTENT_TOP: f32 = HEADER + 30.0;
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
const LINK_WIDTH: f32 = 310.0;
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
const ROW: f32 = 22.0;
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
const SCROLL_RESERVE: f32 = 10.0;
/// DialogScreen's `dialog/warning_button` ImageButton.
const WARNING_BUTTON: f32 = 20.0;
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
#[derive(Default)]
pub(super) struct LinksUi {
pub scroll: f32,
pub confirm: Option<String>,
}
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
/// (java.net.URI's scheme then the allowlist; whitespace-free).
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
let Some(colon) = uri.find(':') else {
return false;
};
let scheme = &uri[..colon];
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return false;
}
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
return false;
}
!uri.chars().any(|c| c.is_control() || c == ' ')
}
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
/// that validate, warning about the dropped ones.
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
links
.into_iter()
.filter(|l| {
let ok = valid_untrusted_uri(&l.link);
if !ok {
log::warn!("Horizon: server sent an invalid link: {}", l.link);
}
ok
})
.collect()
}
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
fn link_label(link: &ServerLink) -> Text {
const KNOWN: [&str; 10] = [
"report_bug",
"community_guidelines",
"support",
"status",
"feedback",
"community",
"website",
"forums",
"news",
"announcements",
];
match &link.kind {
ServerLinkKind::Known(id) => Text::translate(
&format!(
"known_server_link.{}",
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
),
&[],
),
ServerLinkKind::Custom(tag) => {
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
}
}
}
/// The scrolled column of link buttons: content height and, for a list that
/// fits, the `HeaderAndFooterLayout` contents frame's top.
fn list_height(n: usize) -> f32 {
n as f32 * ROW - 2.0
}
fn list_top(h: f32, n: usize) -> f32 {
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
}
fn scroll_limit(h: f32, n: usize) -> f32 {
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
}
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
/// footer `Back` button's corner. `top` includes the scroll.
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
}
/// The warning button beside the title (DialogScreen's title row: 10 pixels
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
(0.0f32.max(w - 40.0), 5.0f32.min(h))
} else {
(x, y)
}
}
fn back_button(w: f32, h: f32) -> (f32, f32) {
(
((w - 200.0) / 2.0).floor(),
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
)
}
impl Game<'_> {
/// `connection.serverLinks()`: the links the open server sent (empty for
/// a world without a connection or one that sent none).
pub(super) fn server_links(&self) -> &[ServerLink] {
self.remote
.as_ref()
.map_or(&[], |r| r.server_links.as_slice())
}
/// DialogScreen over the pause menu: `menu.server_links.title`, the
/// scrollable link buttons and `gui.back` in the footer.
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let links = self.server_links().to_vec();
self.links_ui.scroll = self
.links_ui
.scroll
.clamp(0.0, scroll_limit(h, links.len()));
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
// The title row: the title, then 10 pixels on the warning button.
let title = Text::translate("menu.server_links.title", &[]);
let tw = atlas.font.width_text(&title);
let tx = ((w - tw - 30.0) / 2.0).floor();
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
let (wx, wy) = warning_button(w, h, tw);
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
horizon_client::widgets::record(
"menu.custom_screen_info.tooltip",
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
true,
);
batch.sprite_scaled(
atlas,
if wh {
"gui/sprites/dialog/warning_button_highlighted"
} else {
"gui/sprites/dialog/warning_button"
},
wx,
wy,
WARNING_BUTTON,
WARNING_BUTTON,
u32::MAX,
);
let first = batch.vertices.len();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
ui::button(
batch,
atlas,
x,
y,
LINK_WIDTH,
20.0,
&link_label(link),
true,
hover,
);
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::scrollbar(
batch,
atlas,
(w / 2.0).floor() + 155.0 + 4.0,
HEADER,
h - FOOTER,
list_height(links.len()),
self.links_ui.scroll.trunc(),
);
let (bx, by) = back_button(w, h);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
batch,
atlas,
bx,
by,
200.0,
20.0,
&Text::translate("gui.back", &[]),
true,
hover,
);
if wh {
ui::tooltip(
batch,
atlas,
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
self.cursor,
);
}
}
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
pub(super) fn server_links_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let links = self.server_links().to_vec();
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
if inside
&& let Some(i) = link_buttons(w, top, links.len())
.iter()
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
{
self.audio.play_button_click();
self.click_link(&links[i].link);
return Ok(());
}
let title = Text::translate("menu.server_links.title", &[]);
let tw = self.gui.atlas.font.width_text(&title);
let (wx, wy) = warning_button(w, h, tw);
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
self.audio.play_button_click();
self.screen = Screen::DialogWarning;
return Ok(());
}
let (bx, by) = back_button(w, h);
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
self.audio.play_button_click();
self.screen = Screen::Paused;
}
Ok(())
}
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
fn click_link(&mut self, url: &str) {
if !self.settings.chat_links {
return;
}
if self.settings.chat_links_prompt {
self.links_ui.confirm = Some(url.to_owned());
self.screen = Screen::ConfirmLink;
} else {
open_uri(url);
}
}
/// `ScrollableLayout`'s mouse wheel over the links.
pub(super) fn server_links_scroll(&mut self, delta: f32) {
let (_, h) = self.gui_size();
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
.clamp(0.0, scroll_limit(h, self.server_links().len()));
}
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
/// following website?", the link, the red warning, and Yes / Copy Link to
/// Clipboard / No.
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(atlas, (w, h), &url);
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
for ((x, y), key) in layout.buttons.iter().zip(labels) {
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
ui::button(
batch,
atlas,
*x,
*y,
100.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
/// copies the link, no closes; all return to Server Links.
pub(super) fn confirm_link_click(&mut self) {
let (w, h) = self.gui_size();
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
continue;
}
self.audio.play_button_click();
match i {
0 => open_uri(&url),
1 => {
self.clipboard.set(&url);
}
_ => {}
}
self.screen = Screen::ServerLinks;
return;
}
}
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
/// button ("Custom screen rejected" is the shown reason) and Back.
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let layout = dialog_warning_layout(atlas, (w, h));
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let quit = if self.external_server() {
"menu.disconnect"
} else {
"menu.returnToMenu"
};
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
ui::button(
batch,
atlas,
x,
y,
150.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// WarningScreen's answer: disconnect, or back to Server Links.
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
continue;
}
self.audio.play_button_click();
if i == 0 {
// DialogConnectionAccess.disconnect: leave with the custom
// screens' reason.
let reason = self.translate(
"menu.custom_screen_info.disconnect",
"Custom screen rejected",
);
self.leave_server(Some(reason))?;
} else {
self.screen = Screen::ServerLinks;
}
return Ok(());
}
Ok(())
}
}
/// A confirm screen's laid-out text (title, message, extra warning lines) and
/// button corners; the whole column centred (ConfirmScreen.init).
struct ConfirmLayout {
lines: Vec<(Text, f32, f32)>,
buttons: Vec<(f32, f32)>,
}
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
fn confirm_layout(
font: &horizon_client::font::Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
extra: &[Text],
button_widths: &[f32],
) -> ConfirmLayout {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.take(15)
.map(|(line, _)| line)
.collect();
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
let extra_h = 17.0 * extra.len() as f32;
let mh = 9.0 * message.len() as f32;
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
let mut y = ((h - total) / 2.0).trunc();
let mut lines = Vec::new();
let mut push = |line: &Text, at: f32| {
let lw = font.width_text(line);
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
};
push(title, y);
y += 17.0;
for line in &message {
push(line, y);
y += 9.0;
}
for line in extra {
y += 8.0;
push(line, y);
y += 9.0;
}
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
let by = y + 8.0 + 16.0;
let mut bx = ((w - row_w) / 2.0).trunc();
let buttons = button_widths
.iter()
.map(|bw| {
let p = (bx, by);
bx += bw + 4.0;
p
})
.collect();
ConfirmLayout { lines, buttons }
}
fn confirm_link_layout(
atlas: &horizon_client::hud::HudAtlas,
size: (f32, f32),
url: &str,
) -> ConfirmLayout {
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
// message, `chat.link.warning` in its red (0xFFCCCC).
confirm_layout(
&atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&Text::literal(url.to_owned()),
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
&[100.0; 3],
)
}
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
confirm_layout(
&atlas.font,
size,
&Text::translate("menu.custom_screen_info.title", &[]),
&Text::translate("menu.custom_screen_info.contents", &[]),
&[],
&[150.0; 2],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_uris_are_http_or_https_with_a_scheme() {
for ok in [
"https://example.net/x",
"http://example.net",
"HTTPS://EXAMPLE.NET",
] {
assert!(valid_untrusted_uri(ok), "{ok}");
}
for bad in [
"example.net/no-scheme",
"ftp://example.net",
"file:///etc/passwd",
"javascript:alert(1)",
"https://exa mple.net",
"://example.net",
"",
] {
assert!(!valid_untrusted_uri(bad), "{bad}");
}
}
}
+1 -153
View File
@@ -5,8 +5,7 @@ use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
items::ItemStack,
signs::{DYE_TEXT, wood_type},
signs::wood_type,
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -32,157 +31,6 @@ fn sign_box() -> EditBox {
}
impl Game<'_> {
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
/// front of the sign's text (within 90 degrees of its facing).
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
let Some(player) = self.player.as_ref() else {
return true;
};
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(state) else {
return true;
};
let prop = |key: &str| state.properties.get(key).map(String::as_str);
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
// ceiling sign's rotation segment.
let y_rot = if state.name.contains("_wall_") {
match prop("facing") {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
}
} else {
prop("rotation")
.and_then(|r| r.parse::<f32>().ok())
.unwrap_or(0.0)
* 22.5
};
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
// against the wall it hangs on.
let centre = if state.name.ends_with("_wall_sign") {
match y_rot as i32 {
90 => (0.9375, 0.5),
180 => (0.5, 0.9375),
270 => (0.0625, 0.5),
_ => (0.5, 0.0625),
}
} else {
(0.5, 0.5)
};
let xd = player.position.x - (f64::from(pos.0) + centre.0);
let zd = player.position.z - (f64::from(pos.2) + centre.1);
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
// Mth.degreesDifferenceAbs.
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
diff <= 90.0
}
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
/// sign plays the failed-interaction sound; otherwise a player who may
/// build edits the side they face.
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return;
};
if sign.waxed {
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
let sound = if sign.hanging {
"minecraft:block.hanging_sign.waxed_interact_fail"
} else {
"minecraft:block.sign.waxed_interact_fail"
};
self.audio
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
} else if self.may_build() {
let front = self.facing_sign_front(key);
self.open_sign_editor(key, front);
}
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let Some(stack) = held else {
return false;
};
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || !self.may_build() {
return false;
}
let item = &*stack.item;
let dye = if item.ends_with("_dye") {
self.res
.items()
.get(&stack.item)
.and_then(|i| i.dye.clone())
} else {
None
};
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
if !matches!(
item,
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
) && colour.is_none()
{
return false;
}
let front = self.facing_sign_front(key);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
return false;
};
let centre = pos.map(|v| f64::from(v) + 0.5);
let sound = if item == "minecraft:honeycomb" {
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
sign.waxed = true;
"minecraft:item.honeycomb.wax_on"
} else {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
// SignApplicator.canApplyToSign: the side must have text.
if side.is_empty() {
return false;
}
match colour {
Some(c) if side.color != c as u8 => {
side.color = c as u8;
"minecraft:item.dye.use"
}
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
side.glowing = true;
"minecraft:item.glow_ink_sac.use"
}
None if item == "minecraft:ink_sac" && side.glowing => {
side.glowing = false;
"minecraft:item.ink_sac.use"
}
_ => return false,
}
};
if item == "minecraft:honeycomb" {
self.level_event_at(3003, pos, 0);
}
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.mark_block_entity_updated(key);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event(
"minecraft:block_change",
pos,
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `SignBlock.openTextEdit` for the front side after placement.
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
self.sign_edit = Some(SignEditor {
+2 -11
View File
@@ -253,21 +253,12 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
pub(super) fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = m.position;
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
self.poof_particles(id);
}
/// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about
/// mob `id`.
pub(super) fn poof_particles(&mut self, id: u32) {
fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
let options = ParticleOptions::simple(ParticleType::Poof);
for _ in 0..20 {
let r = self.particles.level_random();
@@ -779,7 +779,6 @@ impl Game<'_> {
return;
};
let pos = s.pos;
let enchanted_item = enchanted.clone();
// Player.onEnchantmentPerformed
self.give_levels(-(lapis_needed as i32));
self.level.xp_seed = self.world.random.next_int();
@@ -796,7 +795,6 @@ impl Game<'_> {
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.stats.custom("enchant_item", 1);
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
self.station_changed();
}
+10 -22
View File
@@ -256,8 +256,9 @@ impl Game<'_> {
.fluid
== horizon_client::blocks::Fluid::Water;
let on_ground = p.ground.on_ground;
let (sprinting, gliding, swimming) = (
let (sprinting, flying, gliding, swimming) = (
p.sprinting,
p.flying,
p.fall_flying,
p.pose == horizon_client::pose::Pose::Swimming,
);
@@ -271,27 +272,15 @@ impl Game<'_> {
});
let s = &mut self.stats;
match riding {
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
}
_ => {}
}
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", horizontal)
}
Some(Err(())) => {
Some(_) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
} else {
s.custom("minecart_one_cm", total)
}
}
@@ -311,9 +300,8 @@ impl Game<'_> {
}
}
None if gliding => s.custom("aviate_one_cm", total),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None if flying => s.custom("fly_one_cm", horizontal),
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
None => {}
}
}
-74
View File
@@ -3,7 +3,6 @@
//! 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")
@@ -157,79 +156,6 @@ 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)
@@ -164,11 +164,12 @@ impl Game<'_> {
} else {
[self.yaw, self.pitch]
};
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
let range = f64::from(d.view_range) * 64.0 * scale;
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
continue;
}
+2 -4
View File
@@ -178,9 +178,7 @@ impl Game<'_> {
];
self.tnts.remove(i);
count -= 1;
self.with_hive_emptying_blast(|g| {
g.server_explode(center, 4.0, BlockInteraction::Destroy, false)
})?;
self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?;
} else {
i += 1;
}
@@ -254,7 +252,7 @@ impl Game<'_> {
}
let range = super::entity_culling::default_range(
glam::DVec3::splat(PrimedTnt::SIZE),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tnts {
// EntityRenderer.shouldRender.
+6 -24
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talked_to_villager", 1);
self.stats.custom("talk_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,21 +398,18 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
let session = self.merchant_apply(session, mob);
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session = session;
}
}
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
/// What a menu click's session did, applied to the world and its mob.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> (Box<merchant::Session>, Vec<ItemStack>) {
) -> Box<merchant::Session> {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
@@ -441,26 +438,11 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::TradeSound => {}
}
}
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
}
}
session
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+1 -38
View File
@@ -32,8 +32,6 @@ 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.
@@ -53,7 +51,6 @@ impl ThrownTrident {
no_physics: false,
returning: 0,
creative_only: false,
guest: None,
}
}
/// One tick of a server's client: the previous position, then `step`.
@@ -305,33 +302,11 @@ 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;
@@ -419,18 +394,6 @@ 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]),
@@ -532,7 +495,7 @@ impl Game<'_> {
let range = 10.0
* super::entity_culling::default_range(
glam::DVec3::splat(2.0 * HALF),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tridents {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
-2
View File
@@ -169,8 +169,6 @@ 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<()> {
+8 -27
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.mob_event_particles(id, 13);
self.villager_particles(id, "angry_villager");
}
return;
};
@@ -173,31 +173,12 @@ impl Game<'_> {
child.extra.bed = Some(bed);
}
// VillagerMakeLove.giveBirth: entity event 12 for the child.
self.mob_event_particles(id, 12);
self.villager_particles(id, "heart");
}
/// `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);
}
/// `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);
}
/// `particles::around_self` about mob `id`.
pub(super) fn particles_around_mob(
@@ -334,7 +315,7 @@ impl Game<'_> {
}
// Villager.setLastHurtByMob: entity event 13.
for id in angry {
self.mob_event_particles(id, 13);
self.villager_particles(id, "angry_villager");
}
}
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
@@ -389,7 +370,7 @@ impl Game<'_> {
gossip::REPUTATION_CHANGE_PER_TRADE,
);
// Villager.customServerAiStep after a trade: entity event 14.
self.mob_event_particles(id, 14);
self.villager_particles(id, "happy_villager");
}
/// `Villager.getPlayerReputation` of the local player.
pub(super) fn villager_reputation(&self, id: u32) -> i32 {
+1 -46
View File
@@ -2,53 +2,11 @@
//! 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
@@ -172,10 +130,7 @@ impl Game<'_> {
if let Some(id) = owner {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_owner = owner;
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
self.combat.explosion_owner = None;
result
self.explode(at, 1.0)
}
}
/// Wither skulls drawn as the skull model, blue when dangerous.
+24 -23
View File
@@ -511,10 +511,10 @@ pub struct Renderer {
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
hdr: Option<hdr::Hdr>,
/// Whether the window's surface can present scRGB.
hdr_capable: bool,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1071,7 +1071,7 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.ok_or("surface has no supported configuration")?;
@@ -1099,7 +1099,7 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::supported(&caps))
(config, caps.present_modes.clone(), hdr::output(&caps))
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -1113,7 +1113,7 @@ impl Renderer {
view_formats: vec![format],
color_space: wgpu::SurfaceColorSpace::default(),
};
(config, Vec::new(), false)
(config, Vec::new(), None)
};
let offscreen = surface
.is_none()
@@ -1953,22 +1953,23 @@ impl Renderer {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some("fs_celestial"),
entry_point: Some("fs_sky_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
// moon shine; nothing else reads the frame's alpha.
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
// the colour premultiplied in fs_sky_celestial. Its alpha is
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
// and moon's discs shine; nothing else reads the frame's alpha.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
@@ -2456,7 +2457,7 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
hdr: None,
hdr_capable,
hdr_output,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -2990,24 +2991,24 @@ impl Renderer {
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// scRGB while HDR output is on.
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if self.hdr.is_some() {
config.format = hdr::FORMAT;
config.view_formats = vec![hdr::FORMAT];
config.color_space = hdr::COLOR_SPACE;
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
let on = on && self.hdr_capable && self.surface.is_some();
if on != self.hdr.is_some() {
self.hdr =
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
}
if let Some(hdr) = &mut self.hdr
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more