Compare commits

...
Author SHA1 Message Date
Ludovic Marie eb03afb3d4 Merge pull request #2247 from kacpherek/fix/ambient-intervals
🤖 Agent: Read against the vanilla 26.3 sources (Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:54 +01:00
Ludovic Marie 07e317d350 Merge pull request #2242 from kacpherek/fix/absorption-effect-ends
🤖 Agent: Read against the vanilla 26.3 sources (End the Absorption effect once the absorption is used up) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:51 +01:00
Ludovic Marie 4023708865 Merge pull request #2241 from kacpherek/fix/stats-block-interactions
🤖 Agent: Read against the vanilla 26.3 sources (Count cake, cauldron, note block, bell, ender chest and jukebox statistics) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:34:46 +01:00
Ludovic Marie dc5d571b64 Merge pull request #2307 from socksprox/fix/server-pack-reload-progress
🤖 Agent: Merged by hand by Ludovic's AI agent (orchestrator) after the contribution gate. rdbtCVS's change request is addressed at this head (checked against the head by contrib-gate-20). Re-review was asked about 07:30Z with no new objection. Proven in wave 5's union run (main + 54 PRs, 332/332 selected harnesses; lan_e2e 18/18 in-frame and on the server thread). Thank you!
2026-10-27 11:31:04 +01:00
Ludovic Marie 7d80fe24f2 Merge pull request #2271 from kacpherek/fix/2213-frog-speed
🤖 Agent: Read against the vanilla 26.3 sources (Frogs walk at vanilla speed (smooth swimming move control)) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:41 +01:00
Ludovic Marie 212a7ec198 Merge pull request #2267 from kacpherek/fix/allay-delivery-and-death-drops
🤖 Agent: Read against the vanilla 26.3 sources (Make allays drop what they carry on death and use the default pickup delay when throwing) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:37 +01:00
Ludovic Marie 6fb56a9190 Merge pull request #2265 from kacpherek/fix/frog-fall-damage
🤖 Agent: Read against the vanilla 26.3 sources (Let frogs take fall damage five points below the base amount) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:33 +01:00
Ludovic Marie 7b0db2d5ec Merge pull request #2264 from kacpherek/fix/bee-sedated-stay-out
🤖 Agent: Read against the vanilla 26.3 sources (Bees let out of a sedated hive stay out of hives for 20 seconds) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:30 +01:00
Ludovic Marie 2e73331cf6 Merge pull request #2263 from kacpherek/fix/bee-poison-difficulty
🤖 Agent: Read against the vanilla 26.3 sources (Bee stings only poison on Normal and Hard) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:30:26 +01:00
Ludovic Marie 217a780f28 Merge pull request #2716 from RoyalPixelsDE/fix/duplicate-rabbit-arm
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). This fixes main's clippy failure: save_mob's second minecraft:rabbit arm is unreachable, and the first one already writes RabbitType and MoreCarrotTicks. Verify is green. Thanks @FusselTV for catching it first!
2026-10-27 11:28:00 +01:00
Ludovic Marie 92167dab3c Merge pull request #2240 from kacpherek/fix/goat-breed-milk
🤖 Agent: Read against the vanilla 26.3 sources (Let goats breed, follow wheat and be milked) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:45 +01:00
Ludovic Marie aea024d615 Merge pull request #2239 from kacpherek/fix/breeze-shot-spread-and-aim
🤖 Agent: Read against the vanilla 26.3 sources (Make breeze shots follow the world difficulty and aim higher at riders) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:42 +01:00
Ludovic Marie 9d19e22efe Merge pull request #2238 from kacpherek/fix/partial-pickup-pitch
🤖 Agent: Read against the vanilla 26.3 sources (Give a partial item pickup the random pitch of a full pickup) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:38 +01:00
Ludovic Marie 1d878f41fd Merge pull request #2237 from kacpherek/fix/detector-rail-container-signal
🤖 Agent: Read against the vanilla 26.3 sources (Make a detector rail's comparator read the minecart on it) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:34 +01:00
Ludovic Marie a541cbb77b Merge pull request #2222 from kacpherek/fix/tadpole-slime-ball-feeding
🤖 Agent: Read against the vanilla 26.3 sources (Let tadpoles be fed slime balls to grow up faster) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:27:31 +01:00
Ludovic Marie 88b3b3e879 Merge pull request #2234 from kacpherek/fix/armadillo-brush
🤖 Agent: Read against the vanilla 26.3 sources (Let a brush take a scute off an armadillo) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:55 +01:00
Ludovic Marie 2e5e61600a Merge pull request #2228 from kacpherek/fix/stats-talked-to-villager
🤖 Agent: Read against the vanilla 26.3 sources (Count talking to villagers under vanilla's statistic name) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:51 +01:00
Ludovic Marie 517c54c427 Merge pull request #2227 from kacpherek/fix/happy-ghast-camera-distance
🤖 Agent: Read against the vanilla 26.3 sources (Pull the third-person camera back further when riding a happy ghast) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:48 +01:00
Ludovic Marie 48d95663d6 Merge pull request #2225 from kacpherek/fix/sign-reopen-and-applicators
🤖 Agent: Read against the vanilla 26.3 sources (Signs reopen their editor on right click and take dyes, ink sacs and honeycomb) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:44 +01:00
Ludovic Marie 3c53df3100 Merge pull request #2224 from kacpherek/fix/stats-movement
🤖 Agent: Read against the vanilla 26.3 sources (Count fall, riding and flying statistics as vanilla does) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:24:40 +01:00
Ludovic Marie 22d72feae8 Merge pull request #2221 from kacpherek/fix/villager-trade-trigger
🤖 Agent: Read against the vanilla 26.3 sources (Fire the villager_trade advancement trigger on trades) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:51 +01:00
Ludovic Marie bd1f66f5ca Merge pull request #2219 from kacpherek/fix/minecart-dismount-direction
🤖 Agent: Read against the vanilla 26.3 sources (Step out of a minecart in vanilla's order) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:47 +01:00
Ludovic Marie df7103ef02 Merge pull request #2218 from kacpherek/fix/copper-golem-chest-comparator
🤖 Agent: Read against the vanilla 26.3 sources (Update comparators when a copper golem moves items in or out of a chest) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:43 +01:00
Ludovic Marie 23db3397a7 Merge pull request #2136 from kacpherek/fix/furnace-wet-sponge-bucket
🤖 Agent: Read against the vanilla 26.3 sources (Furnaces: drying a wet sponge fills the bucket in the fuel slot) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:39 +01:00
Ludovic Marie d713ec4c92 Merge pull request #2135 from kacpherek/fix/infested-blocks-silverfish
🤖 Agent: Read against the vanilla 26.3 sources (Infested blocks release their silverfish when broken) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:21:35 +01:00
Ludovic Marie 1e9eca2dcf Merge pull request #2133 from kacpherek/fix/damage-gamerules
🤖 Agent: Read against the vanilla 26.3 sources (Make the fall, drowning, fire and freeze damage game rules work) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:51 +01:00
Ludovic Marie 7f7e591c8a Merge pull request #2132 from kacpherek/fix/suspicious-stew-effects
🤖 Agent: Read against the vanilla 26.3 sources (Apply suspicious stew effects when the stew is eaten) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:47 +01:00
Ludovic Marie f41f360288 Merge pull request #2129 from kacpherek/fix/server-sent-particles
🤖 Agent: Read against the vanilla 26.3 sources (Server players see sweep attacks, damage indicators and fishing particles) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:44 +01:00
Ludovic Marie b8fdda0de9 Merge pull request #2126 from kacpherek/fix/missing-level-events
🤖 Agent: Read against the vanilla 26.3 sources (Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:40 +01:00
Ludovic Marie 3e22b6e383 Merge pull request #1969 from kacpherek/fix/1302-speed-after-travel
🤖 Agent: Read against the vanilla 26.3 sources (Give a server player's new body its effects at once after a level change) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:18:36 +01:00
Ludovic Marie d9b6483aa2 Merge pull request #2268 from kacpherek/fix/minecart-chest-sneak-open
🤖 Agent: Read against the vanilla 26.3 sources (Let a sneaking player open chest and hopper minecarts) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:14:07 +01:00
Ludovic Marie e8c1d1219d Merge pull request #2134 from kacpherek/fix/melee-exhaustion-on-hit
🤖 Agent: Read against the vanilla 26.3 sources (Charge melee exhaustion only when the hit hurts) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:13:02 +01:00
Ludovic Marie ac10eff413 Merge pull request #2119 from kacpherek/fix/1893-crystal-explosion
🤖 Agent: Read against the vanilla 26.3 sources (End crystal explosion shows its particles on the server thread) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:58 +01:00
Ludovic Marie 59d6b525ce Merge pull request #2116 from kacpherek/fix/2095-signal-save
🤖 Agent: Read against the vanilla 26.3 sources (Save the world on SIGTERM, Ctrl+C and SIGHUP) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:54 +01:00
Ludovic Marie d24f9f5949 Merge pull request #2115 from kacpherek/fix/1361-lingering-cloud
🤖 Agent: Read against the vanilla 26.3 sources (Lingering potions leave an effect cloud) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:50 +01:00
Ludovic Marie 167b18d7d0 Merge pull request #2111 from kacpherek/fix/2067-deflect-fireballs
🤖 Agent: Read against the vanilla 26.3 sources (Show a hit-back fireball turning at once) and proven in contrib-gate-21's union of 72 kacpherek PRs: 95 parity harnesses on playhorizon and the Mac mini, clippy, 1534 tests, lan_e2e 23 checks in-frame and with --server-thread. Thanks @kacpherek!
2026-10-27 11:12:46 +01:00
FusselTVandClaude Opus 5.5 07e935d0bf Mobs: drop the unreachable second rabbit arm in mob_to_nbt
#2585 and 0d496fdc8 both added a "minecraft:rabbit" arm to mob_to_nbt;
the first writes RabbitType and MoreCarrotTicks, so the second is never
reached and clippy -D warnings fails on main (unreachable_patterns).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:29:53 +01:00
Ludovic Marie 12e0094f1f Merge pull request #2707 from ludovic111/refactor/cut-sprites-gfx
🤖 Agent: verify passed at 42c653be0. Move only: sprite_mipmap_limit and its test go from gfx.rs to sprites.rs, cutting sprites -> gfx (step A of the cycle plan). pr_ready --quick green (1315 tests). Ready to merge once parity is green.
2026-10-27 09:47:41 +01:00
Ludovic Marie 1586018c65 Merge pull request #2534 from kacpherek/feat/bidi-text
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw Arabic, Persian and Hebrew text joined and right to left. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes. I merged main in (no conflicts).
2026-10-27 09:40:02 +01:00
Ludovic Marie 8457b35d8a Merge pull request #2548 from kacpherek/fix/body-saddle-equipment-sync
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Send body armour, carpets and saddles to the single player window. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:39:59 +01:00
Ludovic Marie 830b6890e4 Merge pull request #2507 from kacpherek/feat/create-world-rules
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Create New World: edit the game rules before creating the world. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:39 +01:00
Ludovic Marie 0e793be2ac Merge pull request #2547 from kacpherek/fix/variant-textures
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw mob variants: rabbit, parrot, axolotl, salmon size and baby models. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:34 +01:00
Ludovic Marie 324a170a84 Merge pull request #2553 from kacpherek/fix/save-effects-teams
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Save and load a mob's active effects. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:30 +01:00
Ludovic Marie 84fc738ae5 Merge pull request #2531 from kacpherek/fix/f3b-all-hitboxes
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw F3+B hitboxes for every entity. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:27 +01:00
Ludovic Marie 5fac4f0f34 Merge pull request #2525 from kacpherek/fix/player-pose-dimensions
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Size players' boxes by pose, including sleeping. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:36:23 +01:00
Ludovic Marie d65bbfc30f Merge pull request #2526 from kacpherek/fix/mob-freeze-strider-shake
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Freeze mobs in powder snow and shake frozen mobs and cold striders. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:54 +01:00
Ludovic Marie 784b586757 Merge pull request #2524 from kacpherek/fix/guardian-bubbles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw the guardian's swimming bubbles and the bubble trail along its beam. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:51 +01:00
Ludovic Marie 71a1181b10 Merge pull request #2518 from kacpherek/fix/block-outline-rules
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Follow vanilla's block outline rules: high contrast, F1 and adventure. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:48 +01:00
Ludovic Marie 45b80deb02 Merge pull request #2517 from kacpherek/fix/drowning-bubbles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Send the drowning entity event (67) and draw the bubbles on clients. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:44 +01:00
Ludovic Marie 4ae877ab3b Merge pull request #2514 from kacpherek/fix/amethyst-step-chime
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the amethyst chime when walking on amethyst blocks. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:33:40 +01:00
Ludovic Marie 8aeb393c54 Merge pull request #2516 from kacpherek/fix/block-ambient-sponge-brushable
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Wet sponges drip water and suspicious sand and gravel shed dust. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:31:05 +01:00
Ludovic Marie 5cf1da78e8 Merge pull request #2510 from kacpherek/fix/projectile-smoke-trail
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Draw the smoke trail behind wither skulls and fireballs, and the wither's head smoke on a client. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:31:02 +01:00
Ludovic Marie 2779c481ad Merge pull request #2498 from kacpherek/fix/2494-command-block-use
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Open the command block screen on a server's client and send its edits. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:30:58 +01:00
Ludovic Marie 932a2215fc Merge pull request #2492 from kacpherek/fix/2487-glass-item-water
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Show water through dropped stained glass items. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:29:50 +01:00
Ludovic Marie cb574cdfdc Merge pull request #2509 from kacpherek/fix/lan-entity-event-particles
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Villagers in an ongoing raid splash for guests too (entity event 42). I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:27:02 +01:00
Ludovic Marie b6ee9b1de8 Merge pull request #2569 from kacpherek/fix/rain-at-game
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Check rain at the player's and arrow's position. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:58 +01:00
Ludovic Marie 107a25bee3 Merge pull request #2567 from kacpherek/fix/rain-at-mobs
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Check rain at the mob's position, not the global weather. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:55 +01:00
Ludovic Marie 9ba8223b40 Merge pull request #2572 from kacpherek/fix/2571-difficulty-persist
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Save and load the difficulty through level.dat's difficulty_settings. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:51 +01:00
Ludovic Marie 345650c697 Merge pull request #2529 from kacpherek/fix/head-rotation-limits
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Use vanilla's head turn limits for some mobs and show a parrot in water as flying. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:26:46 +01:00
Ludovic Marie 8f8aecd2c8 Merge pull request #2504 from kacpherek/fix/rain-splash-radius
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Use the Weather Radius for rain splashes and let the Weather option stop them. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:53 +01:00
Ludovic Marie 7495681901 Merge pull request #2523 from kacpherek/fix/framed-map-decorations
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Hide player arrows on a map in an item frame. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:13 +01:00
Ludovic Marie 3c820a0751 Merge pull request #2520 from kacpherek/fix/mob-step-sounds
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Fix turtle step timing and camel sand step sounds. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:23:01 +01:00
Ludovic Marie a1fa0149ff Merge pull request #2519 from kacpherek/fix/voice-pitch
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Fix the baby zombie villager, happy ghast and player hurt voice pitch. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:22:45 +01:00
Ludovic Marie 167c3d14bd Merge pull request #2522 from kacpherek/fix/level-up-sound
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play the level-up sound for each level a gain crosses. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:16 +01:00
Ludovic Marie 152fe2bb0a Merge pull request #2508 from kacpherek/fix/step-sound-discrete
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Play player step sounds while crawling under a low ceiling. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:13 +01:00
Ludovic Marie 6fd328c602 Merge pull request #2512 from kacpherek/fix/end-flash-hide-lightning
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Stop End flashes lighting the scene when Hide Lightning Flashes is on. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:09 +01:00
Ludovic Marie 5c5ede59c1 Merge pull request #2511 from kacpherek/fix/night-vision-partial-tick
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Ease the night vision pulse with the partial tick. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:05 +01:00
Ludovic Marie 9dda8e0678 Merge pull request #2505 from kacpherek/fix/chunk-builder-preset
🤖 Agent: Merged by Ludovic's AI agent (Claude, contrib-gate-19). Thanks @kacpherek: Set the preset to Custom when Chunk Builder changes. I checked it against vanilla 26.3, and it passed every harness it reaches in a union with your other PRs, plus lan_e2e in both modes.
2026-10-27 09:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 42c653be00 refactor: sprite_mipmap_limit lives in sprites.rs
sprites.rs called back into gfx for this one function, one of the edges that
keep the client lib's modules in a single cycle. The function and its test
move to sprites.rs; gfx calls it there. No behaviour change.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:24:26 +01:00
Ludovic Marie 6af60956ec Merge pull request #2637 from ludovic111/feat/launcher-language-home
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 08:01:36 +01:00
Ludovic Marie 8c1fa65f35 Merge pull request #2636 from ludovic111/feat/launcher-themes
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:51:55 +01:00
Ludovic Marie bc1549a438 Merge pull request #2635 from ludovic111/feat/launcher-language
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:42:16 +01:00
Ludovic Marie b6f18dc8f3 Merge pull request #2632 from ludovic111/feat/launcher-skins
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:32:21 +01:00
Ludovic Marie 504c9daf20 Merge pull request #2631 from ludovic111/feat/launcher-accounts
🤖 Agent: launcher-features-1: base main after #2627 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:21:08 +01:00
Ludovic Marie 5a6bf9705c Merge pull request #2696 from ludovic111/perf/parity-solo-window-harnesses
🤖 Agent: game-window harnesses run alone on 4-CPU machines (playhorizon runner); CI-like mix 8 of 8 on playhorizon.
2026-10-27 07:14:40 +01:00
Ludovic Marie 8f0797f258 Merge pull request #2627 from ludovic111/feat/launcher-linux-toggles
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:11:29 +01:00
Ludovic Marie 8ecf5d6e47 Merge remote-tracking branch 'origin/main' into perf/parity-solo-window-harnesses 2026-10-27 07:03:34 +01:00
Ludovic MarieandClaude Opus 5.5 e42a82e9b9 Name the difficulty id field as #2239 does
#2239 adds the same LocalDifficulty field as `id`; with the same lines
here the two branches merge without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:03:08 +01:00
levzzzandminimax-m3.1-flash e3f6f9d4dc Test the vex's small held item
`VexModel.translateToHand` draws the held item at 0.55 scale, so the
vex's sword is the tiny one vanilla shows. The test checks the scale of
the item's transform; it fails without the cull-scale (it was 1).

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 11:01:50 +05:00
Ludovic MarieandClaude Opus 5.5 d8b49e8697 Merge main into fix/2213-frog-speed
Keeps both registrations: the frog's move control and main's turtle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:01:02 +01:00
Ludovic Marie c1ebf5d1b0 Merge pull request #2626 from ludovic111/feat/launcher-instance-commands
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:00:52 +01:00
Ludovic MarieandClaude Opus 5.5 71b8f39eb5 Merge main into fix/allay-delivery-and-death-drops
Keeps both death drops: the allay's load and main's piglin inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:00:33 +01:00
Ludovic MarieandClaude Opus 5.5 d53114fa82 Size a drowned's thrown trident in F3+B
Main's ProjectileKind::Trident (a mob's trident) is the trident's 0.5 box with
its 0.13 eye height, as the player's tridents below.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:58:31 +01:00
Ludovic Marie 9b5c36e232 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-27 06:58:17 +01:00
Ludovic MarieandClaude Opus 5.5 64279fa1ce Merge main into fix/detector-rail-container-signal
Keeps both GameLevel fields: cart_signals and main's arrow_boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:47 +01:00
levzzz db0a33da3f Merge remote-tracking branch 'upstream/main' into fix/villager-job-site-trades 2026-10-27 10:53:33 +05:00
levzzz b5ec5a782f Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight 2026-10-27 10:53:22 +05:00
levzzz 802368fb95 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 10:53:15 +05:00
levzzz 5815560b0b Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 10:53:01 +05:00
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic MarieandClaude Opus 5.5 4d897de20b Play the rain extinguish sound for every player
Entity.applyEffectsFromBlocks plays playEntityOnFireExtinguishedSound for any
player whose fire the rain puts out: a LAN guest or the server thread's player
(the default single player) heard nothing before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:50:26 +01:00
Ludovic MarieandClaude Opus 5.5 b463f1c80a Waxed hanging signs play their own failed-interaction sound
HangingSignBlockEntity.getSignInteractionFailedSoundEvent is
block.hanging_sign.waxed_interact_fail, not the standing sign's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:50:22 +01:00
Ludovic MarieandClaude Opus 5.5 a8ccf43d2b Merge main into fix/sign-reopen-and-applicators
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:49:57 +01:00
Ludovic MarieandClaude Opus 5.5 6964ee8ece Merge main into fix/stats-movement
Keeps both fall arms: the fall_one_cm statistic and main's landing dust.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:48:18 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic MarieandClaude Opus 5.5 83ee5c303b parity: game-window harnesses run alone on 4-CPU machines
On playhorizon's runner (CPUs 4-7, 4 harness slots, frames drawn on the CPU)
remote_fireworks still failed in #2215's job after #2589 and #2629: a
screenshot took ~1.5 s (58 ticks for two) beside three other harnesses. The
harnesses in parity_hosts.json's new solo list run alone there, after the
others; bigger machines are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:46:49 +01:00
levzzzandminimax-m3.1-flash d966b1e59b Merge kacpherek's PR #2560: the vex holds its item at the small size
`VexModel.translateToHand` draws the held item at 0.55 scale with the
vex's own offset, so the vex's sword is the tiny one vanilla shows.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:46:33 +05:00
Ludovic MarieandClaude Opus 5.5 54ab5dcf00 Merge main into fix/save-effects-teams
Main added a mob's hidden_effects (effects given with hideParticles): a saved
mob effect now writes show_particles and show_icon false for them and reads
show_particles back (MobEffectInstance.Details.CODEC), so a reload keeps them
hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:45:26 +01:00
Ludovic MarieandClaude Opus 5.5 4516f54e2b Merge main into fix/furnace-wet-sponge-bucket
Keeps main's recipe_used_key with this branch's wet sponge bucket fill.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:44:25 +01:00
Ludovic MarieandClaude Opus 5.5 82055fb57d Merge main into fix/1893-crystal-explosion
lan_explode now takes main's `small` flag; the explosions a guest's
turn puts off keep it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:27 +01:00
Ludovic Marie bcf2a34a10 Merge #2563
# Conflicts:
#	tools/lan_e2e.py
2026-10-27 06:41:11 +01:00
Ludovic Marie 407053f37c Merge #2561
# Conflicts:
#	crates/horizon-client/src/menu.rs
2026-10-27 06:41:04 +01:00
Ludovic Marie c79b5be2e4 Merge #2564
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 06:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 30027e32a6 Merge main (selectors: the level candidates keep main's is_arrow; /execute on origin and score holders know the crystals and the dragon)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:37 +01:00
Ludovic MarieandClaude Opus 5.5 739d4fe187 Merge main into fix/2095-signal-save
Keeps both: the stop-signal counter from this branch and main's
release_free_memory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:30 +01:00
Ludovic MarieandClaude Opus 5.5 24744aae44 Merge main into fix/1361-lingering-cloud
Resolves potions.rs against main's thrower tracking: splashes keep
their indirectMagic source, and a lingering cloud keeps the thrower too
(AreaEffectCloud.getOwner), so its instant damage names them, else the
cloud.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:39:43 +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 9c3058f756 Merge main into fix/player-pose-dimensions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:06 +01:00
Ludovic Marie 9ca282df1e Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game/packs.rs
2026-10-27 06:36:44 +01:00
Ludovic MarieandClaude Opus 5.5 ef0ea5daad Merge main into fix/drowning-bubbles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:14 +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
minimax-m3.1-flash 57213668a0 Add a regression test: an arrow kills a player at half a heart
The health layer spends the last of the player's health and raises the dead
flag, so Game::tick can show the death screen.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 10:33:31 +05:00
Ludovic MarieandClaude Opus 5.5 584fa2a9b5 Merge main into fix/step-sound-discrete
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:33:09 +01:00
Ludovic MarieandClaude Opus 5.5 6c09ec2cc8 Save a player effect's icon as shown only when its particles are
MobEffectInstance's show_icon defaults to show_particles: an effect given with
hidden particles hides its icon too, and keeps doing so after a reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:27 +01:00
Ludovic MarieandClaude Opus 5.5 5435eb0f17 Send a cold strider's shiver at index 19
AgeableMob has two entries in 26.3 (DATA_BABY_ID 16, AGE_LOCKED 17), so
Strider's DATA_BOOST_TIME is 18 and DATA_SUFFOCATING 19. A boolean at 18 is
the wrong type for that slot on a vanilla client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:07 +01:00
Ludovic MarieandClaude Opus 5.5 370444eed0 Dim the End flash's light under a boss bar's world fog
LightmapRenderStateExtractor adds a third of the flash's intensity to the sky
factor while the boss overlay creates world fog (the dragon fight).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:50 +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 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
levzzzandminimax-m3.1-flash ddfa71a46f Merge the End crystal and dragon selectors into the cross-level selectors
Both pull requests reshape Game::select in game/commands.rs, so the
cross-level branch now carries the End crystal and dragon candidates as
well: the crystals and the dragon are listed by level_candidates, and
level_holds knows them, so an unbounded selector finds them in the level
that holds them.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 09:33:56 +05: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
SenjinandClaude Opus 5.5 620dfc6e0d HDR: let light sources shine the same under the vignette
The lift's threshold read the frame, which the vignette darkens toward
the screen's edges, so a jack o'lantern lit up fully in the middle of
the screen and only at its eyes and mouth further out. Read it from the
world copy instead; the gain still applies to the frame, so the vignette
darkens highlights as before.

Telling the vignetted world from UI also failed on pixels with a channel
at 0 (a pumpkin's stem): each channel's ratio was compared to their
mean, and a 0 channel's ratio is 0. Hold the channels to one factor, the
ratio of their sums, allowing a step of 8-bit rounding.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 05:01:43 +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
Ludovic MarieandClaude Opus 5.5 7115c708e1 launcher: pre-launch command, wrapper and environment per instance
Home's Instance row gets Launch...: a shell command run in the instance's
folder before the game (which starts only if it succeeds), a wrapper the
game runs through, and environment variables, kept in instances.json.
The game stays one process: /bin/sh runs the command and execs the
wrapper and game through "$@" on Unix, cmd /C on Windows. The window's
Play and `play` both use them. CLI and MCP: instance launch (confirm
needed over MCP). The Instance row's actions move under its name, and
editing an instance that shares its name with an import works again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:22:34 +01:00
Ludovic MarieandClaude Opus 5.5 82c8398c6b launcher: desktop shortcut that plays an instance
Home's Instance row gets Shortcut / Remove shortcut, and the CLI and MCP
instance shortcut [instance] [--remove]. The shortcut runs
horizon-launcher play --instance <id>: a .desktop file on Linux (and in
the applications menu), a script app with Horizon's icon on macOS, and a
Shell Link written from Microsoft's published MS-SHLLINK layout on
Windows. HORIZON_LAUNCHER_DESKTOP points it at a scratch folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Silas LunsfordandGLM 5.3-Flash c3a12bf792 client: retune the entity-shadow depth bias to kill its rim flicker
The shadow quads lie exactly on block tops like vanilla's, but terrain
and shadows are separate draws: their fp32 depth interpolations disagree
by a few dozen ulps of the fragment depth, and at a constant bias of
16 ulps the rim re-rolled the fight with the block top as the camera
drifted a subpixel - the visible speckle. Subpixel yaw glides at three
camera pitches (70, 45, 20 down) measured ~88/~60/~26 strong flip
pixels a frame with the shadow, all on the rim, none without it.
A constant bias of 128 ulps covers the spread at any resolution while
staying far below what the 0.001 a model's feet sit higher amounts to,
so shadows never bleed onto the feet.

Co-Authored-By: GLM 5.3-Flash <[email protected]>
2026-10-26 21:11:44 -05: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
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
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
minimax-m3.1-flash 498de72da2 Frog: walk at vanilla's speed
Vanilla's frog uses a SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true), so
a walking frog covers 0.1 blocks a tick (its movement speed attribute is 1.0
and the controller's outside-water speed is 0.1). Horizon gave every generic
animal the plain ground controller, which drives the mob at the attribute
itself: a frog walked ten times too fast, jittered, teleported and flew
(#2213).

The regression test fails without the change.
2026-10-27 05:07:45 +05:00
levzzz 04672c9613 Merge remote-tracking branch 'upstream/main' into fix/selector-end-crystals 2026-10-27 05:04:30 +05:00
Kacpherek 76bebec833 Stop vexes suffocating inside blocks 2026-10-27 01:01:53 +01:00
levzzzandminimax-m3.1-flash fa4f38f824 commands: an unbounded selector reaches the other loaded levels
`Game::select` gathered its candidates from the level in place only, so
`@e[tag=...]` never found an entity in another dimension the server
still holds: vanilla's `EntitySelector` searches every loaded level
unless the selector carries a spatial constraint.

The selector now searches each loaded level in turn, and the commands
that act on the entity it found (`/data get entity`, `/kill`, `/tp`)
act on it in the level that holds it. The experience orbs and the
projectiles are named by their entity id, like the other entities, so
the entity a command acts on is the one the selector found.

Fixes #1524

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 05:01:07 +05:00
levzzz 14dac749e3 Merge remote-tracking branch 'upstream/main' into fix/thrown-projectile-flight 2026-10-27 04:50:53 +05:00
levzzz 10d0132a53 Merge remote-tracking branch 'upstream/main' into fix/mining-dig-stop-check 2026-10-27 04:50:40 +05:00
levzzz 209fa169cd Merge remote-tracking branch 'upstream/main' into fix/villager-job-site-trades 2026-10-27 04:50:35 +05:00
levzzz 17fa6e826c Merge remote-tracking branch 'upstream/main' into fix/potion-contents-in-the-creative-inventory 2026-10-27 04:50:23 +05:00
levzzz 66bec37b55 Merge remote-tracking branch 'upstream/main' into fix/container-press-state-lifetime 2026-10-27 04:50:18 +05:00
levzzz 0fe747ad51 Merge remote-tracking branch 'upstream/main' into fix/sprint-state-persistence 2026-10-27 04:48:57 +05:00
minimax-m3.1-flash 5be7c02d0c Cover the mining dig stop in the LAN harness (#1340, #1908)
A block the client breaks at once (a creative break, a dead bush) sends no STOP or ABORT, so the server has to forget the dig itself. Without it the server keeps mining the emptied cell: the block put back there is destroyed again (#1340) and the mining sound keeps playing at it (#1908, the cell's sound group is stone).

The new dig_stop check breaks a stone block in creative and a dead bush in survival for the host and the guest, then puts the block back and waits: it must stay, and no mining sound may play.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
minimax-m3.1-flash e11471cda0 Run the LAN harness headless
HORIZON_E2E_HEADLESS launches the games with --offscreen --no-pause, so the suite runs on a machine with no focused window: without it the games pause on the focus loss and wait_ready never completes. macOS keeps its offscreen launch.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:22 +05:00
levzzzandminimax-m3.1-flash 731a35612a Lock the benchmark's load gate: a scene waits for the chunks
The benchmark's readiness rule waits for the resident chunk count to reach
the view's target before "steady" means loaded, so a scene that starts with
an empty view (the integrated server has not sent its first chunk yet) is
measured after the world is really there. The rule had no test, so a change
could silently drop it. This adds one: a fake control that keeps the counts
steady and the queue empty for a second and a half, and only then lets the
chunks arrive, must not end the wait before they do. Without the gate the
wait ends after the quiet second and the test fails.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:38:01 +05:00
FusselTVandClaude Opus 5.5 b1450f1ef2 Mobs: foxes, rabbits, goats and horses keep their state
finalizeSpawn picks a fox's type (red or snow), a rabbit's type, a goat's
screaming flag and horns and a horse's max health, but the save and the
load did not keep them: a snow fox came back red, every rabbit brown, a
goat that had lost a horn grew it back, and a new horse was saved with
the type's health (53) above the max health it rolled.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 00:37:17 +01:00
levzzzandminimax-m3.1-flash e184ab7a42 A villager that loses its job site offers its new profession's trades
The reported sequence in #1639: a villager works at a bench, the bench is
destroyed and it takes another bench's job, and then it does not open a
trading menu.

`Villager.setVillagerData` drops the offers (`offers = null`) when the
profession changes, so the next `AbstractVillager.getOffers` rolls the new
profession's trade set. The brain reaches that through `set_profession`,
which now also marks the offers as not made (#2009); this test covers the
whole reported sequence, from the lost job site to the new profession's
trades, so a villager that keeps the old profession's offers marked as made
cannot pass it.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 04:35:26 +05:00
Kacpherek a934dac2bd Save and load the difficulty through level.dat's difficulty_settings 2026-10-27 00:32:30 +01:00
Kacpherek 87d577e566 Check rain at the player's and arrow's position
Arrows now ask whether it rains at their spot (biome precipitation and
blocks above, Level.isRainingAt) instead of just the sky light, and a
Riptide trident needs rain at the player's position. Rain also puts out
a burning player (Entity.baseTick), in the window and for guests on the
server thread.
2026-10-27 00:20:08 +01:00
Kacpherek b5794fa5b3 Check rain at the mob's position, not the global weather
Water sensitive mobs, endermen, dolphins, wolves, sun burn and the happy
ghast's healing now ask the rain at their own spot (Entity.isInRain,
Level.precipitationAt) instead of the world raining flag. Rain also puts
out a burning mob, and striders are hurt by water and rain.
2026-10-27 00:17:04 +01:00
levzzzandminimax-m3.1-flash 4c9075a072 Keep the thrown-item reproduction for #1762
A server's client ticks a projectile itself between the entity tracker's
updates, as vanilla's Entity no-op interpolation handler does; before #2086 it
only eased towards the server's position, so a thrown egg, ender pearl or wind
charge hung in the air for half a second. The client tick is now a named
function with unit tests for that flight, and the parity harness keeps the
reproduction the issue asked for: a connected client throws each of them and
samples their position twice as often as the tracker's interval. The LAN
harness can also run headless now (HORIZON_E2E_HEADLESS), so the games render
offscreen and never pause on the focus loss on a machine with no focused
window.

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

Fixes #1832

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

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

Fixes #1737

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

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

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

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

Fixes #2471

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:59:03 +01:00
levzzzandminimax-m3.1-flash 8909e0510c commands: selectors find the End crystals and the Ender Dragon
Horizon's command targets enumerated the ordinary entity collections
only, so `@e[type=end_crystal]` and `@e[type=ender_dragon]` matched
nothing: the End crystals and the dragon live in their own lists, and
neither carried an entity id a command could name.

The crystals and the dragon now take an id from the entity counter when
they are created, as EndCrystal's and EnderDragon's constructors do; the
selector offers them as candidates; `/data get entity` reads them and
`/kill` removes them the way `Entity.kill` does. `/summon
minecraft:end_crystal` creates one, as vanilla does.

Fixes #1531

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:55:39 +05: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
Kacpherek 9ada76e00a Restore the lam-alef madda and hamza ligature rows in FORMS 2026-10-26 23:41:13 +01:00
Kacpherek a68ee8009f Save and load a mob's active effects, keep a player's hidden particles
A mob's effects were never written to its NBT and never read back, so
they were gone after a reload. LivingEntity.addAdditionalSaveData and
readAdditionalSaveData store them as active_effects.

A player's effect saved show_particles as always on and read it back
from show_icon, so an effect given with hideParticles came back with
particles.
2026-10-26 23:33:46 +01:00
levzzzandminimax-m3.1-flash ef0714a99a Lock in the sprint state that outlives the sprint key
Sprint is a state, not a held key: LocalPlayer.aiStep starts it with the
sprint key and stops it only on a vanilla stop condition, so releasing the
key keeps it while forward is held. The server-side overwrite that rewrote
the flag from the held keys is gone, so the underwater swim, the sprint
jump and the flight speed follow the state on main. This adds the
end-to-end regression tests that were missing for those three paths: the
swim speed with the key released, the jump impulse with the key released,
and the flight speed that follows the sprint state and not the sprint key.

Co-Authored-By: minimax-m3.1-flash (Claude Code) <[email protected]>
2026-10-27 03:22:24 +05:00
Kacpherek bf64f868d1 Send a mob's body and saddle equipment to the single player window
The body slot (horse armour, llama carpet, harness, nautilus armour) was sent as the head slot and the client ignored slots 6 and 7. Send the body item as slot 6 and apply slots 6 and 7 to the fields the renderer reads.
2026-10-26 23:16:08 +01:00
Kacpherek 0d496fdc8f Save and load the variant of axolotls, rabbits, parrots, salmon and tropical fish 2026-10-26 23:14:31 +01:00
Kacpherek 4cad82b4a9 Draw the variant of rabbits, parrots and axolotls, salmon sizes and the baby goat, polar bear, ocelot, turtle and armadillo
RabbitRenderer, ParrotRenderer, AxolotlRenderer and SalmonRenderer pick
their texture or model from the variant the mob already carries, and the
baby models and textures of the other AgeableMobRenderer species are used.
A baby zombie villager uses its own texture.
2026-10-26 23:14:31 +01:00
Kacpherek 8a5d7bb78d Leave the extended Arabic letters unshaped, as ICU does 2026-10-26 23:12:44 +01:00
Murillo DeolinoandCodex faeff96cf9 Add Wayland launcher window controls
Co-Authored-By: Codex <[email protected]>
2026-10-26 18:59:32 -03:00
Kacpherek 26aef75d7c Shape and reorder right-to-left text before drawing it 2026-10-26 22:58:52 +01:00
Kacpherek 348054d26e Draw F3+B hitboxes for every entity 2026-10-26 22:52:16 +01:00
Kacpherek c05dc8af22 Use vanilla's head turn limits for guardian, shulker, ravager, camel and sitting wolf, and draw a parrot in water as flying 2026-10-26 22:49:24 +01:00
Kacpherek 688733132d Freeze mobs in powder snow and shake frozen mobs and cold striders 2026-10-26 22:47:27 +01:00
Kacpherek 9dd6933dab Size players' boxes by pose, including sleeping 2026-10-26 22:46:36 +01:00
Kacpherek 87b533c5a9 Draw the guardian's swimming bubbles and the bubble trail along its beam 2026-10-26 22:46:15 +01:00
Kacpherek 7f6e47b02e Hide player arrows and markers on a map in an item frame 2026-10-26 22:45:48 +01:00
Kacpherek 7fdd57ad1e Play the level-up sound for each level a gain crosses 2026-10-26 22:45:14 +01:00
Kacpherek dca96dd1df Fix turtle step timing and camel sand step sounds 2026-10-26 22:43:50 +01:00
Kacpherek e0e484e44b Fix the baby zombie villager, happy ghast and player hurt voice pitch 2026-10-26 22:43:18 +01:00
Kacpherek b3e1542e09 Follow vanilla's block outline rules: high contrast option, F1 and adventure 2026-10-26 22:43:10 +01:00
Kacpherek 57e8de0007 Send the drowning entity event (67) and draw the bubbles on clients 2026-10-26 22:42:59 +01:00
Kacpherek 861e7279e0 Drip water from wet sponges and dust from suspicious sand and gravel
Wet sponges now drip water from their sides and bottom like
WetSpongeBlock.animateTick, and suspicious sand and gravel now shed
falling dust in their map colour when nothing is below them.
2026-10-26 22:41:57 +01:00
Kacpherek 7673d38ed7 Play the amethyst chime when walking on amethyst blocks 2026-10-26 22:41:23 +01:00
Kacpherek f2856845f4 Stop End flashes lighting the scene when Hide Lightning Flashes is on
Vanilla only adds the End flash to the sky light factor while the option is off. Horizon added it every time.
2026-10-26 22:40:27 +01:00
Kacpherek 1d93086e33 Ease the night vision pulse with the partial tick
The last 10 seconds of Night Vision pulse in brightness. Vanilla reads the pulse at (duration - partial tick), so it eases between ticks. Horizon used the whole tick count, so the brightness stepped once per tick.
2026-10-26 22:39:50 +01:00
Kacpherek 00e15171d5 Draw the smoke trail behind wither skulls and fireballs, and the wither's head smoke on a client 2026-10-26 22:38:21 +01:00
Kacpherek 960b8aabad Villagers in an ongoing raid splash: send entity event 42 2026-10-26 22:38:18 +01:00
Kacpherek 3f1181bab4 Play player step sounds while crawling under a low ceiling 2026-10-26 22:37:48 +01:00
Kacpherek 1596b84299 Create New World: the Game Rules button opens the rules screen and the choices are saved with the world 2026-10-26 22:37:24 +01:00
Kacpherek 190cf4e0c2 Changing Chunk Builder sets the graphics preset to Custom 2026-10-26 22:30:03 +01:00
Kacpherek cb488cf515 Rain splashes follow the Weather Radius and the Weather option
Splashes always used a radius of 10 blocks; vanilla uses the Weather Radius
option. The Horizon Weather toggle now also stops the splashes and the rain
sound, not only the falling rain.
2026-10-26 22:29:18 +01: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
Kacpherek 6977659843 Open the command block screen on a server's client and send its edits 2026-10-26 22:06:50 +01:00
Kacpherek a529be8915 Keep translucent item rendering with enchantment glint 2026-10-26 21:40:21 +01:00
Kacpherek d75ef5e4f7 Draw translucent item quads without depth writes so water shows through glass items 2026-10-26 21:31:51 +01:00
Kacpherek da736d56d6 Lingering clouds skip mobs none of their effects can affect 2026-10-26 21:30:42 +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
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek a14da6bb70 Merge origin/main into fix/1302-speed-after-travel 2026-10-26 19:23:02 +01:00
Kacpherek 3114fc66e9 Merge origin/main into fix/2067-deflect-fireballs 2026-10-26 19:22:08 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Pavel MikhailovinandClaude Opus 5.5 76d748caf8 Server books: positional page indicator and component-list pages
The page indicator only replaced bare %s, but 26.3's en_us template is
"Page %1$s of %2$s"; it now goes through format_java like other
translations (BookViewScreen.getPageNumberMessage).

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

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

Fixes #2273

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:30:53 +01:00
Devin 5589edb118 Server packs: swallow gameplay input while the reload progress shows
The progress overlay only changed drawing: a pack pushed while playing
left movement, attack and use live behind it. Input events are now
dropped while the staged reload runs (as vanilla's LoadingOverlay does),
and start_pack_reload releases held keys and mouse buttons up front so
nothing keeps acting across the frame it covers.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 18:26:50 +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
Devin 5179f97582 Server packs: show a progress screen while a pushed pack's resources reload
The download toast already matched vanilla, but once a server pack was
downloaded the whole resource reload ran synchronously on the render
thread: the frame froze on "Joining world" for seconds where vanilla's
Minecraft.reloadResourcePacks shows a LoadingOverlay with a progress bar.

The reload is split like the startup loader: a worker builds the overlay
root, models and sprites, the menu bundle and the pack's tables while a
progress screen (Preparing resources -> Loading assets -> Loading pack
tables -> Applying resources) covers the frame, then the renderer swap
and resource application land on the render thread and
ServerPacks.reloaded drives the feedback replies, including the recovery
reload on failure. The pack screen's own reload reuses the same staged
load, so its behaviour is unchanged.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:45:57 +01:00
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
Kacpherek 26626e8c27 Give frogs vanilla's smooth swimming move control 2026-10-26 15:45:51 +01:00
Kacpherek 13144a82a1 Pull the third-person camera back further when riding a happy ghast 2026-10-26 15:16:51 +01:00
Kacpherek 728bf14aa7 Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays
A happy ghast with a passenger waits six times as long.
2026-10-26 15:16:06 +01:00
Kacpherek fd0f09d6f6 End the Absorption effect once the absorption is used up
The effect used to stay in the list until its timer ran out, even with no absorption hearts left.
2026-10-26 15:15:20 +01:00
Kacpherek 06fb03567e Count the block interaction statistics
Eating a cake slice, using a cauldron (fill, use, clean armor, banner or shulker
box), tuning a note block, ringing a bell, opening an ender chest and putting a
record in a jukebox now add to their statistics, which stayed at 0 before.
2026-10-26 15:15:11 +01:00
Kacpherek c418034f10 Let goats breed, follow wheat and be milked
Goats only strolled and panicked, so wheat did nothing after the love
hearts and a bucket gave no milk. Give them the GoatAi core and idle
behaviours that exist here (panic 2.0, breed, tempt 1.25, baby follow
1.25) and Goat.mobInteract's milking with the goat milk sounds.
2026-10-26 15:15:07 +01:00
Kacpherek c273a7271e Make breeze shots follow the world difficulty and aim higher at riders 2026-10-26 15:14:59 +01:00
Kacpherek 5b9a66089e Give a partial item pickup the random pitch of a full pickup 2026-10-26 15:14:43 +01:00
Kacpherek 4c72a3a44f Make a detector rail's comparator read the minecart on it 2026-10-26 15:14:42 +01:00
Kacpherek 79bfb8fcb8 Let a brush take a scute off an armadillo
Right-clicking an adult armadillo with a brush did nothing. Follow
Armadillo.mobInteract: drop a scute from the brush loot table, play the
brush sound, post the entity_interact game event and wear the brush by 16.
2026-10-26 15:14:26 +01:00
Kacpherek 84aba7b3d4 Count talking to villagers under vanilla's statistic name
The statistic was written as talk_to_villager, but vanilla's key is
talked_to_villager, so the statistics screen and scoreboard criteria always
showed 0. Wandering traders already count through the same code.
2026-10-26 15:13:27 +01:00
Kacpherek e2a399aff0 Reopen signs on right click and apply dyes, ink sacs and honeycomb
Right-clicking a placed sign opened an empty inventory screen. It now
opens the editor for the side the player faces, or plays the waxed-sign
fail sound. Dyes, glow ink sacs, ink sacs and honeycomb change the
colour, glow or wax of the side the player faces and are used up.
2026-10-26 15:13:19 +01:00
Kacpherek a4d0f76edd Count movement statistics as vanilla does
Fall distance is counted once per landing from 2 blocks or more instead of on
every downward tick. Riding counts only for the mounts vanilla lists (pig,
horse family by full distance, strider, happy ghast, nautilus, boat, minecart),
and airborne sideways moves over 25 cm count as flying.
2026-10-26 15:13:08 +01:00
Kacpherek 00e2cfec29 Let tadpoles be fed slime balls to grow up faster 2026-10-26 15:12:57 +01:00
Kacpherek a5fa40d2ee Fire the villager_trade trigger so What a Deal! can be earned 2026-10-26 15:12:48 +01:00
Kacpherek 0a5e80de7b Step out of a minecart in vanilla's order 2026-10-26 15:12:33 +01:00
Kacpherek b7c47ccbf9 Let a sneaking player open chest and hopper minecarts 2026-10-26 15:11:52 +01:00
Kacpherek 9c3450622b Update comparators when a copper golem moves items in or out of a chest 2026-10-26 15:11:49 +01:00
Kacpherek 3615c65d18 Make allays drop what they carry on death and use the default pickup delay when throwing 2026-10-26 15:11:01 +01:00
Kacpherek e553db9bb5 Let frogs take fall damage five points below the base amount 2026-10-26 15:10:09 +01:00
Kacpherek 790b191ed1 Bees let out of a sedated hive stay out for 400 ticks 2026-10-26 15:09:20 +01:00
Kacpherek d136dda092 Bee stings only poison on Normal and Hard 2026-10-26 15:08:48 +01:00
Kacpherek a8205dd80d Infested blocks release their silverfish when broken 2026-10-26 13:21:45 +01:00
Kacpherek f252941ac1 Charge melee exhaustion only when the hit hurts 2026-10-26 13:20:39 +01:00
Kacpherek 30fa41ddbd Make the fall, drowning, fire and freeze damage game rules work 2026-10-26 13:20:37 +01:00
Kacpherek e2781f7939 Apply suspicious stew effects when the stew is eaten 2026-10-26 13:20:35 +01:00
Kacpherek 8697f3fd3f Furnaces: drying a wet sponge fills the bucket in the fuel slot 2026-10-26 13:13:32 +01:00
Kacpherek 6bc8a8b889 Send sweep, damage indicator and fishing particles to a server's players
Vanilla sends these with ServerLevel.sendParticles, so every player
nearby sees them. Horizon drew them only in the host window's own
particle engine, which a window on a server (and a guest next to the
host) never sees.

One helper now sends a ClientboundLevelParticlesPacket with the same
count, offset and speed to each player within 32 blocks. While the host
holds the players (a guest's turn, a host hit on a guest) the packet
waits and goes out when the tick ends.
2026-10-26 13:09:45 +01:00
Kacpherek 09078949d7 Send a guest's explosion to the players once their turn ends
An end crystal (or minecart) hit by a player on the server thread blew up
with sound and damage but no explosion particles. The blast runs inside the
guest's turn, while the host holds the players, so lan_explode had no hub to
send the explosion packet through and dropped it. Queue it like a wind burst
and send it when the turn is over.
2026-10-26 12:36:29 +01:00
Kacpherek 265965445d Save the world when the game gets SIGTERM, Ctrl+C or SIGHUP, and disconnect on native quit 2026-10-26 12:34:59 +01:00
Kacpherek f5f7448909 Lingering potions leave an effect cloud
A thrown lingering potion used to act as one weaker splash. It now leaves an area effect cloud like vanilla: radius 3, a 10 tick wait, 600 ticks, shrinking as it goes and by 0.5 each time it affects someone. Every 5 ticks it affects the mobs and players inside, with a 20 tick delay per entity, at a quarter of the duration (instant effects at half strength). Server players see it as an area_effect_cloud, like the dragon's breath clouds.
2026-10-26 12:31:40 +01:00
Kacpherek 9e3956282a Send a deflected fireball's new direction to the player at once
A ghast fireball or wind charge hit back by a player was turned on the server, but the player kept seeing it fly on until its next update ten ticks later. Mark the turn to be sent right away, as vanilla does with needsSync, and reset the acceleration power to 0.1 as an attack deflection does.
2026-10-26 12:18:36 +01:00
Ludovic MarieandClaude Opus 5.5 19a5098b14 Leave a respawned body's reset to the swap that builds it
Respawning drops the guest's body, so the next swap builds a fresh one,
which now clears what the old body was given (equipment, effects and
armor protection) itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:03:41 +01:00
Kacpherek a7ff22eea0 Give a server player's new body its effects at once after a level change
A server's player gets a new body when it joins, respawns or changes
level, but what the old body had been given by its effects (the applied
attribute modifiers and the armor protection table) was kept in its
per-player record. apply_effect_status then saw nothing to change, so
after a trip to another dimension Speed kept its icon while the body's
movement speed fell back from 0.14 to 0.10, until the effect was
reapplied.

Vanilla keeps the same ServerPlayer through ServerPlayer.teleport into
another level, with its attributes and MobEffect modifiers. A new body
now starts with nothing applied, and its effects are applied as soon as
it is created, before the movement that brought it is handled.
2026-10-26 00:16:57 +01:00
241 changed files with 12724 additions and 1448 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
+2 -1
View File
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
@@ -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
+14
View File
@@ -930,6 +930,13 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "far-view"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1398,6 +1405,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -3767,6 +3775,12 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-ident"
version = "1.0.26"
+1
View File
@@ -35,6 +35,7 @@ members = [
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
+18 -1
View File
@@ -4,7 +4,10 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, RwLock},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -23,11 +26,16 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -134,8 +142,12 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
+1
View File
@@ -62,6 +62,7 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -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 {
@@ -1061,6 +1061,7 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(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();
@@ -353,6 +353,7 @@ 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" => {
+36
View File
@@ -73,6 +73,8 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -167,6 +169,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3),
"suspicious_gravel" => Animate::Falling(0x707070),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -963,6 +969,36 @@ fn block_animate_tick(
}
}
}
Animate::WetSponge => {
// Direction.getRandom: DOWN, UP, NORTH, SOUTH, WEST, EAST.
const DIRECTIONS: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
let d = DIRECTIONS[next_int(engine.random(), 6) as usize];
// The sponge occludes, so only a sturdy neighbour face hides the drip.
if d != [0, 1, 0] && !ctx.sturdy([pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]) {
let r = engine.random();
let p = match d {
[0, -1, 0] => [fx + r.next_double(), fy - 0.05, fz + r.next_double()],
[dx, _, 0] => {
let y = fy + r.next_double() * 0.8;
let z = fz + r.next_double();
[fx + if dx > 0 { 1.1 } else { 0.05 }, y, z]
}
[_, _, dz] => {
let y = fy + r.next_double() * 0.8;
let x = fx + r.next_double();
[x, y, fz + if dz > 0 { 1.1 } else { 0.05 }]
}
};
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
Animate::Mycelium => {
let r = engine.random();
if next_int(r, 10) == 0 {
-5
View File
@@ -719,11 +719,6 @@ impl Audio {
self.play(event, at, volume, pitch);
self.capture = capture;
}
/// `LivingEntity.handleDamageEvent`'s pitch, `(r - r) * 0.2 + 1`, drawn on the
/// client (the entity's own random there, never the server's world random).
pub fn hurt_pitch(&mut self) -> f32 {
(self.random.next_float() - self.random.next_float()) * 0.2 + 1.0
}
/// `AbstractWidget.playButtonClickSound`: an unseeded, relative UI sound.
pub fn play_button_click(&mut self) {
self.play_instance(
+48
View File
@@ -450,6 +450,54 @@ mod tests {
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
/// The status a fake control answers with: the player is where it should
/// be, nothing is queued, and the world holds `chunks` resident chunks.
fn status(chunks: u64) -> String {
serde_json::json!({
"player": {"position": [0.0, 64.0, 0.0]},
"pending_chunks": 0,
"screen": "playing",
"render_distance": 12,
"chunks": chunks,
"generated_chunks": chunks,
"meshed_sections": 0,
"streaming": {},
})
.to_string()
}
#[test]
fn the_load_waits_for_the_chunks_a_teleport_starts() {
// Right after a teleport the game reports steady counts and nothing
// queued for over a second, before the integrated server has sent the
// first chunk: the quiet rule alone would call that loaded, and the
// scene would be measured over an empty view (#1749).
const LATE: usize = 30;
let (tx, rx) = mpsc::channel::<Request>();
let polls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counted = std::sync::Arc::clone(&polls);
let server = thread::spawn(move || {
let mut seen = 0;
while let Ok(request) = rx.recv() {
if request.line == "status" {
let chunks = if seen < LATE { 0 } else { 400 };
seen += 1;
counted.store(seen, std::sync::atomic::Ordering::SeqCst);
let _ = request.reply.send(status(chunks));
} else {
let _ = request.reply.send("{}".to_owned());
}
}
});
let control = Control(tx);
wait_loaded(&control, |_| true).expect("the scene loads");
assert!(
polls.load(std::sync::atomic::Ordering::SeqCst) > LATE,
"the load finished before the chunks arrived"
);
drop(control);
let _ = server.join();
}
#[test]
fn yaw_matches_vanilla() {
+302
View File
@@ -0,0 +1,302 @@
//! Right-to-left text (`FormattedBidiReorder.reorder`, behind
//! `Language.getVisualOrder`): the logical text a player types becomes the
//! glyphs a font draws left to right. Arabic letters are shaped into their
//! joined presentation forms (ICU's `ArabicShaping` with `LETTERS_SHAPE`), the
//! text is reordered by the Unicode bidirectional algorithm, and characters in
//! right-to-left runs are mirrored. Left-to-right text comes back unchanged.
use crate::text::{Span, Text};
use std::borrow::Cow;
use unicode_bidi::{
BidiClass, Direction, Level, ParagraphBidiInfo, bidi_class, get_base_direction,
};
/// How a letter joins its neighbours, as ICU's shaper sees it: `Right` joins
/// the letter before it only, `Dual` both neighbours, `Mark` is skipped over
/// (diacritics) and `None` never joins.
#[derive(Clone, Copy, PartialEq)]
enum Join {
None,
Right,
Dual,
Mark,
}
use Join::{Dual, Mark, Right};
/// ICU's joining table (`araLink`): code point ranges and their class, with the
/// lam-alef ligatures at the end. Anything else is `Join::None`.
const JOINS: &[(u32, u32, Join)] = &[
(0x0622, 0x0625, Right),
(0x0626, 0x0626, Dual),
(0x0627, 0x0627, Right),
(0x0628, 0x0628, Dual),
(0x0629, 0x0629, Right),
(0x062A, 0x062E, Dual),
(0x062F, 0x0632, Right),
(0x0633, 0x063A, Dual),
(0x0640, 0x0647, Dual),
(0x0648, 0x0649, Right),
(0x064A, 0x064A, Dual),
(0x064B, 0x0656, Mark),
(0x0670, 0x0670, Mark),
(0x0671, 0x0673, Right),
(0x0675, 0x0677, Right),
(0x0678, 0x0687, Dual),
(0x0688, 0x0699, Right),
(0x069A, 0x06B9, Dual),
(0x06BA, 0x06BA, Right),
(0x06BB, 0x06BF, Dual),
(0x06C0, 0x06C0, Right),
(0x06C1, 0x06C1, Dual),
(0x06C2, 0x06CB, Right),
(0x06CC, 0x06CC, Dual),
(0x06CD, 0x06CD, Right),
(0x06CE, 0x06D1, Dual),
(0x06D2, 0x06D3, Right),
(0x200D, 0x200D, Dual),
(0x206D, 0x206F, Mark),
(0xFEF5, 0xFEFC, Right),
];
/// Letters that have joined forms and the code point of the isolated one; the
/// final, initial and medial forms follow it (a right-joining letter has the
/// isolated and final only). The lam-alef ligatures are their own letters.
/// ICU leaves the extended letters (پ چ ژ ک گ ی and the rest of U+0679 to
/// U+06D3) unchanged, so they are in `JOINS` but not here.
#[rustfmt::skip]
const FORMS: &[(u32, u32)] = &[
(0x0622, 0xFE81), (0x0623, 0xFE83), (0x0624, 0xFE85), (0x0625, 0xFE87), (0x0626, 0xFE89),
(0x0627, 0xFE8D), (0x0628, 0xFE8F), (0x0629, 0xFE93), (0x062A, 0xFE95), (0x062B, 0xFE99),
(0x062C, 0xFE9D), (0x062D, 0xFEA1), (0x062E, 0xFEA5), (0x062F, 0xFEA9), (0x0630, 0xFEAB),
(0x0631, 0xFEAD), (0x0632, 0xFEAF), (0x0633, 0xFEB1), (0x0634, 0xFEB5), (0x0635, 0xFEB9),
(0x0636, 0xFEBD), (0x0637, 0xFEC1), (0x0638, 0xFEC5), (0x0639, 0xFEC9), (0x063A, 0xFECD),
(0x0641, 0xFED1), (0x0642, 0xFED5), (0x0643, 0xFED9), (0x0644, 0xFEDD), (0x0645, 0xFEE1),
(0x0646, 0xFEE5), (0x0647, 0xFEE9), (0x0648, 0xFEED), (0x0649, 0xFEEF), (0x064A, 0xFEF1),
(0xFEF5, 0xFEF5), (0xFEF7, 0xFEF7), (0xFEF9, 0xFEF9), (0xFEFB, 0xFEFB),
];
/// `Bidi_Mirroring_Glyph` (`UCharacter.getMirror`): each pair is two
/// neighbouring characters that mirror each other.
const MIRRORED: &str = "\
()<>[]{}«»༺༻༼༽᚛᚜‹›⁅⁆⁽⁾₍₎∈∋∉∌∊∍∕⧵∟⯾∠⦣∡⦛∢⦠∤⫮∼∽≃⋍≅≌≒≓\
≔≕≤≥≦≧≨≩≪≫≮≯≰≱≲≳≴≵≶≷≸≹≺≻≼≽≾≿⊀⊁⊂⊃⊄⊅⊆⊇⊈⊉⊊⊋⊏⊐⊑⊒⊘⦸⊢⊣⊦⫞\
⊨⫤⊩⫣⊫⫥⊰⊱⊲⊳⊴⊵⊶⊷⊸⟜⋉⋊⋋⋌⋐⋑⋖⋗⋘⋙⋚⋛⋜⋝⋞⋟⋠⋡⋢⋣⋤⋥⋦⋧⋨⋩⋪⋫⋬⋭⋰⋱⋲⋺\
⋳⋻⋴⋼⋶⋽⋷⋾⌈⌉⌊⌋〈〉❨❩❪❫❬❭❮❯❰❱❲❳❴❵⟃⟄⟅⟆⟈⟉⟋⟍⟕⟖⟝⟞⟢⟣⟤⟥⟦⟧⟨⟩⟪⟫\
⟬⟭⟮⟯⦃⦄⦅⦆⦇⦈⦉⦊⦋⦌⦍⦐⦎⦏⦑⦒⦓⦔⦕⦖⦗⦘⦤⦥⦨⦩⦪⦫⦬⦭⦮⦯⧀⧁⧄⧅⧏⧐⧑⧒⧔⧕⧘⧙⧚⧛\
⧨⧩⧸⧹⧼⧽⨫⨬⨭⨮⨴⨵⨼⨽⩤⩥⩹⩺⩻⩼⩽⩾⩿⪀⪁⪂⪃⪄⪅⪆⪇⪈⪉⪊⪋⪌⪍⪎⪏⪐⪑⪒⪓⪔⪕⪖⪗⪘⪙⪚\
⪛⪜⪝⪞⪟⪠⪡⪢⪦⪧⪨⪩⪪⪫⪬⪭⪯⪰⪱⪲⪳⪴⪵⪶⪷⪸⪹⪺⪻⪼⪽⪾⪿⫀⫁⫂⫃⫄⫅⫆⫇⫈⫉⫊⫋⫌⫍⫎⫏⫐\
⫑⫒⫓⫔⫕⫖⫬⫭⫷⫸⫹⫺⸂⸃⸄⸅⸉⸊⸌⸍⸜⸝⸠⸡⸢⸣⸤⸥⸦⸧⸨⸩⹕⹖⹗⹘⹙⹚⹛⹜〈〉《》「」『』【】\
〔〕〖〗〘〙〚〛﹙﹚﹛﹜﹝﹞﹤﹥()<>[]{}⦅⦆「」";
fn join(c: char) -> Join {
let c = c as u32;
JOINS
.iter()
.find(|&&(from, to, _)| (from..=to).contains(&c))
.map_or(Join::None, |&(_, _, join)| join)
}
/// The letter whose joined form `c` is (ICU shapes the forms again), or `c`.
fn letter_of(c: char) -> char {
let c32 = c as u32;
if !matches!(c32, 0xFE70..=0xFEFC) {
return c;
}
if (0xFE70..0xFE80).contains(&c32) {
// The diacritics' forms pair up from fathatan on.
return char::from_u32(0x064B + (c32 - 0xFE70) / 2).unwrap_or(c);
}
FORMS
.iter()
.find(|&&(letter, iso)| {
let forms = if join(char::from_u32(letter).unwrap_or(c)) == Dual {
4
} else {
2
};
(iso..iso + forms).contains(&c32)
})
.and_then(|&(letter, _)| char::from_u32(letter))
.unwrap_or(c)
}
/// `ArabicShaping(LETTERS_SHAPE).shape`: lam followed by an alef (diacritics
/// may sit between) becomes one ligature, then every letter takes the isolated,
/// final, initial or medial form its neighbours call for. Diacritics take the
/// tatweel form between two joined letters. The text can only get shorter.
fn shape(text: &[char]) -> Vec<char> {
let letters: Vec<char> = text.iter().map(|&c| letter_of(c)).collect();
let mut chars = Vec::with_capacity(letters.len());
let mut i = 0;
while i < letters.len() {
if letters[i] == '\u{644}' {
let mut at = i + 1;
while letters.get(at).is_some_and(|&c| join(c) == Mark) {
at += 1;
}
let alef = letters.get(at).and_then(|&c| {
['\u{622}', '\u{623}', '\u{625}', '\u{627}']
.iter()
.position(|&a| a == c)
});
if let Some(alef) = alef {
chars.push(char::from_u32(0xFEF5 + 2 * alef as u32).unwrap_or('\u{644}'));
chars.extend_from_slice(&letters[i + 1..at]);
i = at + 1;
continue;
}
}
chars.push(letters[i]);
i += 1;
}
// Whether the nearest letter before joins the next one, and the joins of
// the nearest letter after.
let mut joins_next = false;
let mut out = Vec::with_capacity(chars.len());
for (i, &c) in chars.iter().enumerate() {
let next_joins = chars[i + 1..]
.iter()
.map(|&n| join(n))
.find(|&j| j != Mark)
.is_some_and(|j| j != Join::None);
let kind = join(c);
let shaped = match kind {
Join::None => {
joins_next = false;
c
}
Mark => match c as u32 {
m @ 0x064B..=0x0652 => {
let iso = 0xFE70 + 2 * (m - 0x064B);
let tatweel = joins_next && next_joins && !matches!(m, 0x064C | 0x064D);
char::from_u32(iso + u32::from(tatweel)).unwrap_or(c)
}
_ => c,
},
Right | Dual => {
let to_next = kind == Dual && next_joins;
let form = match (joins_next, to_next) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
joins_next = kind == Dual;
FORMS
.binary_search_by_key(&(c as u32), |&(letter, _)| letter)
.ok()
.and_then(|at| char::from_u32(FORMS[at].1 + form))
.unwrap_or(c)
}
};
out.push(shaped);
}
out
}
/// `UCharacter.getMirror`.
fn mirror(c: char) -> char {
if bidi_class(c) != BidiClass::ON {
return c;
}
let mut pairs = MIRRORED.chars();
while let (Some(a), Some(b)) = (pairs.next(), pairs.next()) {
if c == a {
return b;
}
if c == b {
return a;
}
}
c
}
/// Whether a character can change anything: right-to-left letters and numbers,
/// the explicit direction controls and the diacritics that shaping turns into
/// letters. Without any, left-to-right text keeps its order.
fn directional(c: char) -> bool {
c as u32 >= 0x0590
&& (matches!(c as u32, 0x064B..=0x0652)
|| matches!(
bidi_class(c),
BidiClass::R
| BidiClass::AL
| BidiClass::AN
| BidiClass::LRE
| BidiClass::RLE
| BidiClass::LRO
| BidiClass::RLO
| BidiClass::PDF
| BidiClass::LRI
| BidiClass::RLI
| BidiClass::FSI
| BidiClass::PDI
))
}
/// Whether `chars` keep their order and shapes: left-to-right text without
/// anything directional, in a language that reads left to right by default.
fn unchanged(mut chars: impl Iterator<Item = char>, default_rtl: bool) -> bool {
!default_rtl && !chars.any(directional)
}
/// `FormattedBidiReorder.reorder`: `chars` shaped, in visual order, with each
/// character's position in the shaped text. A character's style is the one at
/// its position in the shaped text, as `SubStringSource` takes it (the text
/// shrinks at a lam-alef ligature).
fn reorder(chars: &[char], default_rtl: bool) -> Vec<(char, usize)> {
let shaped = shape(chars);
let plain: String = shaped.iter().collect();
// DEFAULT_RTL only changes text without a strong character.
let level =
(default_rtl && get_base_direction(plain.as_str()) == Direction::Mixed).then(Level::rtl);
let info = ParagraphBidiInfo::new(&plain, level);
let levels = info.reordered_levels_per_char(0..plain.len());
ParagraphBidiInfo::reorder_visual(&levels)
.into_iter()
.map(|i| {
let c = if levels[i].is_rtl() {
mirror(shaped[i])
} else {
shaped[i]
};
(c, i)
})
.collect()
}
/// `Language.getVisualOrder` of a text (its `§` codes already resolved), for
/// the current language's direction.
pub fn visual(text: &Text) -> Cow<'_, Text> {
let default_rtl = crate::lang::bidirectional();
if unchanged(text.chars().map(|(c, _)| c), default_rtl) {
return Cow::Borrowed(text);
}
let chars: Vec<char> = text.chars().map(|(c, _)| c).collect();
let styles: Vec<_> = text.chars().map(|(_, style)| style).collect();
let mut out = Text::default();
for (c, at) in reorder(&chars, default_rtl) {
match out.spans.last_mut() {
Some(span) if span.style == *styles[at] => span.text.push(c),
_ => out.spans.push(Span {
text: c.to_string(),
style: styles[at].clone(),
}),
}
}
Cow::Owned(out)
}
/// The same for a plain string.
pub fn visual_str(s: &str) -> Cow<'_, str> {
let default_rtl = crate::lang::bidirectional();
if unchanged(s.chars(), default_rtl) {
return Cow::Borrowed(s);
}
let chars: Vec<char> = s.chars().collect();
Cow::Owned(
reorder(&chars, default_rtl)
.into_iter()
.map(|(c, _)| c)
.collect(),
)
}
@@ -610,7 +610,9 @@ fn sign_text_calls(
.iter()
.enumerate()
.map(|(i, line)| {
// The line that fits, in the order it is drawn (getVisualOrder).
let shown = first_line(line, data.max_line_width(), &advance);
let shown = crate::bidi::visual_str(&shown).into_owned();
TextCall {
matrix: m,
x: -((width(&shown) as i32) / 2) as f32,
+24 -1
View File
@@ -7,7 +7,12 @@ use crate::{
items::{ItemStack, Items},
};
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path, sync::Arc};
use std::{
collections::{HashMap, HashSet},
fs,
path::Path,
sync::{Arc, OnceLock},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Category {
@@ -94,6 +99,24 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
}
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
/// A `Potion`: id, name suffix (`item.minecraft.potion.effect.<name>`) and
/// effects as (effect, duration ticks, amplifier).
#[derive(Clone, Copy, Debug)]
+23 -8
View File
@@ -40,6 +40,8 @@ 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 {
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -179,18 +183,22 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None);
return changed(lowered(cauldron), None, "clean_armor");
}
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))
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound))
changed(block, Some(sound), "fill_cauldron")
};
match item {
"minecraft:lava_bucket" => {
@@ -199,6 +207,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -221,6 +230,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -241,6 +251,7 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -255,7 +266,11 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -266,7 +281,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"))
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
}
("water", name)
if name
@@ -283,7 +298,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_banner")
}
("water", name)
if name
@@ -292,7 +307,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_shulker_box")
}
("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);
@@ -35,6 +35,23 @@ impl Mode {
Self::Redstone => "minecraft:command_block",
}
}
/// `Enum.ordinal`, which `ServerboundSetCommandBlockPacket` writes.
pub fn ordinal(self) -> i32 {
match self {
Self::Sequence => 0,
Self::Auto => 1,
Self::Redstone => 2,
}
}
/// The mode of a packet's ordinal; `handleSetCommandBlock` treats any
/// other value as a plain command block (its `default` branch).
pub fn from_ordinal(ordinal: i32) -> Self {
match ordinal {
0 => Self::Sequence,
1 => Self::Auto,
_ => Self::Redstone,
}
}
/// `CycleButton`: the next value, wrapping round.
pub fn next(self) -> Self {
match self {
@@ -124,6 +124,18 @@ impl Clicks {
self.skip_next_release = true;
}
/// The screen that takes the pointer over starts with its own state:
/// `AbstractContainerScreen` holds `isQuickCrafting` and the drag it
/// belongs to on the screen, so a screen that opens (or closes) drops
/// them. A press whose release never arrived -- the pointer left the
/// window -- would otherwise keep the drag running, and `press` answers
/// `None` for every later press, so the quick move and the clicks go
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
/// `target`, if any. A press holding a stack starts a quick craft drag
/// instead. `cloning` is `keyPickItem` with infinite materials.
@@ -301,4 +313,35 @@ mod tests {
Some(Input::Pickup)
);
}
/// #1737: a press whose release never arrives (the pointer left the
/// window) leaves `isQuickCrafting` set, and `press` answers `None` for
/// every later press, so the Shift quick move and the clicks on the
/// inventory go dead. The drag belongs to the screen that has it
/// (`AbstractContainerScreen` holds `isQuickCrafting`), so the screen
/// that takes the pointer over drops it and the quick move works again.
#[test]
fn a_lost_release_does_not_wedge_the_quick_move() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release never arrives.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
// The stuck drag swallows the next press: the Shift quick move stops
// working and the inventory stops answering.
assert_eq!(clicks.press(&slot, Button::Right, true, true, false), None);
// The screen that takes the pointer over starts with its own state.
clicks.cancel_drag();
assert!(clicks.quick_crafting().is_none());
// The quick move works again.
assert_eq!(
clicks.press(&slot, Button::Right, true, true, false),
Some(Input::QuickMove)
);
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+12
View File
@@ -728,6 +728,18 @@ 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 {
+12 -8
View File
@@ -179,13 +179,14 @@ impl PlayerEffects {
self.list.clear();
}
/// The effects of a saved player (`Player.active_effects`: id,
/// duration, amplifier, ambient, visible).
/// duration, amplifier, ambient, particles visible).
pub fn from_saved(saved: &[(String, i32, i32, bool, bool)]) -> Self {
let mut effects = Self::default();
for (id, duration, amplifier, ambient, _) in saved {
for (id, duration, amplifier, ambient, particles) in saved {
if let Some(def) = brewing::effect(id) {
let mut e = EffectInstance::new(def.id, *duration, *amplifier);
e.ambient = *ambient;
e.visible = *particles;
effects.add(e);
}
}
@@ -213,8 +214,9 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
/// (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.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -224,6 +226,7 @@ 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();
@@ -246,6 +249,7 @@ 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 {
@@ -465,13 +469,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);
let (_, added) = fx.tick_with(&mut convert, false);
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);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -486,9 +490,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+8 -4
View File
@@ -582,17 +582,21 @@ impl Experience {
7 + level * 2
}
}
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
self.level > start
sound_level
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
+16 -3
View File
@@ -203,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// 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);
color *= in.light_color;
if in.overlay == 7u || in.overlay == 8u {
if in.overlay == 7u || in.overlay == 8u || in.overlay == 33u {
// Enchantment glint (overlay 7, 8 with the hurt tint): the purple
// enchanted_glint sheen drifting diagonally over the item's shape.
let px = in.uv * vec2<f32>(textureDimensions(atlas));
@@ -219,9 +219,22 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
+310 -25
View File
@@ -187,6 +187,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -244,7 +269,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 +329,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 +338,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 +372,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()));
}
@@ -426,7 +491,32 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
crate::mobs::villager::write_nbt(m, map);
}
"minecraft:wandering_trader" => put_offers(map, m),
"minecraft:axolotl" => write_axolotl_memories(m, map),
"minecraft:axolotl" => {
write_axolotl_memories(m, map);
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
let packed = i32::from(pattern >= 6)
| i32::from(pattern % 6) << 8
| i32::from(m.extra.variant2 & 15) << 16
| i32::from(m.extra.variant3 & 15) << 24;
map.insert("Variant".into(), Tag::Int(packed));
}
"minecraft:allay" => {
// InventoryCarrier: the fetched stack; the liked player memory.
let mut inv = Vec::new();
@@ -800,6 +890,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") {
@@ -854,6 +950,25 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.no_gravity = flag(tag, "NoGravity");
m.glowing = flag(tag, "Glowing");
m.ticks_frozen = number(tag.get("TicksFrozen")).unwrap_or(0) as i32;
// LivingEntity.readAdditionalSaveData: the saved `active_effects`, the last
// entry of an effect winning.
if let Some(Tag::List(effects)) = tag.get("active_effects") {
for e in effects {
let Some(def) = text(e.get("id")).and_then(|id| crate::brewing::effect(&id)) else {
continue;
};
let effect = crate::brewing::namespaced(def.id);
let duration = number(e.get("duration")).unwrap_or(0) as i32;
let amplifier = number(e.get("amplifier")).unwrap_or(0) as i32;
m.extra.effects.retain(|x| x.0 != effect);
m.extra.effects.push((effect, duration, amplifier));
// Details.CODEC: `show_particles` defaults to true.
m.extra.hidden_effects.retain(|h| *h != effect);
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
m.invulnerable_flag = flag(tag, "Invulnerable");
if m.species() == crate::mobs::Species::Bat {
@@ -935,20 +1050,45 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -959,14 +1099,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(),
@@ -985,6 +1117,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -2166,6 +2299,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
// TropicalFish.Pattern.byId: an unknown id is a kob.
m.extra.variant = if size <= 1 && index < 6 {
(size * 6 + index) as u8
} else {
0
};
m.extra.variant2 = ((packed >> 16) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
@@ -2567,6 +2735,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)]
+8 -3
View File
@@ -889,12 +889,17 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
pub fn width_text(&self, t: &Text) -> f32 {
t.resolved()
let t = t.resolved();
crate::bidi::visual(&t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
+360 -92
View File
@@ -63,7 +63,6 @@ mod frames;
mod friends;
mod functions;
mod game_mode;
mod game_rules_screen;
mod gamemode_switcher;
mod ghasts;
mod golems;
@@ -157,6 +156,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -508,6 +508,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,6 +570,8 @@ 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.
@@ -642,6 +650,37 @@ 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).
@@ -702,6 +741,14 @@ 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;
}
@@ -1675,6 +1722,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)>,
@@ -1698,6 +1746,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.
@@ -1732,6 +1783,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).
@@ -2037,9 +2090,11 @@ impl Game<'_> {
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
/// pose.
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let sleeping = self.sleeping.is_some();
let item_use_speed = self
.using
.and_then(|(slot, _)| {
@@ -2067,6 +2122,7 @@ impl Game<'_> {
player.item_use_speed = item_use_speed;
player.respiration = respiration;
player.glider = glider;
let _ = player.set_sleeping(sleeping);
player.leather_boots = leather_boots;
player.freeze_immune = freeze_immune;
player.fluids.fast_lava = self.dim == Dim::Nether;
@@ -2562,8 +2618,14 @@ 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, 1.0);
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
}
item.stack = rest;
}
@@ -2620,6 +2682,7 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2662,6 +2725,7 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -2990,6 +3054,16 @@ impl Game<'_> {
data,
} => self.sculk_charge_event(pos, data),
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
// LevelEventHandler 2019 and 2020: the crack on a face of the
// block being mined, with its hit sound for 2020.
LevelEvent::LevelEvent {
id: id @ (2019 | 2020),
pos,
data,
} => {
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
}
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
self.spawner_burst([pos.x, pos.y, pos.z])
}
@@ -3009,6 +3083,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,
@@ -3217,6 +3296,8 @@ 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);
@@ -3350,11 +3431,18 @@ 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);
self.container_opener_event(key, true);
let at = [
f64::from(key.0) + 0.5,
@@ -3746,6 +3834,21 @@ 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
@@ -3867,6 +3970,10 @@ 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_jukebox(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -3926,6 +4033,20 @@ 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);
@@ -4256,6 +4377,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 {
@@ -4277,6 +4399,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);
@@ -4295,6 +4418,17 @@ impl Game<'_> {
_ => {}
}
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
fn tick_mining(&mut self) -> Result<()> {
if self.destroy_delay > 0 {
self.destroy_delay -= 1;
@@ -4338,6 +4472,11 @@ impl Game<'_> {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
// The server still counts its destroy ticks on an unbreakable block.
let m = self.mining.as_mut().ok_or("mining")?;
m.ticks += 1;
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
return Ok(());
}
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
@@ -4368,26 +4507,15 @@ impl Game<'_> {
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
};
let hit_sound = facts.sounds.clone();
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
m.progress += progress;
m.ticks += 1;
let crack = (m.pos, hit.face);
if m.ticks % 4 == 1 {
let c = [
f64::from(m.pos[0]) + 0.5,
f64::from(m.pos[1]) + 0.5,
f64::from(m.pos[2]) + 0.5,
];
self.audio.play(
&hit_sound.hit,
Some(c),
(hit_sound.volume + 1.0) / 8.0,
hit_sound.pitch * 0.5,
);
// An instant break at START sends no destroy events.
if m.progress < 1.0 || m.ticks > 1 {
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
}
self.crack_particle(crack.0, crack.1, state);
let m = self.mining.as_mut().ok_or("mining")?;
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
@@ -4707,7 +4835,6 @@ impl Game<'_> {
player.dimensions.eye_height
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
@@ -4743,10 +4870,12 @@ impl Game<'_> {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
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.
// muffled under the swim sound in water. Whether it plays is
// Player.getMovementEmission's call (shift on the ground),
// not the pose: a forced crouch still steps.
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
@@ -4758,26 +4887,21 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
MotionEvent::Drown => {
// LivingEntity.makeDrownParticles: eight bubbles moving
// with the player.
use horizon_client::particles::{ParticleOptions, ParticleType};
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [
feet[0] + triangle(),
feet[1] + triangle(),
feet[2] + triangle(),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Bubble),
p,
player_velocity,
);
// The guests see the entity event 67 too.
if let Some(host) = &self.lan {
host.hub
.entity_event(host.host_entity, combat::DROWN_EVENT, feet);
}
self.drown_particles(feet, player_velocity);
}
MotionEvent::BubbleColumnParticle { bubble, position } => {
// ServerLevel.sendParticles: SPLASH (speed 1) or BUBBLE
@@ -4813,6 +4937,10 @@ 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,
@@ -4880,6 +5008,7 @@ impl Game<'_> {
}
}
self.powder_snow_fire()?;
self.rain_extinguish_in_frame();
self.tick_totem();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -5152,6 +5281,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())
@@ -5173,10 +5305,13 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
// The container screen's press state belongs to the screen: a screen
// that takes the pointer over starts with its own state, as
// `AbstractContainerScreen` does, so a press whose release never
// arrived (the pointer left the window) cannot wedge the next
// screen's quick move and clicks.
self.reset_inventory_clicks();
if !capture {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -5635,6 +5770,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);
@@ -6022,12 +6160,14 @@ impl Game<'_> {
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks = Default::default();
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
@@ -7943,13 +8083,15 @@ 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]>) {
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
);
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);
}
@@ -8104,11 +8246,6 @@ 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 {
@@ -8188,6 +8325,14 @@ 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);
@@ -8218,6 +8363,13 @@ impl Game<'_> {
}
}
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);
@@ -8781,6 +8933,10 @@ 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(())
}
@@ -9062,6 +9218,9 @@ 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)
{
@@ -9764,16 +9923,13 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
if self.experience.give(gained as i32)
&& self.experience.level % 5 == 0
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if self.experience.level > 30 {
1.0
} else {
self.experience.level as f32 / 30.0
};
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
}
@@ -9838,7 +9994,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
self.player_projectile_shot();
let pitch = 1.0 / (self.world.random.next_float() * 0.4 + 1.2) + power * 0.5;
self.audio
@@ -10143,6 +10299,14 @@ impl Game<'_> {
};
Some(t < 0.15)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
fn raining_at(&self, p: BlockPos) -> bool {
self.world.weather.levels(1.0).0 > 0.2
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
&& self.rain_height(p.x, p.z) <= p.y
&& self.precipitation(p.x, p.y, p.z) == Some(false)
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
fn rain_height(&self, x: i32, z: i32) -> i32 {
@@ -10179,12 +10343,15 @@ impl Game<'_> {
} 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,
);
// The Horizon Weather option off silences the rain too.
if self.settings.horizon.weather {
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
}
@@ -10727,6 +10894,9 @@ impl Game<'_> {
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
level.difficulty =
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
level.time = self.world.game_time;
level.weather = self.world.weather;
level.day_time = self.world.day_time;
@@ -10882,6 +11052,21 @@ impl Game<'_> {
}
return Ok(false);
}
// A server pack's reload covers the frame with its progress
// (`LoadingOverlay`) until `tick_server_packs` applies it.
if let Some(reload) = &self.server_packs.reload {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
horizon_client::level_loading::draw_progress(
&mut hud,
&self.gui.atlas,
reload.progress.line(),
reload.progress.fraction(),
);
return renderer.render(&loading_frame(&hud));
}
return Ok(false);
}
let Some(size) = self
.renderer
.as_ref()
@@ -11002,7 +11187,11 @@ impl Game<'_> {
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
let outline = if self.screen == Screen::Playing && !self.spectator() {
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11198,6 +11387,7 @@ impl Game<'_> {
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -11257,7 +11447,7 @@ impl Game<'_> {
}),
ticks: self.ticks,
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
night_vision: self.night_vision_intensity(alpha as f32),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
@@ -11429,6 +11619,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(),
@@ -11497,8 +11691,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 => {
@@ -11595,11 +11791,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.
@@ -11620,7 +11833,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()
@@ -11717,6 +11930,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;
@@ -11770,12 +11984,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 = (
@@ -11942,6 +12154,19 @@ impl ApplicationHandler for Game<'_> {
{
return;
}
// The pack reload's progress overlay takes no input
// (`LoadingOverlay`): `start_pack_reload` released what was held.
if self.server_packs.reload.is_some()
&& !matches!(
&event,
WindowEvent::CloseRequested
| WindowEvent::Focused(_)
| WindowEvent::Resized(_)
| WindowEvent::RedrawRequested
)
{
return;
}
if self.pending_save.is_some() {
match event {
WindowEvent::CloseRequested => {
@@ -12109,6 +12334,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);
}
@@ -12162,7 +12390,8 @@ impl ApplicationHandler for Game<'_> {
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
clicked
let rules = t.press_rules(atlas, self.cursor, (b.width, b.height));
(clicked.0, clicked.1 || rules)
});
self.save_title_options();
if sound {
@@ -12300,6 +12529,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()
}
@@ -12731,6 +12969,16 @@ 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(),
@@ -13147,6 +13395,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(),
@@ -13155,6 +13404,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,
@@ -13181,6 +13431,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,
@@ -13423,7 +13674,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
"Loading assets",
@@ -13439,19 +13689,28 @@ const STAGE_LINES: [&str; 10] = [
/// The background loader's checkpoint, read by the loading screen each frame.
#[derive(Clone)]
struct Progress(Arc<AtomicU32>);
struct Progress {
value: Arc<AtomicU32>,
lines: &'static [&'static str],
}
impl Progress {
fn new() -> Self {
Self(Arc::new(AtomicU32::new(0)))
Self::with(&STAGE_LINES)
}
fn with(lines: &'static [&'static str]) -> Self {
Self {
value: Arc::new(AtomicU32::new(0)),
lines,
}
}
fn set(&self, stage: u32) {
self.0.store(stage, Ordering::Relaxed);
self.value.store(stage, Ordering::Relaxed);
}
fn fraction(&self) -> f32 {
self.0.load(Ordering::Relaxed) as f32 / (STAGES - 1) as f32
self.value.load(Ordering::Relaxed) as f32 / (self.lines.len() - 1) as f32
}
fn line(&self) -> &'static str {
STAGE_LINES[(self.0.load(Ordering::Relaxed) as usize).min(STAGE_LINES.len() - 1)]
self.lines[(self.value.load(Ordering::Relaxed) as usize).min(self.lines.len() - 1)]
}
}
@@ -13905,6 +14164,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -14217,6 +14477,11 @@ 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();
@@ -14494,6 +14759,9 @@ 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();
@@ -708,6 +708,27 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
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)
});
}
/// `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 -1
View File
@@ -131,7 +131,8 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
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,
@@ -547,7 +547,7 @@ impl Game<'_> {
});
}
/// `Entity.isInRain`: rain at the block position or at the top of the box.
fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
pub(super) fn in_rain(&mut self, at: Vec3, top: f64) -> bool {
let p = BlockPos::new(
at.x.floor() as i32,
at.y.floor() as i32,
+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);
};
+13 -35
View File
@@ -789,6 +789,11 @@ 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,
@@ -801,7 +806,11 @@ impl Game<'_> {
pub(super) fn load_world_state(&mut self) {
self.ui.rules = GameRules::load(&self.world_root);
self.ui.clock = WorldClock::default();
self.ui.difficulty = read_difficulty(&self.world_root).unwrap_or(2);
// PrimaryLevelData: the difficulty comes back from level.dat's difficulty_settings.
self.ui.difficulty = horizon_client::title::DIFFICULTIES
.iter()
.position(|d| *d == self.level.difficulty)
.map_or(2, |i| i as u8);
if let Ok(bytes) = std::fs::read(self.world_root.join("data/minecraft/world_clocks.dat"))
&& let Ok(raw) = horizon_world::compression::Compression::Gzip.decode(&bytes, 1 << 20)
&& let Ok((root, _)) =
@@ -826,9 +835,9 @@ impl Game<'_> {
self.build_registries();
self.sync_rules();
}
/// Save the clock and the difficulty beside level.dat (Level.write saves
/// the game rules: a second writer of game_rules.dat could leave it with
/// the rules loaded when the world opened).
/// Save the clock beside level.dat (Level.write saves the game rules: a
/// second writer of game_rules.dat could leave it with the rules loaded
/// when the world opened).
pub(super) fn save_world_state(&mut self) {
if self.world_root.as_os_str().is_empty() {
return;
@@ -876,9 +885,6 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -988,34 +994,6 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
+44 -3
View File
@@ -1,7 +1,16 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
use horizon_client::{
health::HealthEvent,
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::random::RandomSource;
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
pub(super) const DROWN_EVENT: i8 = 67;
struct Entry {
damage_type: String,
@@ -45,6 +54,38 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
self.particles.add(&bubble, p, velocity);
}
}
/// Entity event 67 from a server, for this client's own player or an
/// entity it draws.
pub(super) fn remote_drown(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if own {
self.player.as_ref().map(|p| {
let (at, v) = (p.position, p.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
let (at, v) = (m.position, m.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
};
if let Some((at, v)) = body {
self.drown_particles(at, v);
}
}
/// The display name of a mob: its custom name or its type's name.
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
@@ -82,8 +123,8 @@ impl Game<'_> {
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source};
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,6 +12,7 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -186,6 +197,18 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -572,12 +595,66 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -609,7 +686,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.value.clone();
let command = s.command.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
+233 -79
View File
@@ -42,9 +42,10 @@ pub(super) enum Target {
Guest(u32),
Mob(u32),
Item(u32),
Orb(usize),
/// By index in `creatures.projectiles`.
Projectile(usize),
/// An experience orb, by its entity id.
Orb(u32),
/// A projectile, by its entity id.
Projectile(u32),
Stand(u32),
Frame(u32),
Painting(u32),
@@ -53,6 +54,10 @@ pub(super) enum Target {
/// An entity of a type Horizon does not simulate (markers, displays,
/// interactions...): kept as its saved data at a position.
Other(u32),
/// An End crystal, by its entity id (`minecraft:end_crystal`).
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -340,14 +345,16 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| [i.position.x, i.position.y, i.position.z]),
Target::Orb(i) => self
Target::Orb(id) => self
.orbs
.get(i)
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|a| a.id == id)
.map(|a| [a.position.x, a.position.y, a.position.z]),
Target::Stand(id) => self
.armor_stands
@@ -385,6 +392,16 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map(|o| [o.position.x, o.position.y, o.position.z]),
Target::Crystal(id) => self
.crystals
.iter()
.find(|c| c.id == id)
.map(|c| [c.position.x, c.position.y, c.position.z]),
Target::Dragon(id) => self
.dragon
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -399,10 +416,11 @@ impl Game<'_> {
}),
Target::Item(_) => "minecraft:item".into(),
Target::Orb(_) => "minecraft:experience_orb".into(),
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|p| p.id == id)
.map_or_else(String::new, |p| p.kind.id().into()),
Target::Stand(_) => "minecraft:armor_stand".into(),
Target::Frame(id) => {
@@ -428,6 +446,8 @@ impl Game<'_> {
.iter()
.find(|o| o.id == id)
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -512,50 +532,72 @@ impl Game<'_> {
s.pos[2].unwrap_or(p[2]),
]
};
let mut candidates: Vec<Target> = Vec::new();
let mut source_candidates: Vec<Target> = Vec::new();
match (s.kind, self.command_source) {
// @s: the executing entity (none for a server source).
('s', Some(source)) => candidates.extend(source.entity),
('s', None) => candidates.extend(self.source_entity()),
('s', Some(source)) => source_candidates.extend(source.entity),
('s', None) => source_candidates.extend(self.source_entity()),
// A guest in place is among the players, in join order.
_ if self.player.is_some() && self.lan_acting.is_none() => {
candidates.push(Target::Player);
source_candidates.push(Target::Player);
}
_ => {}
}
if s.kind != 's' {
use horizon_client::projectiles::ProjectileKind as K;
candidates.extend(self.player_candidates(s));
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend((0..self.orbs.len()).map(Target::Orb));
candidates.extend(
self.creatures
.projectiles
.iter()
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
source_candidates.extend(self.player_candidates(s));
}
let source_dim = self.dim;
// A selector with a spatial constraint reaches the level the command
// runs in only; an unbounded one reaches every loaded level
// (EntitySelector.findEntities).
let dims = if s.world_limited() {
vec![source_dim]
} else {
self.loaded_dims()
};
let mut out: Vec<(Target, f64)> = Vec::new();
for dim in dims {
let back = self.enter_command_level(dim);
let mut candidates = Vec::new();
if dim == source_dim {
candidates.append(&mut source_candidates);
}
if s.kind != 's' {
candidates.extend(self.level_candidates());
}
out.extend(self.select_in_level(candidates, s, origin));
if let Some(back) = back {
self.enter_command_level(back);
}
}
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The candidates of the level in place that pass the selector
/// (EntitySelector.getPredicate).
fn select_in_level(
&mut self,
candidates: Vec<Target>,
s: &Selector,
origin: [f64; 3],
) -> Vec<(Target, f64)> {
let level = self.experience.level;
let creative = self.creative;
let passing = self.selector_extra_filter(&candidates, s);
let mut out: Vec<(Target, f64)> = candidates
candidates
.into_iter()
.filter_map(|t| {
let p = self.target_position(t)?;
@@ -627,23 +669,102 @@ impl Game<'_> {
}
Some((t, d2))
})
.collect();
match s.sort {
Sort::Nearest => out.sort_by(|a, b| a.1.total_cmp(&b.1)),
Sort::Furthest => out.sort_by(|a, b| b.1.total_cmp(&a.1)),
Sort::Random => {
for i in (1..out.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
out.swap(i, j);
}
}
Sort::Arbitrary => {}
}
out.into_iter()
.take(s.limit.max(1))
.map(|(t, _)| t)
.collect()
}
/// The entities of the level in place a selector can find.
fn level_candidates(&self) -> Vec<Target> {
use horizon_client::projectiles::ProjectileKind as K;
let mut candidates = Vec::new();
candidates.extend(
self.mobs
.iter()
.filter(|m| m.death_time == 0)
.map(|m| Target::Mob(m.id)),
);
candidates.extend(self.dropped.iter().map(|i| Target::Item(i.id)));
candidates.extend(self.orbs.iter().map(|o| Target::Orb(o.id)));
candidates.extend(
self.creatures
.projectiles
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|p| Target::Projectile(p.id)),
);
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id)));
candidates.extend(self.boats.iter().map(|b| Target::Boat(b.id)));
candidates.extend(self.carts.iter().map(|c| Target::Cart(c.id)));
candidates.extend(self.others.iter().map(|o| Target::Other(o.id)));
// The End crystals and the dragon (EndCrystal and EnderDragon
// are entities of their own).
candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id)));
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates
}
/// The loaded levels, in the order the server holds them: the
/// overworld, the nether, the end (MinecraftServer.getAllLevels).
fn loaded_dims(&self) -> Vec<Dim> {
let mut dims = vec![self.dim];
dims.extend(self.levels.parked_dims());
dims.sort_by_key(|d| match d {
Dim::Overworld => 0,
Dim::Nether => 1,
Dim::End => 2,
});
dims
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
// A player is in no level of its own.
Target::Player | Target::Guest(_) => true,
Target::Mob(id) => self.mobs.iter().any(|m| m.id == id),
Target::Item(id) => self.dropped.iter().any(|i| i.id == id),
Target::Orb(id) => self.orbs.iter().any(|o| o.id == id),
Target::Projectile(id) => self.creatures.projectiles.iter().any(|p| p.id == id),
Target::Stand(id) => self.armor_stands.iter().any(|a| a.id == id),
Target::Frame(id) => self.item_frames.iter().any(|f| f.id == id),
Target::Painting(id) => self.paintings.iter().any(|p| p.id == id),
Target::Boat(id) => self.boats.iter().any(|b| b.id == id),
Target::Cart(id) => self.carts.iter().any(|c| c.id == id),
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
/// (the entity ids are unique across the loaded levels) and put the
/// level back afterwards. `f` does not run when no level holds the
/// entity.
pub(in crate::game) fn in_target_level<R>(
&mut self,
t: Target,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
if self.level_holds(t) {
return Some(f(self));
}
for dim in self.levels.parked_dims() {
let back = self.enter_command_level(dim);
if self.level_holds(t) {
let r = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
return Some(r);
}
if let Some(back) = back {
self.enter_command_level(back);
}
}
None
}
fn entity_tag_matches(&self, tag: &str, ty: &str) -> bool {
let path = self.options.resources.join(format!(
"data/minecraft/tags/entity_type/{}.json",
@@ -1103,6 +1224,10 @@ impl Game<'_> {
Ok(targets.len() as i32)
}
fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1151,14 +1276,14 @@ impl Game<'_> {
i.previous = v;
}
}
Target::Orb(i) => {
if let Some(o) = self.orbs.get_mut(i) {
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.position = v;
o.previous = v;
}
}
Target::Projectile(i) => {
if let Some(a) = self.creatures.projectiles.get_mut(i) {
Target::Projectile(id) => {
if let Some(a) = self.creatures.projectiles.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
}
@@ -1198,6 +1323,17 @@ impl Game<'_> {
}
}
}
Target::Crystal(id) => {
if let Some(c) = self.crystals.iter_mut().find(|c| c.id == id) {
c.position = v;
}
}
Target::Dragon(id) => {
if let Some(d) = self.dragon.as_mut().filter(|d| d.id == id) {
d.position = v;
d.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1557,6 +1693,10 @@ impl Game<'_> {
Ok(duration.unwrap_or(-1))
}
fn kill_target(&mut self, t: Target) {
// An entity in another level is killed in the level that holds it.
let _ = self.in_target_level(t, |g| g.kill_target_in_level(t));
}
fn kill_target_in_level(&mut self, t: Target) {
match t {
Target::Guest(_) => {
self.for_player(t, |g| g.kill_target(Target::Player));
@@ -1576,16 +1716,13 @@ impl Game<'_> {
}
}
Target::Item(id) => self.dropped.retain(|i| i.id != id),
Target::Orb(i) => {
if i < self.orbs.len() {
self.orbs[i].age = u32::MAX / 2;
Target::Orb(id) => {
if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) {
o.age = u32::MAX / 2;
}
}
// cmd_kill goes from the highest index down.
Target::Projectile(i) => {
if i < self.creatures.projectiles.len() {
self.creatures.projectiles.remove(i);
}
Target::Projectile(id) => {
self.creatures.projectiles.retain(|p| p.id != id);
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
@@ -1593,7 +1730,11 @@ impl Game<'_> {
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
self.orbs.retain(|o| o.age < u32::MAX / 2);
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -1601,19 +1742,18 @@ impl Game<'_> {
} else {
vec![self.implied_player()]
};
let names: Vec<Text> = targets.iter().map(|&t| self.target_name(t)).collect();
// Remove indexed entities from the back so earlier indices stay valid.
let mut ordered = targets.clone();
ordered.sort_by_key(|t| {
std::cmp::Reverse(match t {
Target::Orb(i) | Target::Projectile(i) => *i,
_ => 0,
})
});
for t in ordered {
let mut names: Vec<Text> = Vec::new();
for &t in &targets {
// The name of an entity in another level is read there.
let name = match self.in_target_level(t, |g| g.target_name(t)) {
Some(name) => name,
None => self.target_name(t),
};
names.push(name);
}
for &t in &targets {
self.kill_target(t);
}
self.orbs.retain(|o| o.age < u32::MAX / 2);
let message = if targets.len() == 1 {
Text::translate("commands.kill.success.single", &[names[0].clone()])
} else {
@@ -1705,6 +1845,20 @@ impl Game<'_> {
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
"end_crystal" => {
// EndCrystal's constructor takes an id from the entity
// counter, and the crystal is shown with its base.
self.next_entity += 1;
self.crystals.push(super::dragon::Crystal {
id: self.next_entity,
position: Vec3::new(pos[0], pos[1], pos[2]),
show_bottom: true,
time: self.world.random.next_int_bound(100_000).unwrap_or(0),
beam: None,
invulnerable: false,
});
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
@@ -31,8 +31,13 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
impl Game<'_> {
// ---- entity data ---------------------------------------------------
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved.
/// `NbtPredicate.getEntityTagToCompare`: the entity as saved. An entity
/// in another level is read in the level that holds it.
pub(in crate::game) fn entity_nbt(&mut self, t: Target) -> Option<Tag> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.level.player_uuid;
Some(match t {
Target::Guest(_) => {
@@ -113,9 +118,11 @@ impl Game<'_> {
let o = self.others.iter().find(|o| o.id == id)?.clone();
self.other_to_nbt(&o, uuid)
}
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
Target::Projectile(i) => {
let p = self.creatures.projectiles.get(i)?;
Target::Orb(id) => {
entity_nbt::orb_to_nbt(self.orbs.iter().find(|o| o.id == id)?, random_uuid())
}
Target::Projectile(id) => {
let p = self.creatures.projectiles.iter().find(|p| p.id == id)?;
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
player_uuid
} else {
@@ -129,6 +136,16 @@ impl Game<'_> {
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:end_crystal", c.position);
c.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Dragon(id) => {
let d = self.dragon.as_ref().filter(|d| d.id == id)?;
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
@@ -397,6 +414,19 @@ impl Game<'_> {
}
pub(super) fn cmd_data(&mut self, id: &str, ctx: &Context) -> R<i32> {
let target = self.data_ref(ctx, 0)?;
// An entity in another level is read and written in the level that
// holds it.
if let DataRef::Entity(t) = &target {
let t = *t;
let target = target.clone();
return match self.in_target_level(t, |g| g.cmd_data_in(id, ctx, target)) {
Some(r) => r,
None => Err(err("argument.entity.notfound.entity", &[])),
};
}
self.cmd_data_in(id, ctx, target)
}
fn cmd_data_in(&mut self, id: &str, ctx: &Context, target: DataRef) -> R<i32> {
match id {
"data.merge" => {
let Some(Arg::Str(nbt)) = ctx.get("nbt") else {
@@ -1,11 +1,10 @@
//! `/effect` (EffectCommands) and `/enchant` (EnchantCommand).
use super::{Arg, Context, Game, R, Target, err, texts};
use horizon_client::{
brewing,
brewing::{self, namespaced},
effects::EffectInstance,
text::{self, Style, Text},
};
use std::{collections::HashMap, sync::OnceLock};
/// `CommandResponseTracker` over targets: elements with a non-zero result.
#[derive(Default)]
@@ -21,24 +20,6 @@ impl Tracker {
}
}
}
/// Namespaced effect ids for mob effect storage.
fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
IDS.get_or_init(|| {
brewing::EFFECTS
.iter()
.map(|e| {
(
e.id,
&*Box::leak(format!("minecraft:{}", e.id).into_boxed_str()),
)
})
.collect()
})
.get(id)
.copied()
.unwrap_or(id)
}
fn effect_name(id: &str) -> Text {
Text::translate(&format!("effect.minecraft.{id}"), &[])
}
@@ -663,10 +663,11 @@ impl Game<'_> {
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(i) => self
Target::Projectile(id) => self
.creatures
.projectiles
.get(i)
.iter()
.find(|p| p.id == id)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
@@ -907,10 +908,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);
@@ -119,6 +119,7 @@ impl Game<'_> {
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
+21
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,6 +67,17 @@ 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>,
) {
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
&& 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
+199 -10
View File
@@ -46,10 +46,22 @@ 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>,
@@ -198,6 +210,7 @@ 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.
@@ -679,6 +692,7 @@ impl Game<'_> {
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_pickups();
@@ -928,11 +942,29 @@ impl Game<'_> {
self.spawn_item(d, at, [0.0, 0.2, 0.0], 10);
}
}
/// Adds a projectile, giving it an id from the entity counter
/// (`Entity.id`, the same counter as the rest) when it has none.
pub(super) fn add_projectile(&mut self, mut p: horizon_client::projectiles::Projectile) {
if p.id == 0 {
self.next_entity += 1;
p.id = self.next_entity;
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
self.lan_entity_event(mob as i32, super::combat::DROWN_EVENT, at);
let v = self
.mobs
.iter()
.find(|m| m.id == mob)
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -980,6 +1012,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.
@@ -1119,7 +1166,7 @@ impl Game<'_> {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.creatures.projectiles.push(p);
self.add_projectile(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
MobEvent::Spawn(mut m) => {
@@ -1920,6 +1967,7 @@ impl Game<'_> {
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut arrow_events = Vec::new();
let mut trails = Vec::new();
{
let mut targets: Vec<(Who, Aabb)> = self
.mobs
@@ -2011,6 +2059,10 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = Some(Who::Player);
}
// AbstractHurtingProjectile.tick ends with createParticleTrail.
if super::remote_projectiles::has_smoke_trail(&p.kind) {
trails.push(p.position);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -2053,6 +2105,7 @@ impl Game<'_> {
keep
});
}
self.smoke_trails(trails);
for at in deflections {
self.audio
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
@@ -2085,6 +2138,7 @@ 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;
@@ -2217,11 +2271,9 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's
/// precipitation is not checked here).
/// `Level.isRainingAt` for arrows.
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
let raining = self.world.weather.levels(1.0).0 > 0.2;
move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15
move |p: BlockPos| self.raining_at(p)
}
/// The level as arrows see it.
fn arrow_scene<'s>(
@@ -2259,7 +2311,7 @@ impl Game<'_> {
}
}
if keep {
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
}
}
/// `TargetBlock.onProjectileHit`: a target block takes its power from
@@ -2853,6 +2905,14 @@ 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);
@@ -2908,6 +2968,16 @@ 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;
@@ -3168,6 +3238,19 @@ impl Game<'_> {
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,
);
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3255,6 +3338,50 @@ 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;
@@ -3309,7 +3436,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.creatures.projectiles.push(proj);
self.add_projectile(proj);
self.player_projectile_shot();
// ItemStack.use's applyAfterUseComponentSideEffects: the pearl's
// second is the `use_cooldown` component, as for any thrown item.
@@ -3402,11 +3529,65 @@ impl Game<'_> {
}
}
}
/// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian
/// that swims along, and bubbles up the beam to its target. The server
/// never draws them, so the mob's own random stays untouched.
pub(super) fn guardian_bubbles(&mut self) {
use horizon_client::mobs::guardian;
use horizon_client::particles::{ParticleOptions, ParticleType};
let mut swimming = Vec::new();
let mut beams = Vec::new();
for m in &self.mobs {
if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) {
continue;
}
let at = [m.position.x, m.position.y, m.position.z];
if m.extra.flag2 && m.fluids.in_water {
let (width, height, _) = m.dims();
swimming.push((at, width, height, m.view_vector()));
}
if let Some((_, scale)) = guardian::beam(m, 0.0)
&& let Some(t) = self.beam_target(m.ai.target, 1.0)
{
beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale)));
}
}
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for (at, width, height, view) in swimming {
for _ in 0..2 {
let (x, y, z) = {
let r = self.particles.random();
(r.next_double(), r.next_double(), r.next_double())
};
let p = [
at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5,
at[1] + height * y - view[1] * 1.5,
at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5,
];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
for (eye, target, scale) in beams {
let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if length <= 0.0 {
continue;
}
let mut dist = self.particles.random().next_double();
while dist < length {
let r = self.particles.random();
dist += 1.8 - scale + r.next_double() * (1.7 - scale);
let f = dist / length;
let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
}
/// Mob models and projectiles for the entity pass.
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.
@@ -3415,9 +3596,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;
}
@@ -3703,6 +3891,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
}
}
+1 -1
View File
@@ -222,7 +222,7 @@ impl Game<'_> {
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.creatures.projectiles.push(arrow);
self.add_projectile(arrow);
}
self.player_projectile_shot();
let pitch = if index == 0 {
+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;
}
+165 -2
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::Axis;
use horizon_core::geometry::{Axis, Vec3};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,7 +766,8 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -796,6 +797,159 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -828,3 +982,12 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
+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,6 +120,11 @@ 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
+1 -1
View File
@@ -1034,7 +1034,7 @@ impl Game<'_> {
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot(&mut own, dir, power, uncertainty);
self.creatures.projectiles.push(shot);
self.add_projectile(shot);
}
}
}
+51 -11
View File
@@ -291,11 +291,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
creative: bool,
spectator: bool,
}
@@ -693,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.
fn fight_players(&self) -> Vec<FightPlayer> {
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -722,7 +722,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -828,8 +828,10 @@ impl Game<'_> {
continue;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter.
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: at,
show_bottom: true,
time,
@@ -1566,6 +1568,7 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -2078,6 +2081,7 @@ 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.
@@ -2115,6 +2119,10 @@ 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;
@@ -2262,8 +2270,22 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
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
}
}
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
@@ -2840,8 +2862,11 @@ impl Game<'_> {
return false;
}
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
// EndCrystal's constructor takes an id from the entity counter, so
// the crystal is an entity a command can name (`@e[type=end_crystal]`).
self.next_entity += 1;
self.crystals.push(Crystal {
id: 0,
id: self.next_entity,
position: Vec3::new(x + 0.5, y, z + 0.5),
show_bottom: false,
time,
@@ -2858,6 +2883,21 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `EndCrystal.kill` (`/kill`): the crystal is removed, without a blast
/// (`Entity.kill` does not hurt it).
pub(super) fn kill_crystal(&mut self, id: u32) {
if let Some(i) = self.crystals.iter().position(|c| c.id == id) {
self.destroy_crystal(i, None, CrystalDamage::Explosion);
}
}
/// `EnderDragon.kill` (`/kill`): `Entity.kill` removes the entity, it
/// does not hurt it (the dragon flies its death flight when its health
/// runs out).
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
self.dragon = None;
}
}
/// Any entity touching `room` (`Level.getEntities(null, box)`).
fn entity_in(&self, room: Aabb) -> bool {
let touches = |lo: Vec3, hi: Vec3| {
+11 -4
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;
@@ -253,10 +253,17 @@ impl Game<'_> {
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
}
}
@@ -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 -1
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,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
life: i32,
lifetime: i32,
+6 -4
View File
@@ -30,8 +30,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -433,6 +433,8 @@ 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) {
@@ -770,8 +772,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
}
impl Hook {
+4 -3
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);
@@ -456,7 +457,7 @@ impl Game<'_> {
}
for (id, m, light) in maps {
let entity_start = out.len();
self.map_vertices(id, m, light, out);
self.map_vertices(id, true, m, light, out);
self.cull_entity_vertices(out, entity_start);
}
}
@@ -79,6 +79,7 @@ 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 {
+65 -4
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,
@@ -411,6 +412,26 @@ fn mob_meta(
}
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
fn mob_freeze_meta(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.ticks_frozen > 0 {
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
}
defaults.push((7, serializer::INT, Value::Int(0)));
if m.kind.id == "minecraft:strider" {
if m.extra.flag {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
/// off hand, feet, legs, chest, head, body, saddle).
fn mob_equipment(
@@ -419,13 +440,16 @@ fn mob_equipment(
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(5, &e.head),
(if body { 6 } else { 5 }, &e.head),
];
for (slot, item) in slots {
if let Some(item) = item {
@@ -715,6 +739,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(
@@ -928,6 +953,7 @@ 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);
mob_freeze_meta(m, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -1004,6 +1030,9 @@ 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
@@ -1164,8 +1193,11 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
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(
@@ -1267,8 +1299,9 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
// (these levelEvent calls name no excluded player), nor
// the digger's own crack and hit sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
None
} else {
self.lan_acting
@@ -1330,6 +1363,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
@@ -1491,6 +1551,7 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
+72 -24
View File
@@ -165,6 +165,8 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
/// How many projectiles there were: those added since are the guest's.
@@ -265,8 +267,10 @@ 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.
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -389,6 +393,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(
@@ -402,6 +407,12 @@ 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,
@@ -449,6 +460,7 @@ impl Game<'_> {
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -508,6 +520,7 @@ impl Game<'_> {
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -529,6 +542,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(
@@ -1200,6 +1224,7 @@ impl Game<'_> {
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::SetCommandBlock(p) => self.lan_set_command_block(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
@@ -1524,9 +1549,13 @@ 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.stop_sprint_after_extra_knockback());
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
}
return;
}
@@ -1831,10 +1860,6 @@ 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,
@@ -1916,21 +1941,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;
@@ -1967,6 +1979,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];
@@ -2188,7 +2218,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
@@ -2257,6 +2287,7 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
@@ -2273,6 +2304,8 @@ impl Game<'_> {
game.cauldron_extinguish(*pos);
}
}
// Entity.baseTick: rain puts out a burning player.
game.rain_extinguish_player();
// WitherRoseBlock.entityInside along those moves.
for event in &events {
if matches!(event, MotionEvent::InsideWitherRose) {
@@ -2311,6 +2344,13 @@ 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)
@@ -2333,6 +2373,15 @@ impl Game<'_> {
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
MotionEvent::Drown => {
if let Some(g) = host.hub.guest(conn) {
host.hub
.entity_event(g.entity_id, super::combat::DROWN_EVENT, at);
}
self.drown_particles(at, [0.0; 3]);
}
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
@@ -2668,8 +2717,7 @@ impl Game<'_> {
/// with Mending and gives the rest as points. Everyone near hears it.
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
// Player's standing and crouching boxes: 1.8 and 1.5 high.
let height = if g.sneaking { 1.5 } else { 1.8 };
let height = f64::from(g.dimensions().height);
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
@@ -342,6 +342,7 @@ 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,
@@ -466,7 +467,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -484,10 +485,14 @@ 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.age < 20 {
if b.waiting() {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
+7 -11
View File
@@ -175,9 +175,9 @@ impl Game<'_> {
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead && !g.spectator())
.map(|g| {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
([x - 0.3, y, z - 0.3], [x + 0.3, y + height, z + 0.3])
let b = g.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
})
.collect()
}
@@ -319,7 +319,7 @@ impl Game<'_> {
uuid: horizon_client::lan::playerdata::uuid_ints(g.uuid),
position: g.position,
eye: [x, y + g.eye_height(), z],
height: if g.sneaking { 1.5 } else { 1.8 },
height: g.dimensions().height,
creative: g.creative(),
spectator: g.spectator(),
alive: !g.dead,
@@ -336,14 +336,9 @@ impl Game<'_> {
}
}
/// A guest's bounding box (0.6 wide, 1.8 tall, 1.5 crouching).
/// A guest's bounding box, by its pose (`Avatar.POSES`).
pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
)
g.bounds()
}
#[cfg(test)]
@@ -374,6 +369,7 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
passenger: false,
}
}
@@ -318,16 +318,86 @@ 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(
&self,
&mut 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 {
@@ -341,6 +411,15 @@ 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
+15 -8
View File
@@ -86,9 +86,11 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
}
self.lan = Some(host);
true
@@ -106,8 +108,18 @@ 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, the exhaustion and the swing.
/// in): the damage, the charge reset 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];
@@ -116,11 +128,6 @@ 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,
+2 -6
View File
@@ -5,14 +5,10 @@
//! forcing the game mode. Its difficulty lock asks first (PopupScreen), and
//! Restrictions opens RestrictionsScreen.
//! Parity reference: https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/WorldOptionsScreen
use super::{
Game, Screen,
game_rules_screen::{Exit, RulesScreen},
integrated::ServerOptions,
lan::LanSettings,
};
use super::{Game, Screen, integrated::ServerOptions, lan::LanSettings};
use horizon_client::{
font::Font,
game_rules_screen::{Exit, RulesScreen},
hud::{HudBatch, rgba},
text::{Text, named_color},
ui::{self, EditBox},
@@ -277,7 +277,12 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
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));
}
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);
@@ -15,6 +15,12 @@ impl Game<'_> {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
/// shell's old and new cells.
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
@@ -310,6 +316,30 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
/// `play_sound`) and one crumb on the face being mined; nothing for air.
pub(super) fn breaking_block_effects(
&mut self,
pos: [i32; 3],
face: [i32; 3],
play_sound: bool,
) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if self.res.facts().is_air(state) {
return;
}
if play_sound {
let sounds = self.res.facts().get(state).sounds.clone();
// Each guest plays it from the level event itself.
self.audio.play_unshared(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
self.crack_particle(pos, face, state);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
+4
View File
@@ -120,6 +120,10 @@ impl Levels {
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The levels that are not in place, by dimension.
pub(super) fn parked_dims(&self) -> Vec<Dim> {
self.parked.keys().copied().collect()
}
}
impl Game<'_> {
+18 -3
View File
@@ -324,6 +324,13 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers.
fn shown_on_frame(sprite: &str) -> bool {
!matches!(
decoration_type(sprite),
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
)
}
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
@@ -1114,9 +1121,12 @@ impl Game<'_> {
}
/// `MapRenderer.render`: the map texture and its decorations under `m`
/// (map pixels 0..128, y down), drawn at depth offsets toward the viewer.
/// `show_only_frame` (a map in an item frame) leaves out the decorations that
/// do not show on frames.
pub(super) fn map_vertices(
&mut self,
id: i32,
show_only_frame: bool,
m: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
@@ -1158,14 +1168,18 @@ impl Game<'_> {
.get(&id)
.map(|m| m.decorations.clone())
.unwrap_or_default();
for (i, d) in decorations.iter().enumerate() {
let mut count = 0;
for d in &decorations {
if show_only_frame && !shown_on_frame(d.sprite) {
continue;
}
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
continue;
};
let dm = glam::Mat4::from_translation(glam::Vec3::new(
f32::from(d.x) / 2.0 + 64.0,
f32::from(d.y) / 2.0 + 64.0,
-0.02 - i as f32 * 0.001,
-0.02 - count as f32 * 0.001,
)) * glam::Mat4::from_rotation_z(
(f32::from(d.rot) * 360.0 / 16.0).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(4.0))
@@ -1187,6 +1201,7 @@ impl Game<'_> {
[p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)],
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
);
count += 1;
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
@@ -1255,7 +1270,7 @@ impl Game<'_> {
}
}
if has_data {
self.map_vertices(id, m, light, out);
self.map_vertices(id, false, m, light, out);
}
}
}
+86 -24
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::ItemStack,
items::{ItemStack, Items},
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,6 +201,17 @@ 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>,
@@ -536,6 +550,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);
@@ -713,12 +728,7 @@ impl Game<'_> {
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
}) {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
let bb = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
pushers.push((Vec3::new(x, y, z), bb));
pushers.push((Vec3::new(x, y, z), g.bounds()));
}
}
for (feet, bb) in pushers {
@@ -946,7 +956,7 @@ impl Game<'_> {
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
pub(super) fn dismount_cart(&mut self, i: usize) {
self.riding = None;
let c = &self.carts[i];
@@ -956,17 +966,25 @@ 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 [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
for (dx, dz) in offsets {
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));
@@ -1050,6 +1068,22 @@ 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 {
@@ -1067,10 +1101,8 @@ 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
@@ -1340,9 +1372,8 @@ impl Game<'_> {
return;
};
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
// edge, scaled by the entity distance option.
let range =
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
@@ -1509,6 +1540,10 @@ impl Minecart {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
@@ -1558,6 +1593,7 @@ impl Minecart {
_ => 0.0,
};
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
@@ -1582,4 +1618,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,
})
}
}
+13
View File
@@ -126,6 +126,19 @@ impl Game<'_> {
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
return false;
}
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
// seat, inventory or feeding to give. Camels seat two and check their
// passengers themselves.
let id = m.id;
if !matches!(species, Species::Camel | Species::CamelHusk)
&& self
.mobs
.iter()
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
{
return false;
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
+3 -1
View File
@@ -338,7 +338,9 @@ impl Game<'_> {
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
let chars: Vec<(char, u32)> = text
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
.spans
.iter()
.flat_map(|s| {
+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);
}
}
+121 -35
View File
@@ -1,14 +1,23 @@
//! Resource pack selection bound to the game: the pack screen lists, and a
//! full resource reload after a change, while playing.
use super::Game;
use super::{
Game, PackResources, Resources, UiResources, build_renderer, prepare_renderer_textures,
};
use horizon_client::{
assets::Assets,
gfx::PreparedTextures,
hud::HudAtlas,
lang,
pack_screen::{self, Compat, PackInfo, Source as PackSource},
packs::{self, Pack, Position},
streaming::WorkerContext,
text::{Style, Text},
};
use std::path::{Path, PathBuf};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
sync::Arc,
};
/// Where overlay roots are built (beside options.txt).
pub fn cache_dir() -> PathBuf {
@@ -34,6 +43,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(
@@ -94,7 +107,7 @@ pub(super) fn pack_infos(vanilla: &Path) -> Vec<PackInfo> {
infos
}
impl Game<'_> {
fn vanilla_root(&self) -> PathBuf {
pub(super) fn vanilla_root(&self) -> PathBuf {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.options.resources.clone()
} else {
@@ -134,35 +147,36 @@ impl Game<'_> {
self.ui.options.resource_error = Some(e.to_string());
}
}
/// Reload from the selected packs and the server's.
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
/// `apply_staged_pack` finishes the GPU half and the swap.
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let server = &self.server_packs.active;
let root = startup_root(&vanilla, &self.settings.resource_packs, server)?;
let root =
super::mods::content_root(&root, &self.settings.mods.enabled, &self.options.dev_mods);
self.reload_resources(&root)
stage_pack(
&vanilla,
&self.settings.resource_packs,
&self.server_packs.active,
&self.settings.mods.enabled,
&self.options.dev_mods,
&self.pack.assets,
&self.res,
&self.pack.context,
self.settings.force_unicode,
self.settings.japanese_glyphs,
&super::Progress::new(),
)
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let gui = super::UiResources {
atlas,
panorama: std::sync::Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
};
let pack = super::PackResources::load(
/// The render-thread half: the renderer on the new pack's textures, then
/// the swap (`ReloadInstance`'s application listeners).
pub(super) fn apply_staged_pack(&mut self, staged: StagedPack) -> horizon_client::Result<()> {
let StagedPack {
root,
self.pack.assets.clone(),
render,
self.res.facts(),
self.pack.context.biomes.clone(),
self.pack.context.light.clone(),
)?;
gui,
pack,
textures,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
@@ -170,7 +184,17 @@ impl Game<'_> {
let renderer = match self
.window
.clone()
.map(|window| self.renderer_for(window, &pack, &gui))
.map(|window| {
build_renderer(
window,
self.options.offscreen,
&pack,
&gui,
self.renderer.as_ref(),
Some(textures),
self.settings.horizon.api,
)
})
.transpose()
{
Ok(renderer) => renderer,
@@ -186,18 +210,18 @@ impl Game<'_> {
}
};
// Everything loaded: switch over.
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
self.armor_stand_assets.reload_trims(root);
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(&root);
self.armor_stand_assets.reload_trims(&root);
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.to_path_buf();
self.options.resources = root.clone();
let context = pack.context.clone();
self.pack = pack;
self.gui = gui;
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
.reload(root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
.reload(&root, &self.gui.atlas.sprites, &self.gui.atlas.translucent);
horizon_client::ui_theme::reset_atlas();
// Villager hats come from the pack's texture metadata (shared data, swapped in place).
*self
@@ -206,11 +230,11 @@ impl Game<'_> {
.villager_hats
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
horizon_client::mobs::villager_hats::VillagerHats::load(root);
horizon_client::ui::load_metadata(root);
lang::set(lang::Language::load(&[&root.to_path_buf()], &lang::code()));
horizon_client::mobs::villager_hats::VillagerHats::load(&root);
horizon_client::ui::load_metadata(&root);
lang::set(lang::Language::load(&[&root], &lang::code()));
self.refresh_remote_language();
self.audio.reload(root);
self.audio.reload(&root);
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
@@ -225,4 +249,66 @@ impl Game<'_> {
self.creative_screen.forget_items();
Ok(())
}
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
/// applied (`PackReload` runs the same two phases across frames).
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let staged = self.stage_selected_packs()?;
self.apply_staged_pack(staged)
}
}
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
/// root, the models and sprites, the menu bundle, the pack's tables and its
/// CPU mip chains.
pub(super) fn stage_pack(
vanilla_root: &Path,
selected: &[String],
server: &[Pack],
mods_enabled: &BTreeSet<String>,
dev_mods: &[PathBuf],
assets: &Arc<Assets>,
res: &Resources,
context: &WorkerContext,
force_unicode: bool,
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let root = startup_root(vanilla_root, selected, server)?;
let root = super::mods::content_root(&root, mods_enabled, dev_mods);
progress.set(1);
let render = Arc::new(assets.reload(&root)?);
let mut atlas = HudAtlas::build_over(&root, vanilla_root)?;
atlas.font.uniform = force_unicode;
atlas.font.japanese = japanese;
let gui = UiResources {
atlas,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(&root)),
};
progress.set(2);
let pack = PackResources::load(
&root,
assets.clone(),
render,
res.facts(),
context.biomes.clone(),
context.light.clone(),
)?;
let textures = prepare_renderer_textures(&pack);
progress.set(3);
Ok(StagedPack {
root,
gui,
pack,
textures,
})
}
/// Everything a pack reload builds on the CPU (`apply_staged_pack` finishes
/// it on the render thread).
pub(super) struct StagedPack {
pub root: PathBuf,
pub gui: UiResources,
pub pack: PackResources,
pub textures: PreparedTextures,
}
+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;
}
+20 -4
View File
@@ -193,12 +193,12 @@ impl Game<'_> {
if let Some(a) = p.arrow.as_mut() {
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
}
self.creatures.projectiles.push(p);
self.add_projectile(p);
}
}
"minecraft:fireball" | "minecraft:small_fireball" => {
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.creatures.projectiles.push(p);
self.add_projectile(p);
}
}
"minecraft:falling_block" => {
@@ -290,14 +290,22 @@ impl Game<'_> {
"minecraft:ender_dragon" => {
let seed = self.world.random.next_long();
if self.dragon.is_none()
&& let Some(d) = super::dragon::Dragon::from_nbt(&tag, seed)
&& let Some(mut d) = super::dragon::Dragon::from_nbt(&tag, seed)
{
// EnderDragon's constructor takes its id and its
// parts' from the entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
self.dragon = Some(d);
}
}
"minecraft:end_crystal" => {
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
if let Some(c) = super::dragon::Crystal::from_nbt(&tag, time) {
if let Some(mut c) = super::dragon::Crystal::from_nbt(&tag, time) {
// EndCrystal's constructor takes an id from the
// entity counter.
self.next_entity += 1;
c.id = self.next_entity;
self.crystals.push(c);
}
}
@@ -533,6 +541,14 @@ impl Game<'_> {
self.persist.uuids.remove(&id);
self.persist.raw.remove(&id);
}
/// The compound an entity without a Horizon entity ID was loaded with
/// (the crystals and the dragon), found again by type and position.
pub(super) fn persist_anon(&self, kind: &str, p: Vec3) -> (Option<[i32; 4]>, Option<&Tag>) {
self.persist
.anon
.get(&anon_key(kind, p))
.map_or((None, None), |(_, uuid, raw)| (*uuid, Some(raw)))
}
/// The compound an entity was loaded with (and commands have edited).
pub(super) fn persist_raw(&self, id: u32) -> Option<&Tag> {
self.persist.raw.get(&id)
+11 -2
View File
@@ -218,11 +218,20 @@ impl Game<'_> {
_ => None,
})
});
// A living mount at the default scale and camera distance.
// Camera.setup: the living mount's scale and camera distance
// attributes (the happy ghast's is 8, the default 4).
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|_| (1.0, 4.0));
.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),
)
});
// 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
+287 -47
View File
@@ -2,7 +2,12 @@
//! `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, item_draw::owner_at};
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -12,9 +17,13 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -31,6 +40,35 @@ 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};
@@ -48,6 +86,39 @@ 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")
}
}
impl Game<'_> {
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
@@ -313,6 +384,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);
@@ -390,6 +462,7 @@ 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
@@ -405,8 +478,10 @@ impl Game<'_> {
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = player.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));
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
self.potions.effects = effects;
if raid_omen_added {
self.raid_omen_added();
@@ -547,29 +622,176 @@ impl Game<'_> {
let state =
self.world
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
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);
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)));
break;
}
}
if let Some(p) = hit {
landed.push((t.stack.clone(), p, t.owner.clone()));
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at, owner) in landed {
self.splash(&stack, at, owner);
for (stack, at, mob, owner) in landed {
self.splash(&stack, at, mob, 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);
}
}
}
}
}
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -671,12 +893,23 @@ 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,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
@@ -693,22 +926,10 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
self.apply_indirect_effect_action(a, source);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -719,8 +940,9 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob.
fn splash(&mut self, stack: &ItemStack, at: Vec3, mob: Option<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
@@ -744,23 +966,41 @@ 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 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));
// 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, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
@@ -783,7 +1023,7 @@ impl Game<'_> {
_ => {
// `Potion.applySplash`: timed effects last in
// proportion to how close the mob was.
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(name) = mob_effect_id(id)
{
+28 -2
View File
@@ -53,7 +53,11 @@ impl Game<'_> {
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
// Entity.playEntityOnFireExtinguishedSound
self.extinguish_sound(at);
Ok(())
}
/// Entity.playEntityOnFireExtinguishedSound
fn extinguish_sound(&mut self, at: [f64; 3]) {
let r = self.particles.random();
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
self.audio.play(
@@ -62,7 +66,29 @@ impl Game<'_> {
0.7,
pitch,
);
Ok(())
}
/// `Entity.applyEffectsFromBlocks`: a burning player in the rain
/// (`isInRain`) has the fire put out (`clearFire`), with the extinguish
/// sound that everyone near hears.
pub(super) fn rain_extinguish_player(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
let (at, top) = (player.position, player.bounds().max_position().y);
if player.fire_ticks <= 0 || !self.in_rain(at, top) {
return;
}
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
self.extinguish_sound([at.x, at.y, at.z]);
}
/// The in-window player's rain extinguish (a server's player has its
/// fire put out by the server).
pub(super) fn rain_extinguish_in_frame(&mut self) {
if self.remote.is_none() {
self.rain_extinguish_player();
}
}
/// Whether the block at `pos` is powder snow.
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
+21
View File
@@ -1036,6 +1036,7 @@ impl Game<'_> {
/// read it).
fn sync_villager_raids(&mut self, infos: &HashMap<i32, Vec<RaiderInfo>>) {
let raids = &self.raiding.raids;
let mut ongoing = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::Villager {
continue;
@@ -1061,7 +1062,27 @@ impl Game<'_> {
let ext = mobs_villager::ext_mut(m);
ext.raid = status;
ext.raid_wave = wave;
if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) {
ongoing.push(m.id);
}
}
// Villager.customServerAiStep: a 1 in 100 chance each tick, in an
// ongoing raid, of entity event 42 (`handleEntityEvent` splashes).
for id in ongoing {
if self.particles.random().next_int_bound(100) == Ok(0) {
self.mob_raid_splash(id);
}
}
}
/// Entity event 42 for villager `id`: the guests get the byte, and this
/// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself.
fn mob_raid_splash(&mut self, id: u32) {
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, 42, at);
self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER);
}
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
@@ -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()
+235 -75
View File
@@ -117,13 +117,17 @@ pub(super) struct Remote {
joined: bool,
status: String,
pub(super) entity_id: i32,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
dimension: DimensionType,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
view_distance: i32,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
@@ -189,6 +193,10 @@ pub(super) struct Remote {
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
@@ -212,6 +220,9 @@ pub(super) struct Remote {
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -394,6 +405,9 @@ impl Game<'_> {
self.carried = None;
self.using = None;
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
@@ -428,6 +442,7 @@ impl Game<'_> {
joined: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
@@ -473,6 +488,7 @@ impl Game<'_> {
destroying: None,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
death_message: None,
@@ -486,6 +502,7 @@ impl Game<'_> {
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
@@ -862,6 +879,11 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
@@ -1621,7 +1643,11 @@ impl Game<'_> {
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
self.selected = 0;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
@@ -1639,8 +1665,10 @@ impl Game<'_> {
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects: the server sends them again.
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
self.using = None;
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
@@ -1926,6 +1954,22 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
// Strider.DATA_SUFFOCATING (after AgeableMob's two
// and DATA_BOOST_TIME): a cold strider shakes.
(19, Value::Bool(cold))
if m.species() == horizon_client::mobs::Species::Strider =>
{
m.extra.flag = *cold;
}
_ => {}
}
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let mannequin = self
@@ -2024,13 +2068,22 @@ impl Game<'_> {
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>)> = p
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
.slots
.iter()
.map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item))))
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.collect();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (slot, item) in items {
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
@@ -2038,7 +2091,9 @@ impl Game<'_> {
2 => e.feet = item,
3 => e.legs = item,
4 => e.chest = item,
5 => e.head = item,
5 if !body => e.head = item,
6 if body => e.head = item,
7 => m.set_saddle(stack),
_ => {}
}
}
@@ -2157,6 +2212,9 @@ impl Game<'_> {
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
if p.event == super::combat::DROWN_EVENT {
self.remote_drown(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
@@ -2174,6 +2232,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
@@ -2336,10 +2399,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) => {
@@ -3171,12 +3235,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
@@ -3212,6 +3270,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();
@@ -3348,13 +3414,12 @@ impl Game<'_> {
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
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 in_water {
@@ -3368,6 +3433,14 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
horizon_client::motion::MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
// NetherPortalBlock.entityInside on the client's own level:
// the portal overlay and its trigger sound (the server moves
// the player).
@@ -3542,11 +3615,15 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut wither_heads = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
if m.species() == horizon_client::mobs::Species::Wither {
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -3619,7 +3696,7 @@ impl Game<'_> {
m.extra.main_hand.clone()
};
if let Some(item) = item {
let eye = if m.extra.crouching { 1.27 } else { 1.62 };
let eye = m.eye_height();
let at = [m.position.x, m.position.y, m.position.z];
using.push((
item,
@@ -3677,10 +3754,24 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
// WitherBoss.aiStep runs on a client too: smoke around each head.
let smoke = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Smoke,
);
for h in wither_heads {
let r = self.particles.random();
let at = [
h[0] + r.next_gaussian() * 0.3,
h[1] + r.next_gaussian() * 0.3,
h[2] + r.next_gaussian() * 0.3,
];
self.particles.add(&smoke, at, [0.0; 3]);
}
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
@@ -3935,6 +4026,9 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3947,20 +4041,6 @@ impl Game<'_> {
};
let done = done + progress;
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if ticks % 4 == 1 {
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&facts.sounds.hit,
Some(c),
(facts.sounds.volume + 1.0) / 8.0,
facts.sounds.pitch * 0.5,
);
}
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
@@ -4128,6 +4208,7 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
@@ -4359,13 +4440,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;
}
@@ -4437,14 +4532,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;
@@ -4469,20 +4575,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<()> {
@@ -4547,6 +4684,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;
}
@@ -4658,22 +4820,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)
}
@@ -4681,12 +4829,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 => {
@@ -4733,6 +4878,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.
@@ -17,6 +17,12 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
(6, Value::Id(pose))
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
{
m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose);
}
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
@@ -66,8 +66,11 @@ impl Game<'_> {
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Axolotl
| Species::Tadpole => item == "minecraft:water_bucket",
| 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::Armadillo => item == "minecraft:brush",
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
@@ -23,6 +23,58 @@ 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 {
@@ -77,8 +129,36 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
}
p.velocity.y -= 0.05;
}
/// A server's client ticks a projectile itself: it flies on between the
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
/// and a block stops it there; only the server decides what it hits.
pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
if !matches!(p.tick(w).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
/// but the wind charges (no trail) and the dragon's fireball (its own).
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
)
}
impl Game<'_> {
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for p in at {
self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
&mut self,
@@ -171,7 +251,7 @@ impl Game<'_> {
p.velocity = velocity;
p.yaw = yaw;
p.pitch = pitch;
self.creatures.projectiles.push(p);
self.add_projectile(p);
}
}
true
@@ -347,15 +427,19 @@ impl Game<'_> {
p.age += 1;
p.position = p.position.add_vector(p.velocity);
}
_ => {
if !matches!(p.tick(&world).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
_ => remote_projectile_tick(p, &world),
}
}
}
self.creatures.projectiles = projectiles;
let trails: Vec<Vec3> = self
.creatures
.projectiles
.iter()
.filter(|p| has_smoke_trail(&p.kind))
.map(|p| p.position)
.collect();
self.smoke_trails(trails);
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
@@ -414,52 +498,10 @@ impl Game<'_> {
}
}
/// `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).
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
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);
}
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
}
}
@@ -533,3 +575,68 @@ impl Game<'_> {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::mobs::Who;
use horizon_core::geometry::Aabb;
/// A world with nothing in it: the client tick of a projectile only
/// needs the blocks around it.
struct Empty;
impl ProjectileWorld for Empty {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Vec3) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
&[]
}
}
/// A thrown item, as a server's client tracks it: the spawn position and
/// the motion the server gave it.
fn thrown(kind: ProjectileKind) -> Projectile {
let mut p = Projectile::new(kind, Vec3::new(0.0, 100.0, 0.0), None);
p.velocity = Vec3::new(1.5, 0.0, 0.0);
p
}
#[test]
fn a_thrown_item_flies_on_between_the_server_updates() {
// A thrown egg, an ender pearl and a wind charge (#1762): the server
// sends their position every 10 ticks, and the client ticks them
// itself in between, as vanilla's no-op interpolation handler does.
for kind in [
ProjectileKind::Egg,
ProjectileKind::EnderPearl,
ProjectileKind::WindCharge,
] {
let mut p = thrown(kind);
remote_projectile_tick(&mut p, &Empty);
assert!(p.position.x > 1.0, "the projectile did not fly on: {p:?}");
// A second tick moves it further: it keeps flying, not a hop.
let first = p.position.x;
remote_projectile_tick(&mut p, &Empty);
assert!(p.position.x > first, "the projectile stopped: {p:?}");
}
}
#[test]
fn a_thrown_item_falls_and_drags_like_a_throwable_projectile() {
// ThrowableProjectile.tick: gravity 0.03, then the air drag 0.99.
let mut p = thrown(ProjectileKind::Egg);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5 * 0.99).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, "{p:?}");
// A wind charge keeps its speed: no gravity and no drag
// (AbstractWindCharge: acceleration 0, inertia 1).
let mut p = thrown(ProjectileKind::WindCharge);
remote_projectile_tick(&mut p, &Empty);
assert!((p.velocity.x - 1.5).abs() < 1e-9, "{p:?}");
assert!((p.velocity.y - 0.0).abs() < 1e-9, "{p:?}");
}
}
@@ -288,10 +288,11 @@ impl Game<'_> {
e[2].floor() as i32,
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
// The Horizon Weather option off means no rain visuals at all.
if rain > 0.0 && self.settings.horizon.weather {
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = 10;
let radius = self.settings.weather_radius;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
for _ in 0..count {
+8 -2
View File
@@ -139,8 +139,10 @@ impl Game<'_> {
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles are addressed by index and have no UUID here.
// Orbs and projectiles have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
}
}
@@ -188,7 +190,11 @@ impl Game<'_> {
| Target::Cart(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
Target::Guest(_)
| Target::Orb(_)
| Target::Projectile(_)
| Target::Crystal(_)
| Target::Dragon(_) => None,
};
match raw.and_then(|r| r.get("Tags")) {
Some(Tag::List(l)) => l
+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);
}
}
}
}
+2 -2
View File
@@ -1194,14 +1194,14 @@ impl Game<'_> {
/// `LightmapRenderStateExtractor`: the night vision effect's intensity
/// (`GameRenderer.nightVisionScale`), or conduit power's water vision.
pub(super) fn night_vision_intensity(&self) -> f32 {
pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 {
use horizon_client::fog::FogType;
if let Some(e) = self.potions.effects.get("night_vision") {
// MobEffectInstance.endsWithin(200); -1 is infinite.
return if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
};
}
if self.potions.effects.get("conduit_power").is_some()
@@ -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}");
}
}
}
+96 -16
View File
@@ -3,7 +3,7 @@
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::Game;
use super::{Game, Progress};
use horizon_client::{
Result,
confirm_screen::Confirm,
@@ -82,6 +82,9 @@ pub(super) struct ServerPackState {
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
/// frame draws its progress until `tick_server_packs` applies it.
pub(super) reload: Option<PackReload>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
@@ -191,6 +194,21 @@ fn saves() -> PathBuf {
std::env::current_dir().unwrap_or_default().join("saves")
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
"Loading assets",
"Loading pack tables",
"Applying resources",
];
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
/// worker while `progress` feeds the loading screen, then the apply lands
/// on the render thread.
pub(super) struct PackReload {
pub(super) progress: Progress,
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
}
impl Game<'_> {
/// `ConnectScreen`: the server's setting decides what its packs do
/// (`configureForServerControl`); single player's own server takes them
@@ -314,6 +332,7 @@ impl Game<'_> {
if self.rest_loader.is_some() || self.world_loader.is_some() {
return;
}
self.drive_pack_reload();
let finished = self
.server_packs
.queue
@@ -328,13 +347,8 @@ impl Game<'_> {
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
}
if let Some(mut load) = tick.reload {
while let Some(recovery) = {
let ok = self.reload_server_packs(&load);
self.server_packs.packs.reloaded(ok)
} {
load = recovery;
}
if let Some(load) = tick.reload {
self.start_pack_reload(&load);
}
}
for (id, status) in self.server_packs.packs.take_replies() {
@@ -379,24 +393,90 @@ impl Game<'_> {
}
}
/// `DownloadedPackSource.startReload`: the packs on top of the player's
/// own, then `Minecraft.reloadResourcePacks`. False when a pack has no
/// readable `pack.mcmeta` or the reload failed.
fn reload_server_packs(&mut self, load: &[(u128, PathBuf)]) -> bool {
let packs: Option<Vec<_>> = load
/// own, staged on a worker (`Minecraft.reloadResourcePacks` under a
/// `LoadingOverlay`); `drive_pack_reload` applies it. A pack with no
/// readable `pack.mcmeta` fails the reload at once, as `onFailure`.
fn start_pack_reload(&mut self, load: &[(u128, PathBuf)]) {
let server: Option<Vec<_>> = load
.iter()
.map(|(id, path)| packs::server_pack(*id, path))
.collect();
let Some(packs) = packs else {
let Some(server) = server else {
log::warn!("Horizon: invalid pack metadata in a server pack, ignoring all");
return false;
self.finish_pack_reload(false);
return;
};
self.server_packs.active = packs;
match self.reload_selected_packs() {
self.server_packs.active = server.clone();
// The overlay swallows input until the apply (`LoadingOverlay`):
// release what is held so nothing keeps acting while it covers the
// frame (`KeyMapping.releaseAll` when a screen opens).
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, mods, dev_mods) = (
self.settings.resource_packs.clone(),
self.settings.mods.enabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
self.pack.assets.clone(),
self.res.clone(),
self.pack.context.clone(),
);
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
let progress = Progress::with(&RELOAD_LINES);
let worker = progress.clone();
let handle = std::thread::Builder::new()
.name("server-pack-reload".into())
.spawn(move || {
super::packs::stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
unicode, japanese, &worker,
)
.map_err(|e| e.to_string())
});
match handle {
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
Err(e) => {
log::error!("Horizon: could not start the pack reload worker: {e}");
self.finish_pack_reload(false);
}
}
}
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
/// worker is done; on failure the packs active before reload again.
fn drive_pack_reload(&mut self) {
let Some(reload) = &self.server_packs.reload else {
return;
};
if !reload.handle.is_finished() {
return;
}
let reload = self.server_packs.reload.take().unwrap();
let staged = reload
.handle
.join()
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
let ok = match staged
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
{
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
};
self.finish_pack_reload(ok);
}
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
fn finish_pack_reload(&mut self, ok: bool) {
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
self.start_pack_reload(&recovery);
}
}
+153 -1
View File
@@ -5,7 +5,8 @@ use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
items::ItemStack,
signs::{DYE_TEXT, wood_type},
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -31,6 +32,157 @@ 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 {
+1 -1
View File
@@ -253,7 +253,7 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
fn mob_poof(&mut self, id: u32) {
pub(super) fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
@@ -546,6 +546,11 @@ impl Loading {
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
title.press_rules(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
);
self.menu_action(event_loop, action);
}
}
+22 -10
View File
@@ -256,9 +256,8 @@ impl Game<'_> {
.fluid
== horizon_client::blocks::Fluid::Water;
let on_ground = p.ground.on_ground;
let (sprinting, flying, gliding, swimming) = (
let (sprinting, gliding, swimming) = (
p.sprinting,
p.flying,
p.fall_flying,
p.pose == horizon_client::pose::Pose::Swimming,
);
@@ -272,15 +271,27 @@ impl Game<'_> {
});
let s = &mut self.stats;
match riding {
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(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(_) => {
Some(Err(())) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
} else {
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
s.custom("minecart_one_cm", total)
}
}
@@ -300,8 +311,9 @@ impl Game<'_> {
}
}
None if gliding => s.custom("aviate_one_cm", total),
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),
// 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 => {}
}
}
@@ -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;
}
+1 -1
View File
@@ -252,7 +252,7 @@ impl Game<'_> {
}
let range = super::entity_culling::default_range(
glam::DVec3::splat(PrimedTnt::SIZE),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tnts {
// EntityRenderer.shouldRender.
+24 -6
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talk_to_villager", 1);
self.stats.custom("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,18 +398,21 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let session = self.merchant_apply(session, mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
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.
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> Box<merchant::Session> {
) -> (Box<merchant::Session>, Vec<ItemStack>) {
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)) {
@@ -438,11 +441,26 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::TradeSound => {}
}
}
session
(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);
}
}
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+5 -5
View File
@@ -21,7 +21,7 @@ pub(super) struct ThrownTrident {
pub id: u32,
pub(super) stack: ItemStack,
pub(super) position: Vec3,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
pub(super) yaw: f32,
pub(super) pitch: f32,
@@ -112,7 +112,7 @@ impl Game<'_> {
}
true
}
fn in_water_or_rain(&self) -> bool {
fn in_water_or_rain(&mut self) -> bool {
let Some(p) = &self.player else { return false };
let (x, y, z) = (
p.position.x.floor() as i32,
@@ -121,8 +121,8 @@ impl Game<'_> {
);
let water = self.res.facts().get(self.world.block(x, y, z)).fluid
== horizon_client::blocks::Fluid::Water;
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
water || (self.world.weather.raining && sky)
let (at, top) = (p.position, p.bounds().max_position().y);
water || self.in_rain(at, top)
}
/// The living entities a spinning player can touch this tick, for
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
@@ -495,7 +495,7 @@ impl Game<'_> {
let range = 10.0
* super::entity_culling::default_range(
glam::DVec3::splat(2.0 * HALF),
self.settings.entity_distance,
self.view_scale(),
);
for t in &self.tridents {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more