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 MarieandClaude Opus 5.5 523280e781 Draw the new level-event sound pitches from the client level's random
LevelEventHandler plays 1501, 1502, 1046 and 1047 with
ClientLevel.getRandom(), not the server level's random: drawing them from
the world's random moved the level random in use_item_on's respawn
anchor cases (12 of 4000 differed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:07:42 +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 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 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 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
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 38cd3095e5 Merge main into fix/missing-level-events
Keeps both sets of level-event sounds: this branch's chorus flower and
dripstone-into-cauldron, and main's zombie door and potion splashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:02 +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 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 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
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
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
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
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
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
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
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
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
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
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 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
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
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
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 5c0f7142f3 Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst 2026-10-26 13:03:44 +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
133 changed files with 4816 additions and 786 deletions

No files matched your search

Generated
+7
View File
@@ -1405,6 +1405,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -3774,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"
+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).
@@ -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);
@@ -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,
@@ -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
+1 -1
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));
+106 -1
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)),
@@ -466,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();
@@ -900,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 {
@@ -1048,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 {
@@ -2229,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
+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>()
+310 -66
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;
@@ -571,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.
@@ -649,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).
@@ -709,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;
}
@@ -2050,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, _)| {
@@ -2080,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;
@@ -2575,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;
}
@@ -2633,6 +2682,7 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2675,6 +2725,7 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -3071,6 +3122,34 @@ impl Game<'_> {
pos,
data,
} => self.composter_fill_event(pos, data == 1),
// LevelEventHandler 1504: the stalactite's drip particle.
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
// LevelEventHandler 2006: the dragon fireball's burst.
LevelEvent::LevelEvent {
id: 2006,
pos,
data,
} => self.dragon_fireball_burst(pos, data),
// LevelEventHandler 2009: a wet sponge drying.
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
LevelEvent::LevelEvent {
id: 2014,
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
}
LevelEvent::LevelEvent {
id: id @ 3011..=3021,
pos,
@@ -3086,6 +3165,16 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
)),
1047 => Some((
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
2.0,
)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
@@ -3107,6 +3196,13 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
// LevelEventHandler draws these from the client
// level's random, before 1501's and 1502's smoke.
1501 | 1502 => {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
@@ -3125,6 +3221,15 @@ impl Game<'_> {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
match id {
1501 => self.puffs_above_block(
pos,
horizon_client::particles::ParticleType::LargeSmoke,
),
1502 => self.torch_burnout_smoke(pos),
_ => {}
}
}
LevelEvent::Sound {
pos,
@@ -3191,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);
@@ -3324,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,
@@ -3720,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
@@ -3841,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);
@@ -3900,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);
@@ -4688,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() {
@@ -4724,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 {
@@ -4739,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
@@ -4794,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,
@@ -4861,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(());
@@ -5157,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.
@@ -6009,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;
}
@@ -7930,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);
}
@@ -8091,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 {
@@ -8175,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);
@@ -8205,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);
@@ -8768,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(())
}
@@ -9049,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)
{
@@ -9751,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);
}
@@ -9825,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
@@ -10130,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 {
@@ -10166,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,
);
}
}
}
}
@@ -10714,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;
@@ -10869,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()
@@ -10989,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]);
@@ -11185,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,
@@ -11244,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
@@ -11951,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 => {
@@ -12174,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 {
@@ -12752,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(),
@@ -13447,7 +13674,6 @@ const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
"Loading assets",
@@ -13463,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)]
}
}
@@ -13929,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: &[],
@@ -14241,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();
@@ -14518,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 {
+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,
+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))
@@ -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,
+11
View File
@@ -67,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);
}
@@ -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
+174 -7
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
@@ -1134,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) => {
@@ -1935,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
@@ -2026,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)));
}
@@ -2068,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);
@@ -2100,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;
@@ -2232,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>(
@@ -2274,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
@@ -2868,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);
@@ -2923,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;
@@ -3183,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
@@ -3270,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;
@@ -3324,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.
@@ -3417,6 +3529,60 @@ 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;
@@ -3725,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 {
+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,
)
}
@@ -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);
}
}
}
+61 -16
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,12 +2081,18 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
);
return false;
}
@@ -2110,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;
@@ -2257,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`.
@@ -2835,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,
@@ -2853,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| {
+10 -3
View File
@@ -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
};
}
}
+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 {
+1 -1
View File
@@ -457,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 {
+33 -2
View File
@@ -412,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(
@@ -420,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 {
@@ -930,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;
@@ -1006,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
@@ -1166,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(
@@ -1521,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);
+37 -10
View File
@@ -267,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];
@@ -405,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,
@@ -1216,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);
@@ -1540,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;
}
@@ -1847,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,
@@ -2209,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);
@@ -2278,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();
@@ -2294,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) {
@@ -2332,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)
@@ -2354,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.
@@ -2689,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);
+190 -15
View File
@@ -1,12 +1,14 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
@@ -72,24 +74,42 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
(at, count, 0.5, self.block_shape_top(at), true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
let options = ParticleOptions::simple(ParticleType::HappyVillager);
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -108,6 +128,161 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
+4
View File
@@ -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);
}
}
}
+49 -21
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},
@@ -201,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>,
@@ -717,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 {
@@ -950,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];
@@ -960,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));
@@ -1054,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 {
@@ -1071,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
+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| {
+117 -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 {
@@ -98,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 {
@@ -138,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();
@@ -174,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,
@@ -190,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
@@ -210,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();
@@ -229,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,
}
+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
+286 -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).
@@ -391,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
@@ -406,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();
@@ -548,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
@@ -672,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() {
@@ -694,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)
{
@@ -720,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
@@ -745,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();
@@ -784,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,
+63 -7
View File
@@ -1954,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
@@ -2052,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,
@@ -2066,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),
_ => {}
}
}
@@ -2185,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 {
@@ -3384,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 {
@@ -3404,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).
@@ -3578,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;
@@ -3655,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,
@@ -3713,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
@@ -4153,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)
{
@@ -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
+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()
+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 => {}
}
}
+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.
+4 -4
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).
+1 -1
View File
@@ -68,7 +68,7 @@ impl Game<'_> {
velocity,
on_ground,
);
self.creatures.projectiles.push(proj);
self.add_projectile(proj);
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
.play("minecraft:entity.wind_charge.throw", Some(eye), 0.5, pitch);
@@ -94,6 +94,8 @@ impl WorldRequest {
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
@@ -1,8 +1,9 @@
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
//! Options: the rules by category under a search box, a switch or a number
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
//! AbstractGameRulesScreen: the rules by category under a search box, a
//! switch or a number box each. InWorldGameRulesScreen, opened from World
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
//! opened from Create New World, hands them back and leaves without asking.
use crate::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
@@ -32,14 +33,14 @@ enum Row {
/// What a click or Escape asks of World Options.
#[derive(Debug, PartialEq)]
pub(super) enum Exit {
pub enum Exit {
/// Done: the rules that changed.
Apply(Vec<(&'static str, Value)>),
/// Cancel, or discarding the changes.
Discard,
}
pub(super) struct RulesScreen {
pub struct RulesScreen {
search: EditBox,
values: GameRules,
initial: GameRules,
@@ -49,6 +50,8 @@ pub(super) struct RulesScreen {
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
in_world: bool,
}
fn name_key(r: &Rule) -> String {
@@ -102,7 +105,15 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
}
impl RulesScreen {
pub(super) fn new(rules: &GameRules) -> Self {
/// InWorldGameRulesScreen over the world's rules.
pub fn new(rules: &GameRules) -> Self {
Self::build(rules, true)
}
/// WorldCreationGameRulesScreen over the new world's rules.
pub fn for_creation(rules: &GameRules) -> Self {
Self::build(rules, false)
}
fn build(rules: &GameRules, in_world: bool) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
@@ -123,6 +134,7 @@ impl RulesScreen {
numbers,
scroll: ui::Scroll::default(),
confirm: false,
in_world,
}
}
@@ -197,9 +209,10 @@ impl RulesScreen {
})
.collect()
}
/// InWorldGameRulesScreen.onClose: pending changes ask first.
/// InWorldGameRulesScreen.onClose: pending changes ask first;
/// WorldCreationGameRulesScreen.onClose discards them.
fn close(&mut self) -> Option<Exit> {
if self.changes().is_empty() {
if !self.in_world || self.changes().is_empty() {
Some(Exit::Discard)
} else {
self.confirm = true;
@@ -207,7 +220,7 @@ impl RulesScreen {
}
}
/// Escape: ConfirmScreen answers no; the rules close.
pub(super) fn escape(&mut self) -> Option<Exit> {
pub fn escape(&mut self) -> Option<Exit> {
if self.confirm {
self.confirm = false;
None
@@ -216,7 +229,7 @@ impl RulesScreen {
}
}
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
pub fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let content = self.content();
self.scroll
@@ -224,20 +237,20 @@ impl RulesScreen {
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub(super) fn drag(&mut self, y: f32, h: f32) {
pub fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub(super) fn release(&mut self) {
pub fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
@@ -272,7 +285,7 @@ impl RulesScreen {
}
}
/// Run an edit on the focused box, then its responder.
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
let Some((rule, b)) = self.focused() else {
return;
};
@@ -282,12 +295,12 @@ impl RulesScreen {
self.edited(rule);
}
}
pub(super) fn typing(&self) -> bool {
pub fn typing(&self) -> bool {
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
}
/// A left click; Some when the screen closes.
pub(super) fn click(
pub fn click(
&mut self,
atlas: &HudAtlas,
cursor: (f32, f32),
@@ -355,12 +368,7 @@ impl RulesScreen {
}
}
/// Whether a click lands on a button (for its sound).
pub(super) fn clicks_button(
&self,
atlas: &HudAtlas,
cursor: (f32, f32),
size: (f32, f32),
) -> bool {
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
@@ -377,9 +385,9 @@ impl RulesScreen {
})
}
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
if self.confirm {
let labels = [
Text::translate("gui.yes", &[]),
@@ -393,7 +401,7 @@ impl RulesScreen {
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
let first = batch.vertices.len();
let hovered = self.hovered(rows.len(), cursor, (w, h));
let (left, x) = (row_left(w), control_x(w));
@@ -450,7 +458,7 @@ impl RulesScreen {
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::scrollbar(
batch,
atlas,
@@ -526,8 +534,8 @@ mod tests {
#[test]
fn box_colours_are_vanillas() {
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
}
#[test]
+103 -40
View File
@@ -37,7 +37,10 @@ mod crumbling_tests;
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
pub use entity_batch::{
SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -247,6 +250,8 @@ pub struct Frame<'a> {
pub outline: Vec<[f64; 6]>,
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -377,6 +382,7 @@ pub struct Renderer {
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
debug_lines: wgpu::RenderPipeline,
@@ -391,6 +397,7 @@ pub struct Renderer {
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
translucent_item_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
@@ -1213,12 +1220,6 @@ impl Renderer {
attributes: &instance_attributes,
}),
];
let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let alpha_blend = wgpu::BlendState::ALPHA_BLENDING;
let debug_line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
@@ -1351,13 +1352,25 @@ impl Renderer {
"Block outline",
"vs_line",
"fs_line",
&line_buffers,
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
true,
Some(alpha_blend),
);
// RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline.
let lines_secondary = pipeline(
"Block outline secondary",
"vs_line",
"fs_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
true,
Some(alpha_blend),
);
let debug_lines = pipeline(
"Debug lines",
"vs_debug_line",
@@ -1531,7 +1544,7 @@ impl Renderer {
});
let outline = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Outline"),
size: 24 * MAX_OUTLINE_EDGES,
size: 32 * 2 * MAX_OUTLINE_EDGES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -1818,25 +1831,40 @@ impl Renderer {
false,
);
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
// writes. The quads lie on the block tops like vanilla's, but the
// terrain under them snaps to the subpixel grid at other vertices, so
// at a slant their depths differ by up to 1/256 of a pixel's depth
// slope and whole shadows failed the test. The bias covers that and
// the rounding, and stays below the 0.001 a model's feet sit higher
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
// writes. The quads lie exactly on the block tops like vanilla's, but
// terrain and shadows are separate draw paths, so their fp32 depth
// interpolations disagree by a few dozen ulps of the fragment depth
// and the shadow's rim z-fights with the block top (speckle that
// re-rolls as the camera drifts a subpixel). A constant bias of 128
// ulps covers that spread at any resolution while staying far below
// the 0.001 a model's feet sit higher, so shadows never bleed onto
// the feet.
let shadow_blob_pipeline = entity_pipeline(
"Entity shadows",
None,
false,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 16,
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
},
alpha_blend,
false,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
// glass item would fail the test and vanish (vanilla sorts these in
// its order independent transparency pass instead).
let translucent_item_pipeline = entity_pipeline(
"Translucent items",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
alpha_blend,
false,
);
let cloud_pipeline = entity_pipeline(
"Clouds",
Some(wgpu::Face::Back),
@@ -2362,6 +2390,7 @@ impl Renderer {
sky,
dark_disc,
lines,
lines_secondary,
debug_lines,
debug_lines_on_top,
debug_line_buffer,
@@ -2372,6 +2401,7 @@ impl Renderer {
weather,
crumbling_pipeline,
shadow_blob_pipeline,
translucent_item_pipeline,
water_mask_pipeline,
cloud_pipeline,
cloud_mesh: Default::default(),
@@ -2483,6 +2513,18 @@ impl Renderer {
present_modes,
})
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
pass.set_vertex_buffer(0, self.outline.slice(..));
if high_contrast {
pass.set_pipeline(&self.lines_secondary);
pass.draw(0..4, 0..edges);
}
pass.set_pipeline(&self.lines);
let first = if high_contrast { edges } else { 0 };
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
@@ -3195,7 +3237,7 @@ impl Renderer {
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
let levels = sprite_mipmap_limit(
let levels = crate::sprites::sprite_mipmap_limit(
levels,
self.sprite_textures[0].width(),
self.sprite_minimum_size,
@@ -3955,7 +3997,26 @@ impl Renderer {
);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
// The same per-line colour and pixel width layout as the gizmo lines. A high
// contrast outline is two strokes: the black one first, the bright one after it.
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
let width = outline_width(self.config.width);
let stroke = |color: u32, width: f32| {
edges.iter().map(move |e| {
let mut line = [0.0; 8];
line[..6].copy_from_slice(e);
line[6] = f32::from_bits(color);
line[7] = width;
line
})
};
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
.collect()
} else {
stroke(0x6600_0000, width).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
@@ -3982,6 +4043,7 @@ impl Renderer {
water_mask_quads,
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
@@ -3991,7 +4053,8 @@ impl Renderer {
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads
+ translucent_particle_quads;
+ translucent_particle_quads
+ translucent_item_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4514,6 +4577,17 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
self.weather.draw(&mut pass, &self.entity_group);
}
@@ -4531,7 +4605,11 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
((world_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
* 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4563,9 +4641,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if debug_depth > 0 {
pass.set_pipeline(&self.debug_lines);
@@ -4612,7 +4688,8 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_particle_quads) * 4) as i32,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4620,9 +4697,7 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
@@ -5241,7 +5316,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(counts, [4, 1, 1, 0, 1, 1, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -5477,11 +5552,6 @@ mod tests {
}
}
pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, available: u32) -> u32 {
// Only the atlas native minimum determines how many resampling-only mips to skip.
let upscale = (size / minimum.max(1)).max(1).ilog2();
requested.saturating_add(upscale).min(available - 1)
}
#[cfg(test)]
mod sprite_upload_tests {
use super::*;
@@ -5542,13 +5612,6 @@ mod sprite_upload_tests {
}
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
assert_eq!(sprite_mipmap_limit(levels, 16, 16, 5), levels);
}
}
#[test]
fn prepared_texture_uploads_preserve_layer_order_and_settings() {
let size = 4;
let layers = 5;
@@ -5,14 +5,20 @@ use super::Vertex;
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 7;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`,
/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31).
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -45,6 +51,7 @@ impl EntityBatch {
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 6,
_ => 0,
};
self.buckets[material][counts[material]] = index;
+13
View File
@@ -204,6 +204,19 @@ impl Gizmos {
position[1] + f64::from(size.1),
position[2] + half,
];
self.entity_box(min, max, position, eye_height, living, view);
}
/// The same for an entity whose box is not centred on its position
/// (a painting or an item frame): `min` and `max` are the box.
pub fn entity_box(
&mut self,
min: [f64; 3],
max: [f64; 3],
position: [f64; 3],
eye_height: f32,
living: bool,
view: (f32, f32),
) {
self.cuboid(min, max, WHITE, STROKE_WIDTH);
if living {
let eye = min[1] + f64::from(eye_height);
+9 -5
View File
@@ -32,14 +32,15 @@ const STRENGTH: f32 = 0.7;
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
// The hand, HUD and text replace pixels instead, so they never pass.
// The hand, HUD and text replace pixels instead, so they never pass. The
// channels are held to one factor, the ratio of their sums, so a channel at 0
// stays 0; a step of 8-bit rounding is allowed on each.
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
if all(c == w) {
return true;
}
let k = c / max(w, vec3(1.0 / 255.0));
let mean = (k.r + k.g + k.b) / 3.0;
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -69,7 +70,10 @@ fn pq(nits: vec3<f32>) -> vec3<f32> {
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
let y = 0.5 * (dot(wl, vec3(0.2126, 0.7152, 0.0722)) + max(wl.r, max(wl.g, wl.b)));
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
+42 -2
View File
@@ -29,12 +29,22 @@ pub(crate) type State = HurtState<DamageTypeId>;
pub struct HealthRules {
pub difficulty: Difficulty,
pub natural_regeneration: bool,
/// The `drowning_damage`, `fall_damage`, `fire_damage` and
/// `freeze_damage` rules `Player.isInvulnerableTo` reads.
pub drowning_damage: bool,
pub fall_damage: bool,
pub fire_damage: bool,
pub freeze_damage: bool,
}
impl Default for HealthRules {
fn default() -> Self {
Self {
difficulty: Difficulty::Normal,
natural_regeneration: true,
drowning_damage: true,
fall_damage: true,
fire_damage: true,
freeze_damage: true,
}
}
}
@@ -633,8 +643,13 @@ impl ImmunityWorld for Environment<'_> {
fn enchantment_immunity(&mut self) -> io::Result<bool> {
Ok(false)
}
fn damage_rule(&mut self, _: DamageRule) -> io::Result<bool> {
Ok(true)
fn damage_rule(&mut self, rule: DamageRule) -> io::Result<bool> {
Ok(match rule {
DamageRule::Drowning => self.rules.drowning_damage,
DamageRule::Fall => self.rules.fall_damage,
DamageRule::Fire => self.rules.fire_damage,
DamageRule::Freeze => self.rules.freeze_damage,
})
}
fn changing_dimension(&mut self) -> io::Result<bool> {
Ok(false)
@@ -860,6 +875,31 @@ mod tests {
assert!(!third.hurt && !third.knockback);
}
/// #2141: an arrow that takes the last of the player's health must kill
/// them: `LivingEntity.hurt` leaves the health at zero and `Player.die`
/// follows.
#[test]
fn an_arrow_kills_a_player_at_one_health() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut player =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.unwrap();
player.set_health(1.0, false).unwrap();
let (health, rules) = (&resources.health, super::HealthRules::default());
let id = health.damage.id("minecraft:arrow").unwrap();
let hurt = player.hurt(health, rules, id, 5.0).unwrap();
assert!(hurt, "the arrow must hurt");
assert_eq!(player.health.health(), 0.0, "the health must be spent");
assert!(player.health.dead_or_dying(), "the player must be dead");
}
#[test]
fn hurt_sounds_follow_damage_effects() {
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
+2
View File
@@ -912,6 +912,8 @@ impl HudBatch {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
let text = text.resolved();
// Language.getVisualOrder: shaped and reordered for right-to-left text.
let text = crate::bidi::visual(&text);
let font = &atlas.font;
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
+39 -2
View File
@@ -46,6 +46,9 @@ pub struct ItemQuad {
pub foil: Foil,
/// `MaterialInfo.lightEmission` (block light floor).
pub light_emission: u8,
/// The quad's sprite has translucent pixels (`MaterialInfo.itemRenderType`
/// is a translucent sheet): drawn with the translucent entities.
pub translucent: bool,
}
/// Which glint render type a quad uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -159,7 +162,7 @@ impl ItemRenderState {
let m = pose * layer.matrix;
match &layer.geometry {
LayerGeometry::Quads(baked) => {
submit_quads(baked, layer, self.display, m, sprites, sink);
submit_quads(renderer, baked, layer, self.display, m, sprites, sink);
}
LayerGeometry::Special { model, argument } => special::submit(
renderer,
@@ -203,6 +206,7 @@ pub fn argb(color: i32) -> u32 {
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
fn submit_quads(
renderer: &ItemRenderer,
baked: &BakedItemModel,
layer: &LayerRender,
display: DisplayContext,
@@ -216,6 +220,8 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
// Quads of one layer mostly share a sprite: look its translucency up once.
let mut last: Option<(&str, bool)> = None;
for q in &baked.quads {
let Some(r) = sprites
.get(sprite_name(&q.material.sprite))
@@ -239,6 +245,16 @@ fn submit_quads(
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
let uv =
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
let translucent = match last {
Some((sprite, translucent)) if sprite == q.material.sprite => translucent,
_ => {
let translucent = renderer
.sprites
.translucent(sprite_name(&q.material.sprite));
last = Some((&q.material.sprite, translucent));
translucent
}
};
sink.quad(ItemQuad {
pos,
uv,
@@ -246,6 +262,7 @@ fn submit_quads(
color: argb(color),
foil,
light_emission: q.light_emission,
translucent: translucent || q.material.force_translucent,
});
}
}
@@ -260,6 +277,9 @@ struct FileSprites {
root: PathBuf,
permutations: Vec<PalettedPermutations>,
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
/// Sprites with a pixel that is neither fully transparent nor opaque
/// (`Transparency.hasTranslucent`), filled in as sprites load.
translucent: Mutex<HashMap<String, bool>>,
}
impl SpriteLookup for FileSprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
@@ -283,6 +303,13 @@ fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
.collect()
}
impl FileSprites {
fn translucent(&self, sprite: &str) -> bool {
let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied();
known(self).unwrap_or_else(|| {
self.mask(sprite);
known(self).unwrap_or(false)
})
}
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
crate::atlas_sources::texture_path(&self.root, sprite, directory)
}
@@ -303,6 +330,10 @@ impl FileSprites {
(w, h, pixels)
}
};
let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254));
if let Ok(mut cache) = self.translucent.lock() {
cache.insert(sprite.to_owned(), translucent);
}
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
.ok()
@@ -452,6 +483,7 @@ impl ItemRenderer {
root: root.to_path_buf(),
permutations,
cache: Mutex::default(),
translucent: Mutex::default(),
},
baked: Mutex::default(),
extents: Mutex::default(),
@@ -694,7 +726,12 @@ impl ItemSink for WorldSink<'_> {
} else {
self.light
};
let overlay = if q.foil == Foil::None { 0 } else { 7 };
let overlay = match (q.foil, q.translucent) {
(Foil::None, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY,
(Foil::None, false) => 0,
(_, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY,
(_, false) => 7,
};
for (p, uv) in q.pos.iter().zip(q.uv) {
self.out.push(Vertex {
normal: [0; 4],
@@ -358,6 +358,7 @@ mod tests {
color,
foil: Foil::None,
light_emission: 0,
translucent: false,
}
}
@@ -119,6 +119,7 @@ fn draw(
color,
foil,
light_emission: 0,
translucent: false,
});
}
for (child_name, child) in &part.children {
+40 -2
View File
@@ -2,7 +2,12 @@
//! player state (inventory, health, game mode) that the host's gameplay code
//! operates on when it acts for them.
use super::{Joined, PlayerSender, entity::Tracker};
use crate::{items::ItemStack, motion::PlayerMotion};
use crate::{
items::ItemStack,
motion::PlayerMotion,
pose::{Dimensions, Pose},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
item::Stack,
packets::{configuration::ClientInformation, play},
@@ -407,8 +412,41 @@ impl Guest {
}
f
}
/// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose
/// (swimming, crawling, crouching) once its server tick has run.
pub fn pose(&self) -> Pose {
if self.sleeping {
Pose::Sleeping
} else if self.fall_flying {
Pose::FallFlying
} else if let Some(m) = &self.motion {
m.pose
} else if self.sneaking && !self.flying {
Pose::Crouching
} else {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
}
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
let d = self.dimensions();
let half = f64::from(d.width) / 2.0;
Aabb::new(
Vec3::new(x - half, y, z - half),
Vec3::new(x + half, y + f64::from(d.height), z + half),
)
}
pub fn eye_height(&self) -> f64 {
if self.sneaking { 1.27 } else { 1.62 }
f64::from(self.dimensions().eye_height)
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
+2 -12
View File
@@ -2344,18 +2344,8 @@ impl Hub {
}
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
(0.3, 1.5)
} else {
(0.3, 1.8)
};
let d = g.dimensions();
let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height));
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - half, p[1], p[2] - half),
+2
View File
@@ -15,6 +15,7 @@ pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
@@ -74,6 +75,7 @@ pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gamepad;
pub mod gfx;
pub mod gizmos;
+60
View File
@@ -3211,4 +3211,64 @@ pub(crate) mod tests {
assert_eq!(menu.slot(0), stack("dirt", 1).as_ref());
});
}
/// #1737: a Shift+Right-Click quick move in a chest (a double chest's
/// 54 slots) moves the stack and leaves the menu's state clean, so the
/// next quick move still moves its stack and the inventory still
/// answers.
#[test]
fn a_chest_quick_move_keeps_the_next_one_working() {
with_menu(Kind::Chest(6), |env, menu| {
menu.creative = false;
// The hotbar holds two stacks; the chest is empty.
menu.set_slot(env, 81, stack("cobblestone", 64));
menu.set_slot(env, 82, stack("dirt", 32));
// Shift+Right-Click on the hotbar: the stack goes into the chest.
menu.click(env, 81, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(81), None);
assert_eq!(menu.slot(0).map(|s| s.count), Some(64));
// Nothing is left on the cursor and no quick craft is running.
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
// The next quick move still works.
menu.click(env, 82, 1, Input::QuickMove).unwrap();
assert_eq!(menu.slot(82), None);
assert_eq!(menu.slot(1).map(|s| s.count), Some(32));
assert_eq!(menu.carried, None);
assert_eq!(menu.quick_status, 0);
});
}
/// #1832: a potion taken from the creative inventory and put in the
/// hotbar keeps its `PotionContents` (the brew), as vanilla's
/// `PotionContents.createItemStack` builds it: the item tab's stack is
/// the real one, and the hotbar slot holds it as it is.
#[test]
fn a_creative_potion_keeps_its_contents_in_the_hotbar() {
let snbt = r#"{components:{"minecraft:potion_contents":{potion:"minecraft:swiftness"}},count:1,id:"minecraft:potion"}"#;
let potion = crate::item_data::parse_stack(snbt).unwrap().unwrap();
assert_eq!(potion.potion(), Some("minecraft:swiftness"));
// The creative screen hands the stack over...
let mut carried = None;
crate::creative::click_display(
&mut carried,
Some(&potion),
|_: &ItemStack| 1,
0,
Input::Pickup,
);
assert_eq!(
carried.as_ref().and_then(|s| s.potion()),
Some("minecraft:swiftness")
);
// ... and the hotbar slot keeps it: it is not the uncraftable
// potion, which is a `minecraft:potion` with no contents at all.
with_menu(Kind::ItemPicker, |env, menu| {
menu.carried = carried;
menu.click(env, 45, 0, Input::Pickup).unwrap();
let in_hand = menu.slot(45).expect("the hotbar slot");
assert_eq!(in_hand.potion(), Some("minecraft:swiftness"));
assert_eq!(&*in_hand.item, "minecraft:potion");
});
}
}
+53 -2
View File
@@ -527,6 +527,8 @@ pub enum DamageKind {
InWall,
Thorns,
SonicBoom,
/// `freeze`: a fully frozen mob in powder snow.
Freeze,
/// `fell_out_of_world`: below the world's floor.
OutOfWorld,
}
@@ -596,6 +598,7 @@ impl Damage {
| DamageKind::InWall
| DamageKind::Generic
| DamageKind::SonicBoom
| DamageKind::Freeze
| DamageKind::OutOfWorld
)
}
@@ -620,6 +623,7 @@ impl Damage {
| DamageKind::Cactus
| DamageKind::Generic
| DamageKind::InWall
| DamageKind::Freeze
| DamageKind::OutOfWorld
)
}
@@ -646,6 +650,9 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which
/// sets its box (`Avatar.POSES`).
pub avatar_pose: crate::pose::Pose,
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
pub sprinting: bool,
pub passenger: bool,
@@ -904,6 +911,12 @@ pub enum MobEvent {
mob: u32,
at: [f64; 3],
},
/// `LivingEntity.baseTick`'s drowning damage: entity event 67, the
/// clients' `makeDrownParticles`.
Drown {
mob: u32,
at: [f64; 3],
},
Sound(&'static str, [f64; 3], f32, f32),
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
/// chest's opener count goes up or down (interaction range 3).
@@ -1307,6 +1320,8 @@ pub struct Seen {
pub active_raid: bool,
/// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`).
pub opened_chest: Option<[i32; 3]>,
/// `isPassenger`.
pub passenger: bool,
/// A villager's reputation of the player (`getPlayerReputation`).
pub player_rep: i32,
/// `isPushable` and pushed by others (`Entity.push(Entity)` moves it).
@@ -1355,6 +1370,10 @@ impl Mob {
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
self.ticks_frozen = 0;
}
/// `Entity.isFullyFrozen`.
pub fn is_fully_frozen(&self) -> bool {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
}
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by).
@@ -1394,6 +1413,7 @@ impl Mob {
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
player_rep: match (villager, player) {
(true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)),
_ => 0,
@@ -1573,6 +1593,8 @@ pub struct PlayerView {
/// A LAN guest: its own client moves it, and touching mobs push it there
/// (`EntitySelector.pushableBy` on the client).
pub remote: bool,
/// `isPassenger`.
pub passenger: bool,
}
impl PlayerView {
/// `LivingEntity.hasEffect`, by id without namespace.
@@ -1833,6 +1855,15 @@ impl Mob {
{
amount *= 0.75;
}
// LivingEntity.hurtServer: `#freeze_hurts_extra_types` take five times.
if source.kind == DamageKind::Freeze
&& matches!(
self.species(),
Species::Strider | Species::Blaze | Species::MagmaCube
)
{
amount *= 5.0;
}
let (worn, worn_toughness) = self.worn_armor();
if !source.bypasses_armor() && self.kind.armor + worn > 0.0 {
// CombatRules.getDamageAfterAbsorb
@@ -1894,7 +1925,7 @@ impl Mob {
let dead = !self.alive();
let sound = ai::voice_sounds(self)[if dead { 2 } else { 1 }];
if !dead {
self.ambient_timer = -self.kind.ambient_interval;
self.ambient_timer = -self.ambient_interval();
}
if !sound.is_empty() {
let pitch = self.voice_pitch();
@@ -2004,10 +2035,30 @@ impl Mob {
let at = [self.position.x, self.eye_y(), self.position.z];
events.push(MobEvent::Sound(event, at, 1.0, 1.0));
}
/// `Mob.getAmbientSoundInterval`; `HappyGhast` waits six times as long
/// while it carries a passenger.
pub fn ambient_interval(&self) -> i32 {
let ridden = self.rider.is_some() || self.extra.rider.is_some();
if self.species() == Species::HappyGhast && ridden {
self.kind.ambient_interval * 6
} else {
self.kind.ambient_interval
}
}
/// LivingEntity.getVoicePitch.
pub fn voice_pitch(&mut self) -> f32 {
// HappyGhast.getVoicePitch: always 1.0, babies and all.
if self.species() == Species::HappyGhast {
return 1.0;
}
// ZombieVillager.getVoicePitch: babies are 2.0, not 1.5.
let baby = if self.species() == Species::ZombieVillager {
2.0
} else {
1.5
};
let pitch = (self.random.next_float() - self.random.next_float()) * 0.2
+ if self.is_baby() { 1.5 } else { 1.0 };
+ if self.is_baby() { baby } else { 1.0 };
// Bat.getVoicePitch: a little lower.
if self.species() == Species::Bat {
pitch * 0.95
+172 -20
View File
@@ -185,9 +185,35 @@ pub fn register(m: &mut Mob) {
if m.species() == Species::Rabbit {
super::rabbit::register(m);
}
if m.species() == Species::Frog {
// `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true).
// The speed attribute is 1.0, so the controller's scaling is
// what keeps a frog at a walking pace on land.
m.ai.mov.kind = super::control::MoveKind::SmoothSwimming {
max_turn_x: 85.0,
max_turn_y: 10.0,
in_water: 0.02,
outside: 0.1,
gravity: true,
};
}
if m.species() == Species::Turtle {
super::turtle::register(m);
}
if m.species() == Species::Frog {
// `Frog()`: SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true).
// The frog's movement speed is 1.0, so the controller's 0.1
// outside-water speed is what keeps a walking frog at
// vanilla's pace; the plain ground controller walks ten
// times too fast (#2213).
m.ai.mov.kind = super::control::MoveKind::SmoothSwimming {
max_turn_x: 85.0,
max_turn_y: 10.0,
in_water: 0.02,
outside: 0.1,
gravity: true,
};
}
}
Family::Inert => {}
Family::Witch => super::witch::register(m),
@@ -307,6 +333,20 @@ fn generic(m: &mut Mob) {
m.ai.targets.add(1, HurtByTarget::new());
m.ai.targets.add(2, NearestTarget::player(true));
} else {
if m.kind.species == Species::Goat {
// GoatAi's idle and core behaviours (the ram and long jump
// activities are not here): panic 2.0, AnimalMakeLove, tempting
// with goat_food and baby follow at 1.25, then stroll.
g.add(0, Float);
g.add(1, Panic::new(2.0));
g.add(2, Breed::new(1.0));
g.add(3, Tempt::new(1.25, m.kind.food.clone(), false));
g.add(4, FollowParent::new(1.25));
g.add(5, Stroll::avoiding_water(1.0));
g.add(6, LookAtPlayer::new(6.0));
g.add(7, RandomLookAround::default());
return;
}
g.add(0, Float);
let rabbit = m.kind.species == Species::Rabbit;
g.add(1, Panic::new(if rabbit { 2.2 } else { 1.25 }));
@@ -448,6 +488,16 @@ pub fn tick_swing(m: &mut Mob) {
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
}
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
w.raining_at(x, y, z) || w.raining_at(x, top, z)
}
/// `LivingEntity.baseTick` refills a breather's air by 4, then
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
@@ -458,15 +508,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
if m.no_ai {
return;
}
// Entity.isInRain: rain at the block, or at the top of the box.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
if m.fluids.in_water || in_rain {
if m.fluids.in_water || in_rain(m, w) {
m.air = super::axolotl::MAX_AIR;
} else {
m.air = before - 1;
@@ -494,8 +536,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
);
m.damage(fire, Damage::of(DamageKind::InFire));
}
// Fire: water extinguishes, lava ignites, burning hurts every second.
if m.fluids.in_water && m.fire_ticks > 0 {
// Fire: water or rain (Entity.isInRain) extinguishes, lava ignites,
// burning hurts every second.
if m.fire_ticks > 0 && (m.fluids.in_water || in_rain(m, w)) {
m.fire_ticks = 0;
events.push(MobEvent::Sound(
"entity.generic.extinguish_fire",
@@ -524,14 +567,19 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
m.first_tick = false;
if m.alive() {
// LivingEntity.baseTick: suffocation inside full blocks.
// LivingEntity.baseTick: suffocation inside full blocks. Entity.isInWall
// is false under noPhysics, which Vex.tick holds while it ticks.
let (ex, ey, ez) = (
m.position.x.floor() as i32,
m.eye_y().floor() as i32,
m.position.z.floor() as i32,
);
let at_eye = w.facts.get(w.world.block(ex, ey, ez));
if at_eye.full_collision && at_eye.solid_render && m.width() < 1.0 {
if at_eye.full_collision
&& at_eye.solid_render
&& m.width() < 1.0
&& m.species() != Species::Vex
{
m.damage(1.0, Damage::of(DamageKind::InWall));
}
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
@@ -551,17 +599,20 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.air -= 1;
if m.air == -20 {
m.air = 0;
events.push(MobEvent::Drown {
mob: m.id,
at: [m.position.x, m.position.y, m.position.z],
});
m.damage(2.0, Damage::of(DamageKind::Drown));
}
} else if m.air < 300 {
m.air = (m.air + 4).min(300);
}
if m.kind.flags.water_sensitive
&& (m.fluids.in_water || w.raining && w.can_see_sky(ex, ey, ez))
{
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
}
freeze_tick(m, w);
}
tick_effects(m);
if m.species() == Species::Illusioner {
@@ -584,13 +635,64 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let t = m.ambient_timer;
m.ambient_timer += 1;
if roll < t {
m.ambient_timer = -m.kind.ambient_interval;
m.ambient_timer = -m.ambient_interval();
play_ambient(m, w, events);
}
}
// Entity.baseTick ends with Leashable.tickLeash.
super::leash::tick(m, w, events);
}
/// `LivingEntity.aiStep`'s "freezing" with `InsideBlockEffectType.FREEZE`:
/// powder snow builds `ticks_frozen` up, anywhere else it melts twice as
/// fast, and a fully frozen mob takes one freeze damage every 40 ticks.
fn freeze_tick(m: &mut Mob, w: &MobWorld) {
let in_snow = in_powder_snow(m, w);
if !in_snow && m.ticks_frozen == 0 {
return;
}
let can_freeze = can_freeze(m, w);
if in_snow && can_freeze {
m.ticks_frozen =
(m.ticks_frozen + 1).min(crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE);
} else {
m.ticks_frozen = (m.ticks_frozen - 2).max(0);
}
if m.age.is_multiple_of(40) && m.is_fully_frozen() && can_freeze {
m.damage(1.0, Damage::of(DamageKind::Freeze));
}
}
/// Whether the mob's box (shrunk by `1.0E-5`, as in `Entity.checkInsideBlocks`)
/// touches powder snow.
fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool {
let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (min, max) = (b.min_position(), b.max_position());
for x in min.x.floor() as i32..=max.x.floor() as i32 {
for y in min.y.floor() as i32..=max.y.floor() as i32 {
for z in min.z.floor() as i32..=max.z.floor() as i32 {
if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" {
return true;
}
}
}
}
false
}
/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no
/// worn piece in `#freeze_immune_wearables`.
fn can_freeze(m: &Mob, w: &MobWorld) -> bool {
if w.data
.entity_has("minecraft:freeze_immune_entity_types", &m.kind.id)
{
return false;
}
let immune = w
.items
.and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables"));
![&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.into_iter()
.flatten()
.any(|item| immune.is_some_and(|t| t.contains(&**item)))
}
/// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`.
pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
play_ambient(m, w, events);
@@ -895,7 +997,7 @@ fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
m.position.z.floor() as i32,
);
let br = w.brightness(x, y, z);
let wet = m.fluids.in_water || w.raining && w.can_see_sky(x, y, z);
let wet = m.fluids.in_water || in_rain(m, w);
br > 0.5 && m.random.next_float() * 30.0 < (br - 0.4) * 2.0 && !wet && w.can_see_sky(x, y, z)
}
/// Mob.serverAiStep
@@ -1682,8 +1784,20 @@ pub fn step_sound(
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)),
S::Camel => Some(("entity.camel.step", 1.0, 1.0)),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
@@ -1762,3 +1876,41 @@ mod despawn_tests {
}
}
}
#[cfg(test)]
mod frog_tests {
use super::*;
use crate::mobs::MobTypes;
use crate::mobs::control::MoveKind;
use std::sync::Arc;
/// `Frog()`: `new SmoothSwimmingMoveControl<>(this, 85, 10, 0.02F, 0.1F, true)`.
/// The frog's movement speed is 1.0, so the controller's 0.1 outside-water
/// speed is what keeps a walking frog at vanilla's pace. The plain ground
/// controller walks it ten times too fast, which is the jitter, the
/// teleporting and the flying of #2213.
#[test]
fn a_frog_walks_at_the_vanilla_speed() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
return;
}
let data = MobTypes::load(&root).unwrap();
let kind = Arc::new(data.types.get("minecraft:frog").unwrap().clone());
assert_eq!(kind.speed, 1.0, "the frog's movement speed attribute");
let mut m = Mob::new(1, kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
register(&mut m);
assert_eq!(
m.ai.mov.kind,
MoveKind::SmoothSwimming {
max_turn_x: 85.0,
max_turn_y: 10.0,
in_water: 0.02,
outside: 0.1,
gravity: true,
}
);
}
}
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
S::Dolphin => {
// Dolphin.tick: moisture out of water, hop on land.
if m.fluids.in_water || w.raining {
if m.fluids.in_water || super::ai::in_rain(m, w) {
m.extra.counter = 2400;
} else {
m.extra.counter -= 1;
+16 -9
View File
@@ -323,12 +323,19 @@ pub fn sting(m: &mut Mob, who: Who, cx: &mut Cx) {
match who {
Who::Player => {
cx.events.push(MobEvent::AttackPlayer(amount, from));
let seconds = if cx.w.difficulty.hard { 18 } else { 10 };
cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:poison",
ticks: seconds * 20,
amplifier: 0,
});
// Poison only on normal (10 s) and hard (18 s).
let seconds = match cx.w.difficulty.id {
2 => 10,
3 => 18,
_ => 0,
};
if seconds > 0 {
cx.events.push(MobEvent::EffectPlayer {
effect: "minecraft:poison",
ticks: seconds * 20,
amplifier: 0,
});
}
}
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
target: id,
@@ -421,10 +428,10 @@ pub fn drop_off_nectar(m: &mut Mob) {
set_has_nectar(m, false);
st(m).crops_grown = 0;
}
/// `Bee.setStayOutOfHiveCountdown`, which in 26.3 only resets the time
/// without nectar.
/// `Bee.setStayOutOfHiveCountdown`: the bee will not enter a hive for 400
/// ticks (the hive passes that when a sedated hive lets its bees out).
pub fn set_stay_out_of_hive_countdown(m: &mut Mob) {
st(m).ticks_without_nectar = 0;
st(m).stay_out_of_hive = 400;
}
fn drop_hive(m: &mut Mob) {
let b = st(m);
+2 -1
View File
@@ -980,9 +980,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
Species::Parrot => {
let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial;
let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial;
// `Parrot.isFlying` is `!onGround`, in water too.
let pose = if m.extra.sitting {
ParrotPose::Sitting
} else if !m.on_ground && !m.fluids.in_water {
} else if !m.on_ground {
ParrotPose::Flying
} else {
ParrotPose::Standing
+10 -2
View File
@@ -1,6 +1,6 @@
//! Mob controls: `MoveControl` (with the flying and smooth-swimming
//! variants), `LookControl`, `JumpControl` and `BodyRotationControl`.
use super::{Mob, MobWorld, living::wrap};
use super::{Mob, MobWorld, Species, living::wrap};
use horizon_core::geometry::trig;
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
@@ -116,8 +116,16 @@ pub struct BodyRotation {
last_stable_head: f32,
}
impl Mob {
/// `Mob.getMaxHeadXRot`, with `Wolf`'s 20 degrees while sitting.
pub fn max_head_x_rot(&self) -> f32 {
if self.species() == Species::Wolf && self.extra.sitting {
20.0
} else {
self.kind.max_head_x_rot
}
}
pub fn look_at(&mut self, at: [f64; 3]) {
let (y, x) = (self.kind.head_rot_speed, self.kind.max_head_x_rot);
let (y, x) = (self.kind.head_rot_speed, self.max_head_x_rot());
self.ai.look.set_look_at(at, y, x);
}
pub fn look_at_speed(&mut self, at: [f64; 3], y_speed: f32, x_max: f32) {
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more