Compare commits

...
Author SHA1 Message Date
Ludovic Marie 65ab2b6cc9 Leave the default (temperate) variants out of the pairing data
Pig, cow, chicken, frog and zombie nautilus define DATA_VARIANT_ID as
their registry's TEMPERATE, so vanilla's getNonDefaultValues leaves it
out of the add packet; server_entities caught the extra entry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 604025a53b)
2026-10-27 09:11:05 +01:00
Ludovic Marie bf9ad251b5 Goats: keep the ram cooldown a brain memory
RAM_COOLDOWN_TICKS is a memory in vanilla (GoatAi.initMemories,
CountDownCooldownTicks), saved with the others; as a field it was
neither saved nor seen by finalize_spawn, which failed on every goat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 05d150349f)
2026-10-27 08:35:49 +01:00
Ludovic MarieandClaude Opus 5.5 9de19c3322 fixup #2365: what /give cannot fit is dropped with drop(stack, false)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 08:09:01 +01:00
Ludovic Marie 1ad1d68d86 Merge #2374 2026-10-27 08:08:23 +01:00
Ludovic Marie e9a3d18090 Merge #2373
# Conflicts:
#	crates/horizon-client/src/mobs/raider.rs
2026-10-27 08:08:23 +01:00
Ludovic Marie 67f18b252b Merge #2372 2026-10-27 08:08:13 +01:00
Ludovic Marie de0314e88e Merge #2371 2026-10-27 08:08:13 +01:00
Ludovic Marie 46147f9f41 Merge #2370 2026-10-27 08:08:12 +01:00
Ludovic Marie bb8b7bdd64 Merge #2369
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/hud_layout.rs
2026-10-27 08:08:12 +01:00
Ludovic Marie 0011cb63db Merge #2368 2026-10-27 08:07:55 +01:00
Ludovic Marie ddfe91fa18 Merge #2367 2026-10-27 08:07:55 +01:00
Ludovic Marie 0706f9ca44 Merge #2366 2026-10-27 08:07:55 +01:00
Ludovic Marie 83454a3bcb Merge #2365 2026-10-27 08:07:54 +01:00
Ludovic Marie 13e1b831a5 Merge #2364 2026-10-27 08:07:54 +01:00
Ludovic Marie ff7bc961d5 Merge #2360 2026-10-27 08:07:54 +01:00
Ludovic Marie a625c07232 Merge #2358 2026-10-27 08:07:54 +01:00
Ludovic Marie 28d41d7cc4 Merge #2325 2026-10-27 08:07:54 +01:00
Ludovic Marie acba915498 Merge #2690 2026-10-27 08:01:34 +01:00
Ludovic Marie 1f4e3e611c Merge #2688
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 08:01:34 +01:00
Ludovic Marie 05f48119ee Merge #2687 2026-10-27 08:01:17 +01:00
Ludovic Marie d32f9f8451 Merge #2686
# Conflicts:
#	crates/horizon-client/src/game/natural_spawning.rs
2026-10-27 08:01:17 +01:00
Ludovic Marie 9d134df84b Merge #2685
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/mobs/piglin.rs
#	crates/horizon-client/src/mobs/sync.rs
#	crates/horizon-client/src/mobs/wither.rs
2026-10-27 08:00:27 +01:00
Ludovic Marie aef77dfbea Merge #2684
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/goals.rs
2026-10-27 07:56:46 +01:00
Ludovic Marie a5a33fe9ad Merge #2683
# Conflicts:
#	crates/horizon-client/src/mobs/living.rs
2026-10-27 07:56:22 +01:00
Ludovic Marie c8f280c65e Merge #2682
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/item_draw.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/lib.rs
#	crates/horizon-client/src/mobs/anim.rs
2026-10-27 07:54:14 +01:00
Ludovic Marie ce244b4e86 Merge #2680 2026-10-27 07:53:44 +01:00
Ludovic Marie af5b887b94 Merge #2679
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-27 07:53:44 +01:00
Ludovic Marie 7b16eb4729 Merge #2678 2026-10-27 07:53:20 +01:00
Ludovic Marie 547125972c Merge #2677
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 07:52:36 +01:00
Ludovic Marie f5be4c37cc Merge #2676
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:51:38 +01:00
Ludovic Marie 25a55c23a2 Merge #2675 2026-10-27 07:51:26 +01:00
Ludovic Marie 35b2d274ec Merge #2674 2026-10-27 07:51:26 +01:00
Ludovic Marie c868fd8774 Merge #2673
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:51:26 +01:00
Ludovic Marie 28410379f8 Merge #2672 2026-10-27 07:51:15 +01:00
Ludovic Marie c9567418a7 Merge #2671 2026-10-27 07:51:15 +01:00
Ludovic Marie c4054278e6 Merge #2670 2026-10-27 07:51:15 +01:00
Ludovic Marie a136178ff8 Merge #2669 2026-10-27 07:51:15 +01:00
Ludovic Marie 9f6d1d2f97 Merge #2668 2026-10-27 07:51:15 +01:00
Ludovic Marie 1d72029a0c Merge #2667 2026-10-27 07:51:15 +01:00
Ludovic Marie ab7c7ad45f Merge #2666
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/lan.rs
2026-10-27 07:51:15 +01:00
Ludovic Marie 1f99864eb0 Merge #2665 2026-10-27 07:51:08 +01:00
Ludovic Marie edcc980bc6 Merge #2663
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 07:51:08 +01:00
Ludovic Marie 3e1896f5d2 Merge #2662
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:50:54 +01:00
Ludovic Marie 341453a7e3 Merge #2661
# Conflicts:
#	crates/horizon-client/src/game/chat.rs
2026-10-27 07:50:00 +01:00
Ludovic Marie 71be471fc3 Merge #2660 2026-10-27 07:49:31 +01:00
Ludovic Marie 5fc44858ee Merge #2659
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:49:31 +01:00
Ludovic Marie f609dc97d5 Merge #2658
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 07:49:23 +01:00
Ludovic Marie 2cf3e00cd4 Merge #2657
# Conflicts:
#	crates/horizon-client/src/game/hand.rs
2026-10-27 07:49:14 +01:00
Ludovic Marie 3e55b98477 Merge #2656
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
2026-10-27 07:47:27 +01:00
Ludovic Marie d32a944e67 Merge #2655 2026-10-27 07:46:56 +01:00
Ludovic Marie 96beca1079 Merge #2654 2026-10-27 07:46:56 +01:00
Ludovic Marie 2e0bed63b3 Merge #2653
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
2026-10-27 07:46:56 +01:00
Ludovic Marie a94596db72 Merge #2652 2026-10-27 07:46:37 +01:00
Ludovic Marie 7b55cec0a8 Merge #2651 2026-10-27 07:46:37 +01:00
Ludovic Marie ce1aab0899 Merge #2650 2026-10-27 07:46:36 +01:00
Ludovic Marie 82fbb7a627 Merge #2649
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
2026-10-27 07:46:36 +01:00
Ludovic Marie fc47c8a3a2 Merge #2648 2026-10-27 07:46:27 +01:00
Ludovic Marie 2286fdbc60 Merge #2647 2026-10-27 07:46:27 +01:00
Ludovic Marie cc217871f1 Merge #2646 2026-10-27 07:46:27 +01:00
Ludovic Marie ed23f2d936 Merge #2644 2026-10-27 07:46:26 +01:00
Ludovic Marie 204c02eee4 Merge #2643 2026-10-27 07:46:26 +01:00
Ludovic Marie 90aa64c8db Merge #2642 2026-10-27 07:46:26 +01:00
Ludovic Marie 10e04403b9 Merge #2641 2026-10-27 07:46:26 +01:00
Ludovic Marie 5410d116ec Merge #2640 2026-10-27 07:46:25 +01:00
Ludovic Marie 4ae05891a3 Merge #2418 2026-10-27 07:46:01 +01:00
Ludovic Marie 56638bb53b Merge #2417
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/item_tooltip.rs
2026-10-27 07:46:01 +01:00
Ludovic Marie ab44332995 Merge #2416 2026-10-27 07:45:37 +01:00
Ludovic Marie 6c85d24c4d Merge #2415 2026-10-27 07:45:37 +01:00
Ludovic Marie c7d7a8cb2a Merge #2412
# Conflicts:
#	crates/horizon-client/src/animate_tick.rs
2026-10-27 07:44:21 +01:00
Ludovic Marie 7d65bc7cc6 Merge #2411 2026-10-27 07:43:56 +01:00
Ludovic Marie e3e6a8f394 Merge #2410 2026-10-27 07:43:56 +01:00
Ludovic Marie a3a726ad94 Merge #2409 2026-10-27 07:43:56 +01:00
Ludovic Marie d0246ac72f Merge #2408
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/remote.rs
2026-10-27 07:43:56 +01:00
Ludovic Marie 888309df58 Merge #2407
# Conflicts:
#	crates/horizon-client/src/lan/hub.rs
2026-10-27 07:43:42 +01:00
Ludovic Marie 678c3ebb2b Merge #2406
# Conflicts:
#	crates/horizon-client/src/motion.rs
2026-10-27 07:43:33 +01:00
Ludovic Marie 331cfa7999 Merge #2405 2026-10-27 07:43:03 +01:00
Ludovic Marie 448755e217 Merge #2404 2026-10-27 07:43:03 +01:00
Ludovic MarieandClaude Opus 5.5 9c4e9f4f92 fixup #2403: one owner check for the cat's relax goal
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:43:03 +01:00
Ludovic Marie 4669b24c96 Merge #2403
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 07:40:23 +01:00
Ludovic Marie f0dd74e11f Merge #2402
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/body_anim.rs
2026-10-27 07:39:24 +01:00
Ludovic Marie 2cf0000ec4 Merge #2401 2026-10-27 07:39:11 +01:00
Ludovic Marie 1b710cabdc Merge #2400 2026-10-27 07:39:11 +01:00
Ludovic Marie 0c3b10ba8a Merge #2399
# Conflicts:
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 07:39:11 +01:00
Ludovic Marie 09d15bf632 Merge #2398 2026-10-27 07:38:45 +01:00
Ludovic Marie a5bd59a751 Merge #2397
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:38:45 +01:00
Ludovic Marie cf324e6a8f Merge #2396 2026-10-27 07:38:30 +01:00
Ludovic Marie 66ce553f18 Merge #2395 2026-10-27 07:38:30 +01:00
Ludovic Marie 2179d7cfe8 Merge #2394
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/lan_actions.rs
2026-10-27 07:38:30 +01:00
Ludovic MarieandClaude Opus 5.5 2c65d6ae36 fixup #2310 #2382: the death drop's thrower flag, one parrot save arm
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:38:19 +01:00
Ludovic Marie d2b56b1a2e Merge #2393
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/mobs/drops.rs
2026-10-27 07:35:30 +01:00
Ludovic Marie 35b88b0e59 Merge #2392 2026-10-27 07:34:25 +01:00
Ludovic Marie 4c015db67c Merge #2390 2026-10-27 07:34:25 +01:00
Ludovic Marie a3a57ef113 Merge #2389
# Conflicts:
#	crates/horizon-client/src/biome.rs
2026-10-27 07:34:24 +01:00
Ludovic Marie 39a762615d Merge #2388
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/screens.rs
2026-10-27 07:33:52 +01:00
Ludovic Marie bf7e612d0a Merge #2387
# Conflicts:
#	crates/horizon-client/src/save.rs
2026-10-27 07:33:28 +01:00
Ludovic Marie a616484cf0 Merge #2386 2026-10-27 07:33:16 +01:00
Ludovic Marie 9098a6af83 Merge #2385 2026-10-27 07:33:16 +01:00
Ludovic Marie 66c15775e5 Merge #2383 2026-10-27 07:33:16 +01:00
Ludovic Marie 8fb9f53fa9 Merge #2382
# Conflicts:
#	crates/horizon-client/src/entity_nbt.rs
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/tame.rs
2026-10-27 07:33:15 +01:00
Ludovic Marie a21814f11a Merge #2381 2026-10-27 07:31:46 +01:00
Ludovic Marie 80d981d89a Merge #2380
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-27 07:31:46 +01:00
Ludovic Marie f0317722df Merge #2379 2026-10-27 07:31:36 +01:00
Ludovic Marie e5e686249c Merge #2378 2026-10-27 07:31:36 +01:00
Ludovic Marie cd9269bd8c Merge #2377 2026-10-27 07:31:36 +01:00
Ludovic Marie be502bfd99 Merge #2376
# Conflicts:
#	crates/horizon-client/src/mobs/finalize.rs
2026-10-27 07:31:36 +01:00
Ludovic Marie d1cf1449d2 Merge #2375
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:31:23 +01:00
Ludovic Marie 86e7028570 Merge #2363
# Conflicts:
#	crates/horizon-client/src/game/commands/execute.rs
2026-10-27 07:30:32 +01:00
Ludovic Marie cce529a06f Merge #2362 2026-10-27 07:30:06 +01:00
Ludovic Marie 3c370e7e95 Merge #2361 2026-10-27 07:30:06 +01:00
Ludovic Marie 5d870b8e66 Merge #2359
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 07:30:06 +01:00
Ludovic Marie a027fb6e51 Merge #2357
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:29:56 +01:00
Ludovic Marie 1a5f14ec6e Merge #2356
# Conflicts:
#	crates/horizon-client/src/game/remote_projectiles.rs
2026-10-27 07:29:43 +01:00
Ludovic MarieandClaude Opus 5.5 5b7e545f5c fixup #2324: a trident's size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:29:24 +01:00
Ludovic Marie e3a5fd3ce6 Merge #2355
# Conflicts:
#	crates/horizon-client/src/game/initial_spawn.rs
2026-10-27 07:26:39 +01:00
Ludovic Marie 2bd740b789 Merge #2354 2026-10-27 07:25:54 +01:00
Ludovic Marie aacade14ea Merge #2353 2026-10-27 07:25:54 +01:00
Ludovic Marie 3098632a5b Merge #2352 2026-10-27 07:25:54 +01:00
Ludovic Marie d7414a5916 Merge #2351 2026-10-27 07:25:54 +01:00
Ludovic Marie a3a10ebeb6 Merge #2349
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-27 07:25:54 +01:00
Ludovic Marie 25877a53a2 Merge #2348
# Conflicts:
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 07:25:43 +01:00
Ludovic Marie de5deec490 Merge #2346
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote.rs
2026-10-27 07:25:21 +01:00
Ludovic MarieandClaude Opus 5.5 c636c307a5 Merge main into fix/burn-in-daylight-list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:24:40 +01:00
Ludovic Marie 91c7b37b70 Merge #2344 2026-10-27 07:23:04 +01:00
Ludovic Marie 59bed6f787 Merge #2343
# Conflicts:
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 07:23:04 +01:00
Ludovic Marie f58cfa1a83 Merge #2342
# Conflicts:
#	crates/horizon-client/src/mobs/spawning.rs
2026-10-27 07:22:52 +01:00
Ludovic Marie c66358e4da Merge #2341 2026-10-27 07:21:53 +01:00
Ludovic Marie 889c7c5bb4 Merge #2340 2026-10-27 07:21:53 +01:00
Ludovic Marie 3400326a5b Merge #2339 2026-10-27 07:21:53 +01:00
Ludovic Marie e981be8643 Merge #2338
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-27 07:21:53 +01:00
Ludovic Marie 77712bf584 Merge #2337 2026-10-27 07:21:41 +01:00
Ludovic Marie c65e15b090 Merge #2336 2026-10-27 07:21:41 +01:00
Ludovic Marie 09b9a4f71d Merge #2335
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/enchant_effects.rs
2026-10-27 07:21:41 +01:00
Ludovic Marie 2f3179c9f0 Merge #2334 2026-10-27 07:21:08 +01:00
Ludovic Marie ed456932a6 Merge #2333 2026-10-27 07:21:08 +01:00
Ludovic Marie 3606c1f69c Merge #2332 2026-10-27 07:21:07 +01:00
Ludovic Marie 7ceffa5b09 Merge #2331 2026-10-27 07:21:07 +01:00
Ludovic Marie 14ecb7f776 Merge #2330 2026-10-27 07:21:07 +01:00
Ludovic Marie a398a17b07 Merge #2329 2026-10-27 07:21:07 +01:00
Ludovic Marie e12c42e469 Merge #2328
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-world/src/level/redstone.rs
2026-10-27 07:21:07 +01:00
Ludovic Marie 2a16e36bc8 Merge #2327 2026-10-27 07:20:46 +01:00
Ludovic Marie 567a1da6cd Merge #2326
# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/sulfur_cube.rs
2026-10-27 07:20:46 +01:00
Ludovic Marie 515fa17cca Merge #2324
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-27 07:20:30 +01:00
Ludovic Marie 9a995ad461 Merge #2323 2026-10-27 07:20:08 +01:00
Ludovic Marie 075a903b00 Merge #2322 2026-10-27 07:20:08 +01:00
Ludovic Marie 05ddc43d47 Merge #2321 2026-10-27 07:20:08 +01:00
Ludovic Marie 4c5704d2b4 Merge #2311 2026-10-27 07:20:08 +01:00
Ludovic Marie 2ad7a2b0e1 Merge #2310 2026-10-27 07:20:07 +01:00
Ludovic Marie 5fb64f0d62 Merge #2548 2026-10-27 07:04:23 +01:00
Ludovic Marie 663828ad73 Merge #2537 2026-10-27 07:04:23 +01:00
Ludovic Marie be47fd0f0f Merge #2534 2026-10-27 07:04:23 +01:00
Ludovic Marie 3b75c78a91 Merge #2507 2026-10-27 07:04:23 +01:00
Ludovic Marie 71206c2bcc Merge #2555
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:04:23 +01:00
Ludovic Marie e2dd8489a7 Merge #2554 2026-10-27 07:04:23 +01:00
Ludovic Marie 02e7249bfc Merge #2547 2026-10-27 07:04:23 +01:00
Ludovic Marie 1bd0b885be Merge #2553 2026-10-27 07:04:22 +01:00
Ludovic Marie 705fe123d3 Merge #2540
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie 4611c75a8d Merge #2551
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/control.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie 7ccb4d846a Merge #2538
# Conflicts:
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie 1fdcfdd0c9 Merge #2545
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/sync.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie 8144b31bfd Merge #2539
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/mobs.rs
#	crates/horizon-client/src/mobs/ai.rs
#	crates/horizon-client/src/mobs/sync.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie 7b695ed0c7 Merge #2535
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/remote_mob_data.rs
#	crates/horizon-client/src/mobs/sync.rs
2026-10-27 07:04:22 +01:00
Ludovic Marie c484ad2640 Merge #2533
# Conflicts:
#	crates/horizon-client/src/game/remote_mob_data.rs
2026-10-27 07:04:21 +01:00
Ludovic Marie 2f2c3061cc Merge #2528
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/mobs.rs
2026-10-27 07:04:21 +01:00
Ludovic Marie 2b3629d027 Merge #2531 2026-10-27 07:03:59 +01:00
Ludovic Marie 7be7ee228e Merge #2525 2026-10-27 07:03:59 +01:00
Ludovic Marie 2e85bd6564 Merge #2526 2026-10-27 07:03:59 +01:00
Ludovic Marie 98443995bd Merge #2524 2026-10-27 07:03:59 +01:00
Ludovic Marie c61c103660 Merge #2518 2026-10-27 07:03:59 +01:00
Ludovic Marie c8428161f0 Merge #2517 2026-10-27 07:03:59 +01:00
Ludovic Marie 808d03f302 Merge #2514 2026-10-27 07:03:59 +01:00
Ludovic Marie f8397cbb0a Merge #2513 2026-10-27 07:03:58 +01:00
Ludovic Marie ba0d418637 Merge #2516 2026-10-27 07:03:58 +01:00
Ludovic Marie 4bada9606f Merge #2510 2026-10-27 07:03:58 +01:00
Ludovic Marie e29f2ecd72 Merge #2498 2026-10-27 07:03:58 +01:00
Ludovic Marie 49f87fa6eb Merge #2495 2026-10-27 07:03:58 +01:00
Ludovic Marie f555b61f4c Merge #2521 2026-10-27 07:03:58 +01:00
Ludovic Marie d4e0a0328a Merge #2492 2026-10-27 07:03:58 +01:00
Ludovic Marie 5564b7c275 Merge #2491 2026-10-27 07:03:58 +01:00
Ludovic Marie f0d7bd629e Merge #2490 2026-10-27 07:03:57 +01:00
Ludovic Marie e44b07c075 Merge #2509 2026-10-27 07:03:57 +01:00
Ludovic Marie 222e6024c3 Merge #2569 2026-10-27 07:03:57 +01:00
Ludovic Marie 30fc75a5df Merge #2567 2026-10-27 07:03:57 +01:00
Ludovic Marie 758f5b33fb Merge #2572 2026-10-27 07:03:57 +01:00
Ludovic Marie a01a38fe36 Merge #2529 2026-10-27 07:03:57 +01:00
Ludovic Marie 660ba02e2d Merge #2504 2026-10-27 07:03:57 +01:00
Ludovic Marie 6c8ac2c770 Merge #2503 2026-10-27 07:03:57 +01:00
Ludovic Marie fe16c59bc4 Merge #2523 2026-10-27 07:03:56 +01:00
Ludovic Marie 17f2ddf41c Merge #2520 2026-10-27 07:03:56 +01:00
Ludovic Marie 9e11fc0a87 Merge #2519 2026-10-27 07:03:56 +01:00
Ludovic Marie daaf6c0b48 Merge #2522 2026-10-27 07:03:56 +01:00
Ludovic Marie bb761ecaeb Merge #2508 2026-10-27 07:03:56 +01:00
Ludovic Marie 8b92f8f477 Merge #2580 2026-10-27 07:03:56 +01:00
Ludovic Marie 4f72879c54 Merge #2560 2026-10-27 07:03:56 +01:00
Ludovic Marie 8be1f4233f Merge #2512 2026-10-27 07:03:56 +01:00
Ludovic Marie 49e61f02ed Merge #2511 2026-10-27 07:03:56 +01:00
Ludovic Marie 8934f72ba8 Merge #2505 2026-10-27 07:03:55 +01:00
Ludovic Marie c1ebf5d1b0 Merge pull request #2626 from ludovic111/feat/launcher-instance-commands
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 07:00:52 +01:00
Ludovic MarieandClaude Opus 5.5 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 f4271d7c16 Merge #2492 into the energy swirl branch
#2492 takes overlay code 32 for translucent item quads (33 with glint): the
energy swirl moves to 34 and is the fifth of eight entity materials.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:16 +01:00
Ludovic Marie 0e927e6f11 Merge commit 'refs/cg19/pr/2516' into HEAD 2026-10-27 06:54:01 +01:00
Ludovic Marie 9ad735b9d7 Merge pull request #2625 from ludovic111/feat/launcher-shortcuts
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:51:21 +01:00
Ludovic MarieandClaude Opus 5.5 4d897de20b Play the rain extinguish sound for every player
Entity.applyEffectsFromBlocks plays playEntityOnFireExtinguishedSound for any
player whose fire the rain puts out: a LAN guest or the server thread's player
(the default single player) heard nothing before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:50:26 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Kacpherek fc06585938 Send mob particles to clients the way vanilla does 2026-10-27 06:45:32 +01: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
Kacpherek e5e1b74fd8 Draw the enderman portal trail, phantom wing dust and bee nectar drips 2026-10-27 06:44:37 +01:00
Ludovic MarieandClaude Opus 5.5 5ea88533e0 Merge main into feat/panda-states
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:44 +01:00
Ludovic MarieandClaude Opus 5.5 594394ab30 Merge main into fix/goat-horns-ram
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:31 +01:00
Ludovic MarieandClaude Opus 5.5 c3ef0a06e7 Merge main into fix/frog-croak-tongue
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:15 +01:00
Ludovic MarieandClaude Opus 5.5 531212c812 Merge main into fix/armadillo-roll
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:54 +01:00
Ludovic MarieandClaude Opus 5.5 2e9827a259 Merge main into fix/server-mob-animation-sync-2
The strider's DATA_SUFFOCATING sync is left to #2526, which sends and reads
index 19 already (lan.rs mob_freeze_meta, remote.rs), so both don't send it twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:37 +01:00
Kacpherek 97d119b4e8 Loop the bee buzz and the guardian attack hum like vanilla 2026-10-27 06:39:31 +01:00
Kacpherek 2f6d259615 Put books into a chiseled bookshelf and take them out
Horizon only kept a chiseled bookshelf's saved data, so using it did
nothing. It is now a block entity with six slots and lastInteractedSlot:

- ChiseledBookShelfBlock.useItemOn / useWithoutItem: the slot comes from
  the hit position on the front face (getHitSlot, 3x2). A #bookshelf_books
  item goes into an empty slot (one book, the insert sounds, the used
  statistic), a book comes out to the inventory or the ground, with the
  pickup sounds. The slot_N_occupied states follow
  (ChiseledBookShelfBlockEntity.updateState) and reach a server's clients
  with the block update; the client's prediction ends the click the same way.
- Hoppers and droppers use its container: books only, one to a slot, and the
  occupied state and last slot follow what they moved.
- The comparator reads lastInteractedSlot from the block entity.
2026-10-27 06:38:17 +01:00
Ludovic MarieandClaude Opus 5.5 6c769f38ae Merge main into fix/server-mob-animation-sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:58 +01:00
Kacpherek bca4513187 Keep withers out of Peaceful, raiders out of the spawn count, and use local difficulty for skeleton traps 2026-10-27 06:37:08 +01:00
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
Kacpherek 28b72d46c5 Give turtles vanilla's goals: panic, tempt, go to water, travel and stroll 2026-10-27 06:36:15 +01:00
Kacpherek cece5aca30 Send more mob state to the single player window
A client of a server only draws what the server sends. Send and read the
synched data for sleeping villagers (pose and bed), horses grazing and
rearing, invisible mobs, a carried enderman block, swimming drowned, zombie
villager cures, the villager head shake, zombie drowning conversion,
hoglin and piglin conversion, charged blazes, turtle eggs, baby zoglins and
the wither's side head targets.

A wither's side heads also need a line of sight to shoot, as in vanilla.
2026-10-27 06:36:13 +01:00
Kacpherek 713de31f64 Slow mobs in cobwebs and make magma cubes jump out of lava 2026-10-27 06:36:04 +01:00
Kacpherek e2a48d1c36 Implement the spear's use action: charge, stab, knockback, dismount 2026-10-27 06:36:00 +01:00
Kacpherek b22c42ea85 Stop jockey spiders attacking and shove hoglin targets like vanilla 2026-10-27 06:34:37 +01:00
Ludovic MarieandClaude Opus 5.5 ef0ea5daad Merge main into fix/drowning-bubbles
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:14 +01:00
Ludovic MarieandClaude Opus 5.5 a21cf57a11 Merge main into fix/desert-leaves-ambient
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:34:01 +01:00
Kacpherek 9142f57bc8 Add the spectator menu with Teleport to Player and Teleport to Team Member 2026-10-27 06:33:52 +01:00
Kacpherek 6c36da40e0 Turn skeletons into strays after standing in powder snow 2026-10-27 06:33:50 +01:00
Ludovic MarieandClaude Opus 5.5 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
Kacpherek 306c6625e9 Shear snow golems and bogged by hand and use vanilla's snowball spread 2026-10-27 06:33:07 +01:00
Kacpherek 46f674f842 Give dolphins swim-with-player, jump and treasure goals and fish feeding 2026-10-27 06:32:31 +01:00
Kacpherek dca3bb89fe Show the air bubbles only when the eyes are below the water surface 2026-10-27 06:32:01 +01:00
Kacpherek e74a1fe461 Use every player in the level for patrols, sniffers, allays, trap horses and other world checks on the server thread 2026-10-27 06:31:46 +01:00
Kacpherek 581828bf94 Show the smithing template lines in item tooltips 2026-10-27 06:30:47 +01:00
Kacpherek 2c0d3c1a86 Chat wheel counts right Shift, durability line is translated 2026-10-27 06:30:46 +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 4940a24423 Send the fox's type and flags at indices 18 and 19
Fox extends Animal, and AgeableMob has two entries in 26.3 (DATA_BABY_ID 16,
AGE_LOCKED 17): DATA_TYPE_ID is 18 and DATA_FLAGS_ID 19. An int at 17 is the
wrong type for AGE_LOCKED's boolean on a vanilla client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:20 +01:00
Kacpherek 62556429dc Add the four missing options to the Chat page 2026-10-27 06:30:09 +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 765cef8a4e Put the candle's doc comment back on candle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:56 +01:00
Ludovic MarieandClaude Opus 5.5 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
Kacpherek 29616ad502 Stack toasts in five slots and hide them with F1 2026-10-27 06:29:44 +01:00
Ludovic MarieandClaude Opus 5.5 79fca90886 Tame a pet to the player's real UUID, not an unset placeholder
acting_uuid() falls back to [0, 0, 0, 0] while level.player_uuid is unset (a new
world before its first save); the owner saved then never matches the UUID the
world gets, so the player could no longer command the pet after a reload.
acting_player_uuid() assigns the world's player UUID first, as dropped items do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:42 +01:00
Kacpherek f918fc711a Keep the command history between runs and show the restricted chat prompt 2026-10-27 06:28:21 +01:00
Kacpherek cc0f8f8a50 Track a vehicle as far as its widest passenger 2026-10-27 06:27:49 +01:00
Kacpherek eeceb9c2de Let a fishing hook catch and pull other players 2026-10-27 06:27:40 +01:00
Kacpherek c4427d2a14 Size the mount hearts from the server's max health and fix the jump bar 2026-10-27 06:27:34 +01:00
Kacpherek 2a1132387f Swing the right arm and for the right time on other players 2026-10-27 06:26:53 +01:00
Kacpherek ca83704cbb Add zombie village sieges 2026-10-27 06:26:12 +01:00
Kacpherek 180e2f612a Chat lines: server message bar and tooltip, item hover, run_command clicks, Shift insertion 2026-10-27 06:26:12 +01:00
Kacpherek f2db04bd64 Translate the sleep messages and send the respawn point message only when it changes 2026-10-27 06:26:07 +01:00
Kacpherek 7b84a7ae91 Let a sweep attack hurt and push back the other players next to the target 2026-10-27 06:25:43 +01:00
Kacpherek ffad9e1f0a Keep the styling of the death message on the death screen 2026-10-27 06:25:21 +01:00
Kacpherek 1131a2910d Apply Haste and Mining Fatigue to the attack speed 2026-10-27 06:24:43 +01:00
Kacpherek 18217532af Show other players gliding, swimming, spinning and holding a loaded crossbow 2026-10-27 06:24:15 +01:00
Kacpherek 6d69bba15e Remote mining test fixture: the held-item key and the attack strength reset 2026-10-27 06:24:05 +01:00
Kacpherek 71b2f110eb Show the account name on the book signing screen 2026-10-27 06:23:59 +01:00
Kacpherek b6c77e12f3 Hide invisible guests in the host window 2026-10-27 06:23:43 +01:00
Kacpherek 5bbb61aec2 Angry endermen open their mouth on a server thread 2026-10-27 06:23:23 +01:00
Kacpherek 1f98f3ca89 Open the Leave Bed chat screen while the player sleeps 2026-10-27 06:23:22 +01:00
Kacpherek 0cd3410a2d Squeeze hotbar slots that gain items and draw the bundle fullness bar 2026-10-27 06:23:01 +01:00
Kacpherek 4afb5748f2 Let a player's axe hit disable the victim's shield 2026-10-27 06:22:13 +01:00
Kacpherek 6b1d376e6f Show the sleeping players count on the action bar of a LAN host or server 2026-10-27 06:22:11 +01:00
Kacpherek 008c4f91cd Treat single player as a local server for the tab list and F3 charts 2026-10-27 06:21:37 +01:00
Kacpherek 85f34393fe Save and load creative toolbars with the toolbar keys 2026-10-27 06:21:01 +01:00
Kacpherek 59bdb00599 Husk, cave spider, wither skeleton, bee and pufferfish effects only come when the hit lands 2026-10-27 06:19:41 +01:00
Ludovic MarieandClaude Opus 5.5 73aef0ce8e Merge main into fix/mining-hit-sound-level-events
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:17:53 +01:00
Kacpherek 29ab66935e Restart a break when the held item changes and reset the attack strength when it stops 2026-10-27 06:17:48 +01:00
Kacpherek 80fd68e1c2 Send the dragon's breath cloud particle to the client 2026-10-27 06:16:45 +01:00
Kacpherek f92cf816b1 Pick blocks and entities on the server like vanilla 2026-10-27 06:16:32 +01:00
Kacpherek ff28fd8390 Read a chiseled bookshelf's last slot and a shelf's back mask with a comparator 2026-10-27 06:13:40 +01:00
Kacpherek 0c9bddecda Let block hoppers move items into and out of minecart containers 2026-10-27 06:11:01 +01:00
Kacpherek a19cad22cb A mob jumps out of a fluid only where Entity.isFree allows: no liquid in the box 2026-10-27 05:51:10 +01:00
Ludovic MarieandClaude Opus 5.5 7115c708e1 launcher: pre-launch command, wrapper and environment per instance
Home's Instance row gets Launch...: a shell command run in the instance's
folder before the game (which starts only if it succeeds), a wrapper the
game runs through, and environment variables, kept in instances.json.
The game stays one process: /bin/sh runs the command and execs the
wrapper and game through "$@" on Unix, cmd /C on Windows. The window's
Play and `play` both use them. CLI and MCP: instance launch (confirm
needed over MCP). The Instance row's actions move under its name, and
editing an instance that shares its name with an import works again.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:02:20 +01:00
Kacpherek 76bebec833 Stop vexes suffocating inside blocks 2026-10-27 01:01:53 +01: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
Kacpherek 4e82a62489 Vexes hold their sword at the small size Java uses 2026-10-27 00:01:54 +01:00
Kacpherek 9ada76e00a Restore the lam-alef madda and hamza ligature rows in FORMS 2026-10-26 23:41:13 +01:00
Kacpherek 2c894783aa Bubble columns lift and drag mobs, items and ender pearls 2026-10-26 23:37:14 +01:00
Kacpherek 9266e0c7c9 Mobs are pushed by flowing water and lava 2026-10-26 23:34:03 +01:00
Kacpherek 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
Kacpherek 909f15b909 Count the warden tendril and heart animations down again 2026-10-26 23:22:03 +01:00
Kacpherek da57350af5 Pandas: genes, sit, roll, lie on back, sneeze and unhappy states, synced to clients
A panda now has vanilla's states: it sits to eat bamboo or cake it picks up or is
given, a lazy one lies on its back, a playful one or a cub rolls over, a cub
sneezes, and a panda that is fed with no bamboo around sulks. A worried panda
sits and shivers in a thunderstorm. The genes show in its coat, pass to its
young, and are saved. The flags, counters and genes go to clients as entity
data, and the model, the item in its mouth, the crumbs and the sneeze are drawn
from them, in game and in a LAN guest's window.
2026-10-26 23:19:43 +01: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 400d9e6115 Show goats' lost horns and let them lower their head and ram 2026-10-26 23:11:56 +01:00
Kacpherek 04b0b9aed5 Let frogs croak, shoot their tongue at small slimes and idle-swim 2026-10-26 23:06:30 +01:00
Kacpherek 5e27fd3421 Use the mob level event that main already has 2026-10-26 23:04:35 +01:00
Kacpherek e544924504 Draw entity outlines for the Glowing effect
Glowing entities are drawn as team-coloured silhouettes into an outline
target with no depth test, then vanilla's entity_outline chain (sobel,
box blur across and up) is blended over the frame after the hand.
Mobs, players, item entities and projectiles get outlines. The server
sends shared flag 6 for mobs and items, and the remote client reads it.
2026-10-26 23:03:39 +01:00
Kacpherek 29754b857d Merge origin/main into feat/fox-states 2026-10-26 23:02:27 +01:00
Kacpherek 8144385ce6 Give foxes their stalk, pounce, faceplant, shelter and perch states
The fox flags were only ever set for sleeping. Add the goals that drive
the rest of Fox.DATA_FLAGS_ID and show them on the client:

- StalkPreyGoal, FoxPounceGoal, FaceplantGoal: creep up on a chicken or
  rabbit, crouch, leap with the flight tilt and bite, or end up with the
  nose in snow (with its block crumbs).
- SeekShelterGoal and PerchAndSearchGoal.
- The fox's own conditions on the shared goals (melee, look at player) and
  clearStates on float, breed and follow parent; FoxMoveControl and
  FoxLookControl waits.
- The head tilt and crouch amount ease on the client from the synched
  flags, and the model, body tilt and sleeping, snow and baby textures
  follow them.
2026-10-26 23:02:03 +01:00
Kacpherek 11e1ff12d0 Let armadillos roll up when scared, peek out and take less damage as a ball 2026-10-26 22:59:54 +01:00
Kacpherek 26aef75d7c Shape and reorder right-to-left text before drawing it 2026-10-26 22:58:52 +01:00
Kacpherek 25c53cc803 Server-thread clients animate ravager, hoglin, iron golem, creaking, vex, allay, axolotl, guardian, copper golem and strider
Builds on the warden, sniffer, breeze, camel and shulker sync. Each of these mobs
now sends its synched data and entity events, and the client runs the animation
steps vanilla runs on the client.
2026-10-26 22:56:31 +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 a45d0de43e Server-thread clients animate warden, sniffer, breeze, camel and shulker
A client of a server (single player's window included) never got the
animation state these mobs compute on the server: no pose, no sniffer
state, no shulker peek or wall face, no camel sit or dash, no warden
tendril, attack or sonic boom events.

Send Entity.DATA_POSE and the species' synched data from mob_meta,
broadcast the warden's entity events 4, 61 and 62, read both in the
remote mob code, and run the client animation steps in
mobs::sync::client_tick.
2026-10-26 22:48:27 +01:00
Kacpherek 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 f6db8b1f56 Scroll and add the energy swirl of charged creepers and powered withers 2026-10-26 22:44:16 +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 426b3c83cf Play the sand idle and poplar leaves ambient sounds 2026-10-26 22:40:47 +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 a146754b0b Make the Particles option thin every particle, explosion debris and rain splash
The Particles option only changed two effects. Follow ClientLevel.doAddParticle
and calculateParticleLevel in the one add path: Decreased drops a third, Minimal
drops all but the always-visible and limiter-overriding particles. Explosion
block debris is thrown only on All, and rain splashes halve on Decreased and
stop on Minimal.
2026-10-26 22:28:10 +01:00
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 d2a8cf3bdf Keep a hurt pet's attacker as its player instead of swapping in the owner 2026-10-26 22:05:02 +01:00
Kacpherek e9797d69d0 Use the berry damage type for sweet berry bushes and check the whole move for contact 2026-10-26 21:51:25 +01:00
Kacpherek 4b4361fbc0 Clear a piglin's hoglin target once it dies or disappears 2026-10-26 21:48:20 +01:00
Kacpherek b0ce58adea Check the cat's owner and sync lying and relaxing to clients 2026-10-26 21:48:18 +01:00
Kacpherek 77fe538e09 Let only the owner command a tame wolf or cat, and make pets follow their owner
Sitting, standing, dyeing and healing a cat or wolf now check the saved
owner. The follow, teleport and owner-hurt goals use the owner, not the
nearest player, and a new tame wolf or cat is owned by whoever tamed it.
2026-10-26 21:48:05 +01:00
Kacpherek d8b8246622 Light redstone ore under dropped items too 2026-10-26 21:46:31 +01:00
Kacpherek 35a02c79a8 Cats also skip chests a copper golem has open 2026-10-26 21:46:18 +01:00
Kacpherek db985f717c Spectator crosshair also checks the other half of a double chest 2026-10-26 21:46:04 +01:00
Kacpherek e41c602359 Keep picking up a banner next to a raider even when its path was cached as unreachable 2026-10-26 21:46:00 +01:00
Kacpherek 6145077d7e Make a tame parrot answer only to its owner 2026-10-26 21:44:11 +01:00
Kacpherek 512e003cbd Wolf armor: check the acting player's UUID and keep the armor's components 2026-10-26 21:43:50 +01:00
Kacpherek 75267d2fc6 Read inline goat horn instruments as well as registry ids 2026-10-26 21:43:33 +01:00
Kacpherek daee960ba2 Send an armor stand's default pose when it is reset 2026-10-26 21:43:21 +01:00
Kacpherek b33ae69938 Find the End podium level from its rim so a broken centre column is fine 2026-10-26 21:43:08 +01:00
Kacpherek 2a297a43eb Credit the feeder with a parrot's cookie kill 2026-10-26 21:42:51 +01:00
Kacpherek a529be8915 Keep translucent item rendering with enchantment glint 2026-10-26 21:40:21 +01:00
Kacpherek 7eaa5f592b Send jukebox play and stop events to guests so their parrots dance 2026-10-26 21:38:21 +01:00
Kacpherek be714b38bb Only let a tame parrot land on its saved owner 2026-10-26 21:38:16 +01:00
Kacpherek 1a5e5eb358 Owner combat goals only follow the wolf's own owner 2026-10-26 21:37:58 +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 414a2b82a8 Do not rebuild the End exit podium once the fight has its location 2026-10-26 21:30:57 +01:00
Kacpherek 9958fe0e49 Light redstone ore when an entity stands on it 2026-10-26 21:29:26 +01:00
Kacpherek fba004eb3d Skip leaves in the no-leaves heightmap for firefly bush sounds 2026-10-26 21:22:41 +01:00
Kacpherek 2d70e55fbb Load the respawn search's candidate chunks instead of skipping unloaded ones 2026-10-26 21:21:42 +01:00
Kacpherek d0df238972 Include equipment modifiers in a mob's knockback resistance for the sonic boom push 2026-10-26 21:19:49 +01:00
Kacpherek 3fe301aaa1 Record the dragon's attacker before the crystal blast and only for applied damage 2026-10-26 21:17:18 +01:00
Kacpherek d97b58fd4a Send the thrower's entity id with a tracked trident 2026-10-26 21:15:49 +01:00
Kacpherek 4e96037c78 Never show fire-immune mobs as on fire 2026-10-26 20:42:58 +01:00
Kacpherek fbdd04d817 Use armor stand, boat, cart and item eye heights for facing entity eyes 2026-10-26 19:58:03 +01:00
Kacpherek aed4e26f59 Apply the legacy announce-player-achievements key
The key was parsed and dropped. It now sets the show_advancement_messages game rule when the dedicated server starts, like DedicatedServer.initServer.
2026-10-26 19:54:23 +01:00
Kacpherek 9b88df32d3 Make goat horns play their sound and show the horn pose
A goat horn did nothing when used. It now plays its instrument at the
player, is held up for the instrument's duration, goes on cooldown, posts
the instrument_play game event and counts as used. Other players hear it,
see the arm raised to the head, and the tooltip names the call.
2026-10-26 19:54:15 +01:00
Kacpherek b5adad15eb Stop the riptide ground lift at ceilings 2026-10-26 19:53:42 +01:00
Kacpherek d17df3ca05 Give /op the level from op-permission-level
The console's /op always granted level 4. It now grants the permission level set in server.properties, like PlayerList.op.
2026-10-26 19:53:41 +01:00
Kacpherek 2d72e987c5 Play ambient sounds and spawn fireflies around firefly bushes, eyeblossoms, pale moss and dry plants 2026-10-26 19:53:15 +01:00
Kacpherek 5b066b1b78 Send the host's death and advancement messages to guests and follow team visibility 2026-10-26 19:53:09 +01:00
Kacpherek 9ae3882c1a Kick unlisted players when enforce-whitelist is on
Whitelist remove, reload and on, and deop, now disconnect online players who are not on the whitelist, like MinecraftServer.kickUnlistedPlayers.
2026-10-26 19:52:46 +01:00
Kacpherek 89c195fa6e Don't back off from edges while flying 2026-10-26 19:51:46 +01:00
Kacpherek de808e3612 Empty a bundle by holding right click 2026-10-26 19:51:31 +01:00
Kacpherek 4575f4f1df Honour spawn-protection on a dedicated server
Players who are not operators can no longer start digging or use an item on a block within the radius of the world spawn. They get the spawn protection message and the client gets the block back, like DedicatedServer.isUnderSpawnProtection.
2026-10-26 19:51:18 +01:00
Kacpherek 54b1c4f941 Make climbing follow vanilla: no climbing while flying, open trapdoors over ladders climb 2026-10-26 19:51:03 +01:00
Kacpherek 8a309c393d Show join and leave messages in a LAN host's own chat 2026-10-26 19:50:50 +01:00
Kacpherek 58a99a2ce9 Sulfur cubes bounce, slide and keep speed by their block's archetype 2026-10-26 19:49:41 +01:00
Kacpherek 23019a7bf9 Cats lie beside a sleeping owner and may leave a morning gift 2026-10-26 19:48:21 +01:00
Kacpherek 8aad511579 Drop the cursor stack when the dead player respawns 2026-10-26 19:46:01 +01:00
Kacpherek 94600b266f Format the fox module order 2026-10-26 19:44:07 +01:00
Kacpherek 78a5ee0e55 Let parrots imitate nearby mobs and dance to a jukebox
Parrots only ever made their own sound. Follow Parrot.aiStep: one tick in
400 a parrot imitates a random mob within 20 blocks (half the time), and
its ambient sound is sometimes a mob's voice. A parrot within 3.46 blocks
of a playing jukebox now uses the party pose (level events 1010 and 1011).
2026-10-26 19:43:53 +01:00
Kacpherek 63ee634ee0 Apply an armor stand's saved pose
The Pose tag of an armor stand is now read and saved, drawn on the stand and its armor, and sent to clients on the server thread.
2026-10-26 19:43:42 +01:00
Kacpherek 752138e866 Cats sit on a chest only while nobody has it open, and on a bed's foot only 2026-10-26 19:43:27 +01:00
Kacpherek c30d51d65e Wolves fight other owners' tamed wolves 2026-10-26 19:41:45 +01:00
Kacpherek c439b806b8 Let a compass lock onto a lodestone
Using a compass on a lodestone now sets its lodestone target (in place for a single compass in survival, otherwise one is split off), and the target clears when the lodestone is gone. The target also travels in item packets on the server thread.
2026-10-26 19:40:59 +01:00
Kacpherek 50fdcad10f A cookie poisons and kills a parrot 2026-10-26 19:40:42 +01:00
Kacpherek c74626448c Show the crosshair to spectators over containers, stations and portals 2026-10-26 19:40:06 +01:00
Kacpherek 2886b8f9ba Horse armor reduces the damage a horse takes 2026-10-26 19:39:31 +01:00
Kacpherek 9ca7162169 Let a tame parrot sit on its owner's shoulder
A tame parrot never landed on its owner. Add LandOnOwnersShoulderGoal:
when the parrot touches its owner, it is stored in the player's
ShoulderEntityLeft or ShoulderEntityRight and removed from the world.
It comes back, still tame, when the player falls, swims, flies, sleeps,
is hurt, dies, turns spectator or spins with a riptide.

The shoulder data is the player's own save data, so it also works for
players on the integrated server and for LAN guests.
2026-10-26 19:39:14 +01:00
Kacpherek de2df51193 Wolf armor: put it on, repair it, shear it off, and let it take the damage 2026-10-26 19:38:43 +01:00
Kacpherek dc36089156 Apply the set_name loot function to generated items 2026-10-26 19:37:51 +01:00
Kacpherek d248ab2605 Give foxes their own goals: flee, hunt, nap in the shade and eat berries
Foxes used the generic animal goals: they panicked at 1.25, never
avoided players or wolves, never hunted, slept, bred or ate berries.
Add mobs/fox.rs with the goals Fox.registerGoals sets up that Horizon
can run today, send the fox variant and flags to clients so the
sleeping pose shows, and let a baby fox take either parent's variant.
2026-10-26 19:37:15 +01:00
Kacpherek 08c959807d Broken heads keep their skin and silk-touched hives keep their bees and honey 2026-10-26 19:37:01 +01:00
Kacpherek 796dc320d9 Use the height adjusted biome temperature for the rain and snow line on the client 2026-10-26 19:36:45 +01:00
Kacpherek f1949c3bda Ask before the death screen's Title Screen button leaves the world 2026-10-26 19:36:39 +01:00
Kacpherek 4d9ead721e Point the recovery compass at the player's last death
The local player's last death location is now recorded, saved as LastDeathLocation, taken from the Login and Respawn packets on the server thread, and given to the item owner.
2026-10-26 19:36:35 +01:00
Kacpherek 10d2b202d5 Push the other players too with a mace smash 2026-10-26 19:35:53 +01:00
Kacpherek b8d0590d6d Lightning flash: mix the sky colour toward the flash tint instead of replacing it 2026-10-26 19:34:47 +01:00
Kacpherek 36ebd0c545 Follow vanilla's lava fog for Fire Resistance and spectators, and brighten the fog colour with Night Vision 2026-10-26 19:34:12 +01:00
Kacpherek bc2dd35cc3 Raise the held item name and action bar above extra heart, armor and mount rows 2026-10-26 19:33:57 +01:00
Kacpherek b5a735c8d1 Let tame parrots follow their owner and sit when told to
A tamed parrot ignored its owner: it never followed, and right-clicking
it did nothing. Give the parrot the goals of Parrot.registerGoals
(SitWhenOrderedToGoal, FollowOwnerGoal 1.0, 5.0, 1.0), let the owner
toggle sitting with a click while it stands on the ground, and keep the
owner and sitting state in the save file and in the data a guest gets.
2026-10-26 19:33:51 +01:00
Kacpherek 8c0ed0e591 Let a comparator read an item frame through a block
A comparator behind a solid block now reads the item frame on its far side (rotation + 1, or 0 when empty), and updates when the frame's item or rotation changes.
2026-10-26 19:33:24 +01:00
Kacpherek 849b5b663b Let a sculk catalyst take the experience of mobs killed without a player 2026-10-26 19:33:09 +01:00
Kacpherek b65b0a6a7d Dim the sun and moon in rain and turn the moon texture the right way up 2026-10-26 19:32:59 +01:00
Kacpherek 97f8bca9da Raiders skip ominous banners they cannot reach
A captainless raider walked towards the nearest fallen ominous banner
even when no path led to it. Check that a path reaches the banner before
going for it, and skip a banner without one for 600 ticks, like vanilla.
2026-10-26 19:32:41 +01:00
Kacpherek f1181d4a25 Trial chamber mobs spawn with the gear their spawner's equipment table gives 2026-10-26 19:32:16 +01:00
Kacpherek c5a37c02de Push sonic boom targets only when hit, by their knockback resistance 2026-10-26 19:32:01 +01:00
Kacpherek 74dc389321 Keep the effect icons on screen over the pause menu, chat and other screens 2026-10-26 19:31:55 +01:00
Kacpherek 7d7a85d184 Recruit nearby raiders only while the raid pathfinding goal runs
Raiders recruited other raiders every 20 ticks whether or not they were
heading for the raid. In vanilla the recruiting is part of
PathfindToRaidGoal.tick, so a raider that has a target or carries a
rider does not recruit. Do the recruiting from the goal's tick.
2026-10-26 19:31:28 +01:00
Kacpherek de1e5f7292 Spin the player arrow on a Nether map
Vanilla's MapItemSavedData.calculateRotation gives the arrow a changing rotation in the Nether. The player marker used the yaw there too, so it never spun.
2026-10-26 19:31:10 +01:00
Kacpherek 9bc05faf53 Tamed wolves heal twice the food's nutrition 2026-10-26 19:30:54 +01:00
Kacpherek 575cef1d26 Let a vindicator named Johnny attack other raiders too
Vanilla's Johnny goal targets any living entity that can be attacked.
Horizon skipped pillagers, vindicators, witches, evokers, illusioners
and ravagers, so Johnny left them alone. Remove that filter.
2026-10-26 19:30:38 +01:00
Kacpherek 0cc3f567fc Play the bubble pop sound when an air bubble bursts 2026-10-26 19:30:29 +01:00
Kacpherek cb97c5496e Raiders in a raid no longer count idle time twice as fast
A raider's idle counter grew by 4 per tick while in a raid, so it
dropped out of the raid after about 700 ticks instead of 1400 and could
no longer be recruited sooner than in vanilla. Remove the extra tick
added by the raid bookkeeping; the counter now grows by 3 like vanilla.
2026-10-26 19:30:07 +01:00
Kacpherek 8ca15ae2ca Apply /xp, /clear and /spawnpoint to each target, not only the host 2026-10-26 19:23:47 +01:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek 10872bdd64 Add the tridents field to the remote motion test fixture 2026-10-26 19:22:53 +01:00
Kacpherek 3b24aceb04 Let /clone move work on overlapping regions 2026-10-26 19:21:53 +01:00
Kacpherek a4ccf205f7 Throw what /give cannot fit from the player's eyes, for that player only 2026-10-26 19:21:09 +01:00
Kacpherek 9dbd0a4baf Make the strafing dragon charge its fireball only with a clear line of sight 2026-10-26 19:19:36 +01:00
Kacpherek 9aabeb39ee Make /tp facing look from the feet, or the eyes under anchored eyes 2026-10-26 19:18:50 +01:00
Kacpherek 605e8a1488 Stop shulkers from targeting and shooting in Peaceful 2026-10-26 19:18:08 +01:00
Kacpherek e6587dce6a Respawn at once without a death screen when immediate_respawn is on 2026-10-26 19:17:05 +01:00
Kacpherek dfd0601163 Teleport a dragon egg when it is left-clicked 2026-10-26 19:16:52 +01:00
Kacpherek 034af45777 Make adult piglins hunt adult hoglins 2026-10-26 19:15:33 +01:00
Kacpherek 0b3e40f9d4 Give a closed shulker its 20 covered armor 2026-10-26 19:15:28 +01:00
Kacpherek a44581eaf3 Let a shulker bullet's levitation run out on mobs 2026-10-26 19:14:23 +01:00
Kacpherek 886a84dea0 Fly thrown tridents on by themselves between server updates 2026-10-26 19:14:04 +01:00
Kacpherek 79c3896449 Respawn on a free column within the respawn radius around the world spawn 2026-10-26 19:13:58 +01:00
Kacpherek a45c73bb36 Credit the player who kills the ender dragon 2026-10-26 19:12:49 +01:00
Kacpherek 6823e34451 Let a piglin let go of the gold it admires when a player hits it 2026-10-26 19:12:31 +01:00
Kacpherek f25fbb6f2e Give zombified piglins that spawn in a nether portal a portal cooldown 2026-10-26 19:11:24 +01:00
Kacpherek 872409ea57 Mined ice melts into water unless the tool has silk touch 2026-10-26 19:08:19 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Kacpherek 2277c85fc3 Run neighbour updates when a mob trampling farmland places dirt 2026-10-26 19:07:02 +01:00
Kacpherek e98d939919 Use the mob's own chunk for its local difficulty 2026-10-26 19:06:59 +01:00
Kacpherek 81b4c09ee6 Count the endermite lifetime with NoAI set 2026-10-26 19:06:53 +01:00
Kacpherek 5b89508fe9 Predict putting out a lit campfire with a shovel on the client
In single player and LAN the client never recognized the shovel on a lit
campfire, so there was no smoke and a shield in the other hand was raised
too. The client now shows the smoke, puts the fire out locally and ends
the click after the hand that doused.
2026-10-26 18:58:36 +01:00
Kacpherek b92dd80566 Let silverfish hide in stone and wake their friends when hurt 2026-10-26 18:55:02 +01:00
Kacpherek 4084bcdf75 Let a brush dust suspicious sand and gravel 2026-10-26 18:54:11 +01:00
Kacpherek 4eb95c4d3e Follow the burn in daylight tag for mobs that burn in the sun 2026-10-26 18:53:26 +01:00
Kacpherek 5657080884 Swell a creeper's fuse when it falls 2026-10-26 18:51:45 +01:00
Kacpherek 708702917c Remove an endermite after 2400 ticks 2026-10-26 18:51:16 +01:00
Kacpherek 4c8ff02969 Make skeleton arrow spread and damage follow the difficulty 2026-10-26 18:51:03 +01:00
Kacpherek fe167781c4 Give the fishing rain bonus only where rain falls 2026-10-26 18:50:36 +01:00
Kacpherek fe8565b874 Scale cave spider poison and husk hunger with the difficulty 2026-10-26 18:50:05 +01:00
Kacpherek 0d02a4c722 Let a caught fish be picked up right away 2026-10-26 18:50:04 +01:00
Kacpherek 9d4c984e03 Speed up an enderman while it has a target 2026-10-26 18:49:31 +01:00
Kacpherek e4cd56ed7b Make Frost Walker freeze water and protect from magma 2026-10-26 18:49:17 +01:00
Kacpherek b32989cf1e Wear shears on mooshrooms and apply Unbreaking to held tool wear 2026-10-26 18:49:13 +01:00
Kacpherek f2424043f3 Stop zombie drowning conversion when it leaves the water 2026-10-26 18:49:12 +01:00
Kacpherek fece9d85e3 Spawn village and swamp hut cats 2026-10-26 18:48:18 +01:00
Kacpherek 45996d8d78 Match the farmer's requested items to vanilla
The farmer's list had food, torchflower seeds and pitcher pods and lacked bone meal. Vanilla requests wheat, wheat seeds, beetroot seeds and bone meal. Food is already picked up and traded through its villager food value.
2026-10-26 18:48:10 +01:00
Kacpherek 7569f9615a Treat unbreakable items as undamageable in the anvil and grindstone 2026-10-26 18:47:58 +01:00
Kacpherek 90501f9ab4 Keep leashed mobs and carrying endermen from despawning
Mob.requiresCustomPersistence also covers a leashed mob and an enderman holding a block, and the spawn caps skip such mobs. Gather the existing cases in one helper, add the missing ones, and use it for both the despawn check and the cap count.
2026-10-26 18:46:46 +01:00
Kacpherek c1844dfe57 Let mobs trample farmland when they land on it
A cow dropped onto farmland left it untouched. A landing mob now turns
farmland to dirt with chance fall distance - 0.5, when mob griefing is on and
the mob is big enough, and is lifted onto the dirt.
2026-10-26 18:46:37 +01:00
Kacpherek bf527e7a13 Apply knockback resistance to Punch arrows 2026-10-26 18:46:36 +01:00
Kacpherek 472da6509d Projectiles ring bells they hit 2026-10-26 18:45:57 +01:00
Kacpherek dbef78bcb8 Apply Unbreaking to armor wear from Thorns
Thorns always cost the worn piece 2 durability. Run it through the same
durability loss as other wear so Unbreaking can cancel points.
2026-10-26 18:45:54 +01:00
Kacpherek b283f2ad31 Hurt mobs that touch a cactus or a sweet berry bush
Mobs ignored both blocks. A mob inside a cactus now takes 1 damage a tick,
and a mob inside a sweet berry bush is slowed (0.8, 0.75, 0.8) and, in a
grown bush, takes 1 damage when it moved at least 0.003 on X or Z. Foxes
and bees are left alone.
2026-10-26 18:45:44 +01:00
Kacpherek 6dcf4b3e8b Use the real local difficulty for the phantom spawn check
The phantom spawn gate assumed a fixed effective difficulty of 2. Use the local difficulty at the player instead, so easy, hard, a long-played chunk and the moon phase change how often phantoms spawn.
2026-10-26 18:45:43 +01:00
Kacpherek bf508c2842 Let mobs, dropped items and projectiles trigger pressure plates and tripwires 2026-10-26 18:45:39 +01:00
Kacpherek 564b230ab1 Make a warden's hit disable the player's shield
A warden holds no weapon, so its blows never put the shield on cooldown. Warden.getSecondsToDisableBlocking is a flat 5 seconds, so use that when the attacker is a warden.
2026-10-26 18:45:13 +01:00
Kacpherek 555ddb94bf Use the impaling entity tag for thrown tridents
The bonus went to every swimming or water-breathing mob, including undead,
blazes, ghasts and iron golems. Read the damage from the enchantment data so
only #sensitive_to_impaling mobs take it.
2026-10-26 18:45:13 +01:00
Kacpherek e92937d956 Apply Slowness with Bane of Arthropods
Post-attack effects only handled ignite, so the apply_mob_effect effect of
Bane of Arthropods never did anything. Handle apply_mob_effect like vanilla.
2026-10-26 18:44:23 +01:00
Kacpherek a6d2eb9d64 Shulkers keep their body still while the head turns 2026-10-26 18:17:27 +01:00
Kacpherek d18197a176 Shield: hold it up in first person and show the block pose on other players 2026-10-26 18:14:24 +01:00
Kacpherek b28108128d Keep a hopper minecart's items in sync with a leftover menu copy 2026-10-26 16:33:08 +01:00
Kacpherek dd3697bb4d Hopper minecarts take from the container above, one item per tick 2026-10-26 15:16:43 +01:00
253 changed files with 20263 additions and 1950 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
launcher, `HORIZON_LAUNCHER_TRASH`, `HORIZON_LAUNCHER_CLIPBOARD` and `HORIZON_LAUNCHER_DESKTOP` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
Generated
+7
View File
@@ -1398,6 +1398,7 @@ dependencies = [
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
"winit",
@@ -3767,6 +3768,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 -6
View File
@@ -174,19 +174,31 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
_ => None,
};
}
// HoverEvent.ShowText.
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
&& e.get("action").and_then(Value::as_str) == Some("show_text")
&& let Some(value) = e.get("value")
{
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
// HoverEvent.ShowText and ShowItem.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
Some("show_item") if e.get("id").is_some_and(Value::is_string) => {
style.hover_item = Some(Box::new(Value::Object(e.clone())));
}
_ => {}
}
}
style.insertion = o
.get("insertion")
.and_then(Value::as_str)
.map(str::to_owned);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
+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())
}
+16 -4
View File
@@ -662,10 +662,22 @@ pub fn name_shown(own: Option<&Team>, viewer: Option<&Team>, v: NameView) -> boo
}
!v.hud_hidden && !v.is_camera && visible && !v.is_vehicle
}
/// `ServerPlayer.die`: whether a player's death message reaches the player
/// themselves (the team broadcasts leave out the dying player).
pub fn own_death_message_shown(team: Option<&Team>) -> bool {
team.is_none_or(|t| t.death_message_visibility == Visibility::Always)
/// `ServerPlayer.die`: whether `other` hears the death message of `dying`
/// (`broadcastSystemMessage`, `broadcastSystemToTeam` or
/// `broadcastSystemToAllExceptTeam`, by the team's death message visibility).
pub fn death_message_reaches(board: &Scoreboard, dying: &str, other: &str) -> bool {
let Some(team) = board.players_team(dying) else {
return true;
};
let same_team = board
.players_team(other)
.is_some_and(|t| t.name == team.name);
match team.death_message_visibility {
Visibility::Always => true,
Visibility::Never => false,
Visibility::HideForOtherTeams => same_team && other != dying,
Visibility::HideForOwnTeam => !same_team,
}
}
// ---- objectives and scores ---------------------------------------------
+24 -1
View File
@@ -43,6 +43,8 @@ pub fn named_color(name: &str) -> Option<u32> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClickEvent {
SuggestCommand(String),
/// `ClickEvent.RunCommand`: the command, with or without its `/`.
RunCommand(String),
CopyToClipboard(String),
/// A local file to open with the system handler.
/// Only allow this in locally constructed messages, never server/LAN text.
@@ -61,6 +63,10 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// `HoverEvent.ShowItem`: the event's `{id, count?, components?}` object.
pub hover_item: Option<Box<Value>>,
/// `Style.insertion`: the text a Shift-click puts in the chat box.
pub insertion: Option<String>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
@@ -90,6 +96,12 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.hover_item.is_none() {
self.hover_item.clone_from(&base.hover_item);
}
if self.insertion.is_none() {
self.insertion.clone_from(&base.insertion);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
@@ -495,7 +507,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 9] = [
const STYLE_KEYS: [&str; 10] = [
"color",
"bold",
"italic",
@@ -504,6 +516,7 @@ const STYLE_KEYS: [&str; 9] = [
"obfuscated",
"click_event",
"hover_event",
"insertion",
"font",
];
/// Style.Serializer: the fields a style sets.
@@ -535,6 +548,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
ClickEvent::CopyToClipboard(value) => {
json!({ "action": "copy_to_clipboard", "value": value })
}
ClickEvent::RunCommand(command) => {
json!({ "action": "run_command", "command": command })
}
ClickEvent::OpenFile(path) => json!({ "action": "open_file", "path": path }),
};
out.push(("click_event".to_owned(), event));
@@ -544,6 +560,13 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
"hover_event".to_owned(),
json!({ "action": "show_text", "value": hover.component() }),
));
} else if let Some(Value::Object(item)) = style.hover_item.as_deref() {
let mut event = item.clone();
event.insert("action".into(), "show_item".into());
out.push(("hover_event".to_owned(), Value::Object(event)));
}
if let Some(insertion) = &style.insertion {
out.push(("insertion".to_owned(), insertion.clone().into()));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
+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).
@@ -24,6 +24,7 @@ fn stack(spec: &str) -> LootStack {
exploration: None,
ominous_amplifier: None,
stew_effects: Vec::new(),
names: Vec::new(),
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
@@ -223,6 +223,7 @@ fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
..HealthHud::default()
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
@@ -74,6 +74,9 @@ pub fn main() -> Result<()> {
biomes: &biomes,
registry: assets.registry,
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
};
engine.seed_randoms(1057, 0, 0);
@@ -568,6 +568,10 @@ fn print_heart(
fn player_damage(e: &MobEvent) -> f32 {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount,
MobEvent::SonicBoom {
target: Who::Player,
..
} => 10.0,
_ => 0.0,
}
}
@@ -579,6 +583,10 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
MobEvent::SonicBoom {
target: Who::Player,
..
} => true,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
let d = Vec3::new(at[0], at[1], at[2])
@@ -1045,6 +1053,7 @@ pub fn main() -> horizon_client::Result<()> {
alive: true,
creative: s.creative,
sneaking: false,
swimming: false,
look: [0.0, 0.0, 1.0],
main_hand: s.hand.as_deref().map(Into::into),
off_hand: None,
@@ -1056,11 +1065,15 @@ pub fn main() -> horizon_client::Result<()> {
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
sprinting: false,
riding: false,
hero: false,
effects: Vec::new(),
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1070,6 +1083,8 @@ pub fn main() -> horizon_client::Result<()> {
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m, s.creative, world.game_time);
}
// No player has a chest open in the scenarios.
let lid_openers = std::collections::HashMap::new();
// The MobWorld every step sees, over a snapshot of the mobs.
macro_rules! mob_world {
($snapshot:expr) => {
@@ -1081,6 +1096,7 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
@@ -1104,6 +1120,7 @@ pub fn main() -> horizon_client::Result<()> {
// FlatLevelSource.getSeaLevel.
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
}
};
}
+316 -27
View File
@@ -36,8 +36,9 @@ enum Animate {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
/// the biome foliage colour unless a fixed colour is given (azalea); and
/// whether they have the poplar ambient sound (`AmbientLeavesBlockSoundPlayer`).
Leaves(Option<(T, f32)>, Option<u32>, bool),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -48,8 +49,9 @@ enum Animate {
CandleCake,
BrewingStand,
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// FallingBlock with its dust colour; sand also has the ambient desert sound
/// (`SandBlock.animateTick`).
Falling(u32, bool),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
@@ -73,6 +75,16 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
FireflyBush,
/// An open eyeblossom (`Type.OPEN.emitSounds()`).
OpenEyeblossom,
PaleHangingMoss,
/// `DryVegetationBlock`: a dead bush.
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -107,17 +119,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
}
"spruce_leaves" => Animate::Leaves(None, None),
"spruce_leaves" => Animate::Leaves(None, None, false),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -126,16 +138,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -167,6 +180,15 @@ 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, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -233,6 +255,14 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `#triggers_ambient_desert_dry_vegetation_block_sounds`.
desert_dry_vegetation_sounds: Vec<bool>,
terracotta: Vec<bool>,
pale_oak_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
@@ -308,6 +338,13 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
desert_dry_vegetation_sounds: by_state(
"minecraft:triggers_ambient_desert_dry_vegetation_block_sounds",
),
terracotta: by_state("minecraft:terracotta"),
pale_oak_logs: by_state("minecraft:pale_oak_logs"),
burnable,
ambient,
})
@@ -333,6 +370,12 @@ pub struct AnimateContext<'a> {
pub registry: &'a BlockRegistry,
/// `Level.isRainingAt`.
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// `getHeight(MOTION_BLOCKING_NO_LEAVES, x, z)` at a position's column.
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
}
@@ -383,6 +426,12 @@ impl AnimateContext<'_> {
],
))
}
/// `Level.getMaxLocalRawBrightness`: the brighter of block light and
/// sky light less the sky darkening.
fn max_local_raw_brightness(&self, p: [i32; 3]) -> i32 {
let light = self.world.light(p[0], p[1], p[2]);
i32::from(light & 15).max(i32::from(light >> 4) - self.sky_darken)
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -396,6 +445,10 @@ impl AnimateContext<'_> {
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add(&ParticleOptions::simple(ty), p, v);
}
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
}
@@ -434,7 +487,7 @@ fn do_animate_tick(
return;
}
let state = ctx.state(pos);
block_animate_tick(tables, engine, ctx, state, pos);
block_animate_tick(tables, engine, ctx, state, pos, center);
let facts = ctx.facts.get(state);
if facts.fluid != Fluid::None {
fluid_animate_tick(
@@ -590,6 +643,22 @@ fn beehive_drip(
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayDesertDryVegetationBlockSounds`:
/// sand or terracotta two blocks deep.
fn desert_dry_vegetation_sounds(
tables: &AnimateTables,
ctx: &AnimateContext,
below: [i32; 3],
) -> bool {
let triggers = |p: [i32; 3]| {
tables
.desert_dry_vegetation_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
triggers(below) && triggers([below[0], below[1] - 1, below[2]])
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -665,6 +734,7 @@ fn block_animate_tick(
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
player: [i32; 3],
) {
let (fx, fy, fz) = (f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
match tables.kind(state) {
@@ -771,7 +841,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint) => {
Animate::Leaves(falling, fixed_tint, poplar) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -783,6 +853,9 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -955,7 +1028,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::SulfurBubbles, p, [0.0; 3]);
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -963,6 +1036,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 {
@@ -1021,6 +1124,99 @@ fn block_animate_tick(
}
}
}
Animate::FireflyBush => {
// FireflyBushBlock.animateTick: the idle sound on a quiet night
// under open sky, and fireflies in the dark within 5 blocks.
if next_int(engine.random(), 30) == 0
&& ctx.firefly_bush_sounds
&& (ctx.height_no_leaves)(pos) <= pos[1]
{
let name = "minecraft:block.firefly_bush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
if ctx.max_local_raw_brightness(pos) <= 13 {
let r = engine.random();
if r.next_double() <= 0.7 {
let p = [
fx + r.next_double() * 10.0 - 5.0,
fy + r.next_double() * 5.0,
fz + r.next_double() * 10.0 - 5.0,
];
add(engine, T::Firefly, p, [0.0; 3]);
}
}
}
Animate::OpenEyeblossom => {
// EyeblossomBlock.animateTick: pale moss below.
if next_int(engine.random(), 700) == 0 {
let below = ctx.state([pos[0], pos[1] - 1, pos[2]]);
if ctx
.registry
.state(below)
.is_some_and(|s| s.name == "minecraft:pale_moss_block")
{
let name = "minecraft:block.eyeblossom.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::PaleHangingMoss => {
// HangingMossBlock.animateTick: under pale oak logs or leaves.
if next_int(engine.random(), 500) == 0 {
let above = ctx.state([pos[0], pos[1] + 1, pos[2]]);
let pale_leaves = ctx
.registry
.state(above)
.is_some_and(|s| s.name == "minecraft:pale_oak_leaves");
if pale_leaves
|| tables
.pale_oak_logs
.get(above.0 as usize)
.copied()
.unwrap_or(false)
{
let name = "minecraft:block.pale_hanging_moss.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
let below = [pos[0], pos[1] - 1, pos[2]];
let state = ctx.state(below);
let red_sand = ctx
.registry
.state(state)
.is_some_and(|s| s.name == "minecraft:red_sand");
let terracotta = tables
.terracotta
.get(state.0 as usize)
.copied()
.unwrap_or(false);
if (red_sand || terracotta) && next_int(engine.random(), 3) != 0 {
return;
}
if desert_dry_vegetation_sounds(tables, ctx, below) {
let name = "minecraft:block.deadbush.idle";
sound(engine, name, [fx, fy, fz], 1.0, 1.0);
}
}
}
Animate::DryGrass => {
// AmbientDesertBlockSoundsPlayer.playAmbientDryGrassSounds:
// played at the player.
if next_int(engine.random(), 200) == 0
&& desert_dry_vegetation_sounds(tables, ctx, [pos[0], pos[1] - 1, pos[2]])
{
let at = [
f64::from(player[0]) + 0.5,
f64::from(player[1]),
f64::from(player[2]) + 0.5,
];
sound(engine, "minecraft:block.dry_grass.ambient", at, 1.0, 1.0);
}
}
Animate::EndPortal => {
// EndPortalBlock.animateTick: one smoke particle above the surface.
let r = engine.random();
@@ -1045,7 +1241,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color) => {
Animate::Falling(color, sand) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -1062,6 +1258,14 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
@@ -1179,7 +1383,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1192,7 +1396,7 @@ fn block_animate_tick(
);
}
} else {
add(
add_visible(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1204,7 +1408,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1282,6 +1486,91 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
-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(
+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(),
)
}
+7
View File
@@ -66,6 +66,13 @@ impl BiomeInfo {
}
.height_adjusted(pos, sea_level)
}
/// `Biome.getPrecipitationAt` in the Overworld (sea level 63): None
/// without precipitation, else whether it falls as snow
/// (`getHeightAdjustedTemperature` below 0.15).
pub fn snows_at(&self, x: i32, y: i32, z: i32) -> Option<bool> {
self.precipitation
.then(|| self.temperature_at(BlockPos::new(x, y, z), 63) < 0.15)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
@@ -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)]
+14 -1
View File
@@ -50,10 +50,12 @@ impl Weight {
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
/// insert at 1, 1.
/// insert at 1, 1; the drop of its contents is played at the player's block
/// like remove one.
pub const INSERT: &str = "minecraft:item.bundle.insert";
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
pub const DROP_CONTENTS: &str = "minecraft:item.bundle.drop_contents";
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
@@ -211,6 +213,17 @@ impl Contents {
self.weight.n >= self.weight.d
}
/// `BundleItem.isBarVisible`, `getBarWidth` and `getBarColor`: the fullness bar
/// (width, RGB), blue until the bundle is full, then red.
pub fn bar(&self) -> Option<(i32, u32)> {
if self.weight.n <= 0 {
return None;
}
let width = (1 + self.weight.n * 12 / self.weight.d).min(13) as i32;
let color = if self.is_full() { 0xFF5454 } else { 0x7087FF };
Some((width, color))
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
let data = bundle.data_mut();
+125 -12
View File
@@ -16,6 +16,9 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
@@ -80,6 +83,10 @@ impl ChatAbilities {
ChatSource::Client => true,
}
}
/// `ChatAbilities.hasAnyRestrictions`: the Chat option takes something.
pub fn any_restriction(&self) -> bool {
*self != Self::new(ChatVisibility::Full)
}
/// Whether the chat screen may send `input` (`hasAllowedInput`).
pub fn allows(&self, input: &str) -> bool {
if input.starts_with('/') {
@@ -185,6 +192,10 @@ pub struct Chat {
lines: Vec<Line>,
/// Sent messages and commands (ChatComponent.recentChat).
pub recent: Vec<String>,
/// `CommandHistory`: the last commands, kept in `command_history.txt`
/// in the game folder once `load_history` has set it.
commands: Vec<String>,
history_file: Option<std::path::PathBuf>,
scroll: usize,
new_since_scroll: bool,
pub options: ChatOptions,
@@ -310,6 +321,17 @@ impl Chat {
self.lines.clear();
self.scroll = 0;
}
/// `new CommandHistory(gameFolder)`: the commands kept from earlier runs
/// start the chat's history (`ChatComponent`'s constructor).
pub fn load_history(&mut self, game_folder: &std::path::Path) {
let file = game_folder.join(COMMAND_HISTORY_FILE);
if let Ok(text) = std::fs::read_to_string(&file) {
self.commands = text.lines().map(str::to_owned).collect();
self.recent.extend(self.commands.iter().cloned());
}
self.history_file = Some(file);
}
/// `ChatComponent.addRecentChat`: a command also goes to the saved history.
pub fn add_recent(&mut self, message: &str) {
if self.recent.last().map(String::as_str) != Some(message) {
if self.recent.len() >= 100 {
@@ -317,6 +339,21 @@ impl Chat {
}
self.recent.push(message.to_owned());
}
if message.starts_with('/') && self.commands.last().map(String::as_str) != Some(message) {
if self.commands.len() >= MAX_PERSISTED_COMMAND_HISTORY {
self.commands.remove(0);
}
self.commands.push(message.to_owned());
if let Some(file) = &self.history_file {
let mut text = self.commands.join("\n");
text.push('\n');
if let Err(e) = std::fs::write(file, text) {
log::error!(
"Failed to write {COMMAND_HISTORY_FILE}, command history will be missing: {e}"
);
}
}
}
}
pub fn reset_scroll(&mut self) {
self.scroll = 0;
@@ -438,10 +475,71 @@ impl Chat {
out.push(GuiDraw::Pop);
out
}
/// Draws [`Chat::layout`] into the HUD batch.
/// Draws [`Chat::layout`] into the HUD batch, and with the chat screen
/// open the restricted prompt above the lines.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
hud_layout::paint(batch, atlas, &prompt);
}
}
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
/// background row and the red, underlined "Chat is restricted" line in
/// the row above the messages, clipped to the chat's width.
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
let o = &self.options;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let shown = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let top = chat_bottom - (shown as i32 + 1) * entry_height;
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
// ComponentRenderUtils.clipText.
let mut width = 0.0;
let fits = prompt
.chars()
.take_while(|(c, style)| {
width += font.advance(*c, style);
width <= max_width as f32
})
.count();
if fits < prompt.chars().count() {
prompt = prompt.slice(0, fits);
}
vec![
GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
]),
hud_layout::fill(
-2,
top,
max_width + 4 + 4,
top + entry_height,
hud_layout::as8(o.background_opacity as f32) << 24,
),
GuiDraw::Text {
text: prompt,
x: 0,
y: top + entry_height - bottom_to_text - 1,
color: hud_layout::white(o.opacity as f32 * 0.9 + 0.1),
shadow: true,
},
GuiDraw::Pop,
]
}
/// The click action under a GUI-space cursor while chat is focused.
pub fn click_at(
@@ -458,26 +556,34 @@ impl Chat {
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
self.style_at(font, height, x, y)?.hover.map(|h| *h)
}
/// ChatComponent.getClickedComponentStyleAt.
fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
/// The wrapped line under a GUI-space cursor while chat is focused, and
/// the cursor's x within it (the text starts at 0).
fn line_at(&self, height: i32, x: f64, y: f64) -> Option<(&Line, f64)> {
let scale = self.options.scale as f32;
let mut x = x / f64::from(scale) - 4.0;
let x = x / f64::from(scale) - 4.0;
let bottom = hud_layout::floor((height - 40) as f32 / scale);
let from_bottom = f64::from(bottom) - y / f64::from(scale);
if x < 0.0 || x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
if x >= f64::from(self.max_width()) || from_bottom <= 0.0 {
return None;
}
let row = (from_bottom / f64::from(self.options.line_height())).ceil() as usize - 1;
if row >= self.lines_per_page(true) {
return None;
}
let line = self.lines.get(self.scroll + row)?;
Some((self.lines.get(self.scroll + row)?, x))
}
/// ChatComponent.getClickedComponentStyleAt.
pub fn style_at(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> Option<crate::text::Style> {
let (line, mut x) = self.line_at(height, x, y)?;
if x < 0.0 {
return None;
}
let content = line.content.resolve_legacy();
for (c, style) in content.chars() {
x -= f64::from(font.advance(c, style));
@@ -487,6 +593,13 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
self.messages
@@ -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 {
+13 -2
View File
@@ -63,13 +63,24 @@ impl Confirm {
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
labels: [&Text; 2],
active: bool,
) {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
let hover = ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, true, hover);
let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
}
+23
View File
@@ -101,6 +101,22 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
pub items: [Option<ItemStack>; 6],
pub last_slot: i32,
}
impl Default for ChiseledBookshelf {
fn default() -> Self {
Self {
items: Default::default(),
last_slot: -1,
}
}
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
@@ -381,6 +397,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -420,6 +439,7 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -443,6 +463,7 @@ impl BlockEntity {
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -459,6 +480,7 @@ impl BlockEntity {
| Self::Skull(_)
| Self::DaylightDetector => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -483,6 +505,7 @@ impl BlockEntity {
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+3 -2
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);
}
}
+32 -5
View File
@@ -19,6 +19,8 @@ pub struct ItemEntity {
pub id: u32,
pub stack: ItemStack,
pub thrower: Option<[i32; 4]>,
/// `ItemEntity.target`: the only player who may pick the item up.
pub target: Option<[i32; 4]>,
pub position: Vec3,
pub previous: Vec3,
pub velocity: Vec3,
@@ -355,7 +357,10 @@ pub fn merge_with_neighbours(
}
// ItemEntity.areMergable: both fit one stack of the same item and components.
let (this, other) = (&items[i].stack, &items[j].stack);
if this.count + other.count > max_stack(other) || !this.same_item_same_components(other) {
if items[i].target != items[j].target
|| this.count + other.count > max_stack(other)
|| !this.same_item_same_components(other)
{
continue;
}
let (to, from) = if other.count < this.count {
@@ -582,17 +587,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
@@ -767,9 +776,24 @@ pub struct ArmorStand {
pub name_visible: bool,
/// Game time of the last hit (the wobble and the second-hit window).
pub last_hit: i64,
/// `DATA_HEAD_POSE` to `DATA_RIGHT_LEG_POSE`: head, body, left arm, right
/// arm, left leg, right leg, in degrees (`POSE_KEYS` order).
pub pose: [[f32; 3]; 6],
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// The `Pose` compound's keys.
pub const POSE_KEYS: [&'static str; 6] =
["Head", "Body", "LeftArm", "RightArm", "LeftLeg", "RightLeg"];
/// `ArmorStand.DEFAULT_*_POSE`.
pub const DEFAULT_POSE: [[f32; 3]; 6] = [
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
[-10.0, 0.0, -10.0],
[-15.0, 0.0, 10.0],
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
@@ -788,6 +812,7 @@ impl ArmorStand {
custom_name: None,
name_visible: false,
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
}
}
pub fn environment_tick(
@@ -931,6 +956,7 @@ mod fire_environment_tests {
let make = |id: &str| ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count: 1,
@@ -1027,6 +1053,7 @@ mod merge_tests {
ItemEntity {
id: 0,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from(id),
count,
@@ -189,6 +189,7 @@ mod tests {
extra: None,
},
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+22 -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));
@@ -219,11 +219,32 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no
// normal, so no cardinal light) wherever the texture is not fully clear.
@fragment fn fs_outline(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a == 0.0 { discard; }
return vec4(in.color.rgb, 1.0);
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@fragment fn fs_water_mask() -> @location(0) vec4<f32> {
return vec4(0.0);
+200 -5
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)),
@@ -278,6 +303,18 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:panda" => {
use crate::mobs::panda::GENES;
for (key, gene) in [
("MainGene", m.extra.variant),
("HiddenGene", m.extra.variant2),
] {
map.insert(
key.into(),
string(GENES[usize::from(gene).min(GENES.len() - 1)]),
);
}
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
@@ -426,7 +463,42 @@ 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))),
);
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
}
"minecraft:parrot" => {
map.insert(
"Variant".into(),
Tag::Int(i32::from(m.extra.variant.min(4))),
);
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
} else {
map.remove(&"Owner".into());
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
}
"minecraft:salmon" => {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
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();
@@ -450,7 +522,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"memories",
Tag::compound([(
"minecraft:liked_player",
Tag::compound([("value", Tag::IntArray(vec![0, 0, 0, 1]))]),
Tag::compound([(
"value",
Tag::IntArray(m.extra.owner.unwrap_or([0, 0, 0, 1]).to_vec()),
)]),
)]),
)]),
);
@@ -500,6 +575,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("life_ticks".into(), Tag::Int(m.extra.egg_time));
}
}
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
"minecraft:shulker" => {
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
@@ -606,6 +684,17 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A wolf's armor keeps the durability it lost.
if slot == "body"
&& m.species() == crate::mobs::Species::Wolf
&& let Some(id) = item
{
let mut stack = crate::items::ItemStack::new(id, 1);
stack.damage = m.extra.body_damage.max(0) as u32;
stack.extra = m.extra.body_extra.clone();
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
match item {
Some(id) => {
let keep = matches!(
@@ -854,6 +943,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 {
@@ -950,6 +1058,16 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Panda {
let gene = |key| text(tag.get(key)).map_or(0, |n| crate::mobs::panda::gene_from_name(&n));
m.extra.variant = gene("MainGene");
m.extra.variant2 = gene("HiddenGene");
}
if m.species() == crate::mobs::Species::Parrot {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
@@ -957,6 +1075,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
crate::mobs::tame::set_tame(&mut m, tame);
m.health = health;
m.extra.sitting = flag(tag, "Sitting");
let armor = tag
.get("equipment")
.and_then(|e| e.get("body"))
.and_then(crate::save::item_from_nbt);
m.extra.body_damage = armor.as_ref().map_or(0, |s| s.damage as i32);
m.extra.body_extra = armor.and_then(|s| s.extra);
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
@@ -985,6 +1109,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 {
@@ -994,11 +1119,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.ints[1] = number(first.get("count")).unwrap_or(1) as i32;
}
m.extra.egg_time = number(tag.get("DuplicationCooldown")).unwrap_or(0) as i32;
m.extra.tame = tag
let liked = tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:liked_player"))
.is_some();
.and_then(|b| b.get("minecraft:liked_player"));
m.extra.tame = liked.is_some();
m.extra.owner = liked
.and_then(|l| l.get("value"))
.and_then(|v| crate::save::uuid_from_tag(Some(v)));
}
if crate::mobs::camel::is_camel(m.species()) {
let sitting = matches!(tag.get("LastPoseTick"), Some(Tag::Long(v)) if *v < 0);
@@ -1070,6 +1198,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
m.extra.anim_ticks = number(tag.get("RoarTick")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Endermite {
m.extra.counter = number(tag.get("Lifetime")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Vex {
if let Some(Tag::IntArray(v)) = tag.get("bound_pos")
&& v.len() == 3
@@ -1160,6 +1291,11 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Thrower".into());
}
if let Some(target) = e.target {
put(&mut tag, "Owner", Tag::IntArray(target.to_vec()));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"Owner".into());
}
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
@@ -1171,6 +1307,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemE
id,
stack,
thrower: uuid_from_tag(tag.get("Thrower")),
target: uuid_from_tag(tag.get("Owner")),
position,
previous: position,
velocity: motion(tag),
@@ -1689,6 +1826,15 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
}
put(&mut tag, "Health", Tag::Float(a.health.to_bits()));
put(&mut tag, "Fire", Tag::Short(a.fire_ticks as i16));
// ArmorStand.addAdditionalSaveData: the `Pose` parts that are not default.
let pose: Vec<(&str, Tag)> = ArmorStand::POSE_KEYS
.into_iter()
.zip(a.pose)
.zip(ArmorStand::DEFAULT_POSE)
.filter(|((_, rotation), default)| rotation != default)
.map(|((key, rotation), _)| (key, floats(&rotation)))
.collect();
put(&mut tag, "Pose", Tag::compound(pose));
// LivingEntity.addAdditionalSaveData and ArmorStand's own fields.
if let Tag::Compound(map) = &mut tag {
for (key, value) in [
@@ -1728,6 +1874,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
a.on_ground = flag(tag, "OnGround");
// ArmorStand.readAdditionalSaveData: each part of `Pose` that is a list of
// three numbers, the others staying at their defaults.
if let Some(pose) = tag.get("Pose") {
for (rotation, key) in a.pose.iter_mut().zip(ArmorStand::POSE_KEYS) {
if let Some(Tag::List(values)) = pose.get(key)
&& values.len() == 3
{
for (r, v) in rotation.iter_mut().zip(values) {
*r = float(Some(v)).unwrap_or(0.0) as f32;
}
}
}
}
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
@@ -2058,6 +2217,7 @@ mod tests {
health: 2,
fire_ticks: 73,
thrower: Some([5, 6, 7, 8]),
target: None,
id: 7,
stack: ItemStack {
item: Arc::from("minecraft:diamond_pickaxe"),
@@ -2166,6 +2326,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
+17
View File
@@ -26,6 +26,13 @@ pub enum ItemPose {
Bow {
ticks_used: f32,
},
/// A spear being charged (`ItemUseAnimation.SPEAR`): the time held and
/// the ticks since its last hit's feedback.
Spear {
kinetic: crate::spear::Kinetic,
time_held: f32,
ticks_since_hit: f32,
},
Crossbow {
ticks_used: Option<f32>,
charge_duration: f32,
@@ -69,6 +76,16 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
if matches!(pose, ItemPose::Using) {
return m;
}
// The spear's own place in the hand, without the equip height.
if let ItemPose::Spear {
kinetic,
time_held,
ticks_since_hit,
} = pose
{
return Mat4::from_translation(Vec3::new(invert * 0.56, -0.52, -0.72))
* crate::spear::first_person_use(&kinetic, ticks_since_hit, time_held, !left);
}
if matches!(pose, ItemPose::SpinAttack) {
return m
* Mat4::from_translation(Vec3::new(invert * -0.4, 0.8, 0.3))
+25 -5
View File
@@ -203,6 +203,13 @@ pub struct FogInputs<'a> {
/// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with
/// `createWorldFog` is shown (the Ender Dragon's).
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
pub fire_resistance: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -272,7 +279,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
color[1] += (color[1] * 0.6 - color[1]) * t;
color[2] += (color[2] * 0.6 - color[2]) * t;
}
let brighten = if eye_in_water { water_vision } else { 0.0 };
let brighten = if eye_in_water {
water_vision
} else {
input.night_vision
};
if color.iter().all(|&c| c != 0.0) {
let scale = 1.0 / color[0].max(color[1]).max(color[2]);
color = color.map(|c| c + (c * scale - c) * brighten);
@@ -288,10 +299,16 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
};
match input.fog_type {
FogType::Lava => {
fog.environmental_start = 0.25;
fog.environmental_end = 1.0;
fog.sky_end = 1.0;
fog.cloud_end = 1.0;
// LavaFogEnvironment.setupFog.
(fog.environmental_start, fog.environmental_end) = if input.spectator {
(-8.0, rd * 0.5)
} else if input.fire_resistance {
(0.0, 5.0)
} else {
(0.25, 1.0)
};
fog.sky_end = fog.environmental_end;
fog.cloud_end = fog.environmental_end;
}
FogType::PowderSnow => {
fog.environmental_start = 0.0;
@@ -405,6 +422,9 @@ mod tests {
darkness: None,
darken_world: 0.0,
boss_world_fog: false,
spectator: false,
fire_resistance: false,
night_vision: 0.0,
}
}
#[test]
+8 -3
View File
@@ -889,12 +889,17 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
pub fn width_text(&self, t: &Text) -> f32 {
t.resolved()
let t = t.resolved();
crate::bidi::visual(&t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
File diff suppressed because it is too large. Load diff
+99 -39
View File
@@ -21,6 +21,9 @@ pub(super) struct Toast {
frame: Frame,
title: Text,
slide: Slide,
/// `ToastInstance.firstSlotIndex`: the 32-pixel slot it stacks in once it
/// leaves the queue.
slot: Option<usize>,
/// A `RecipeToast`: the crafting station and result of each display
/// unlocked while it lasts (`recipeItems`); empty for an advancement.
recipes: Vec<(String, ItemStack)>,
@@ -36,6 +39,7 @@ impl Toast {
frame: Frame::Task,
title: Text::literal(""),
slide: Slide::default(),
slot: None,
recipes: items,
changed: true,
last_changed: 0,
@@ -524,6 +528,7 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
@@ -546,10 +551,17 @@ impl Game<'_> {
.announced
.push(Text::translate(key, &[player, name]));
}
None => self.system_message(Text::translate(
key,
&[Text::literal(super::chat::PLAYER_NAME), name],
)),
None => {
let text = Text::translate(key, &[self.player_display_text(), name]);
// The guests hear of the host's advancement too.
if let Some(host) = &self.lan {
host.hub
.system_message(horizon_client::lan::data::component_nbt(
&text.component(),
));
}
self.system_message(text);
}
}
}
}
@@ -806,58 +818,105 @@ impl Game<'_> {
c["from"].as_str().is_none_or(|f| f == from) && c["to"].as_str().is_none_or(|t| t == to)
});
}
/// `ToastManager` with the `AdvancementToast` and the `RecipeToast`,
/// one at a time.
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch) {
/// `ToastManager.SLOT_COUNT`.
pub(super) const TOAST_SLOTS: usize = 5;
/// `ToastManager.update` and `extractRenderState` for the `AdvancementToast`s and
/// `RecipeToast`s: up to five stack in 32-pixel slots, the rest queue. `hidden` (F1)
/// keeps them timing out without drawing them.
pub(super) fn draw_toasts(&mut self, batch: &mut HudBatch, hidden: bool) {
let multiplier = f64::from(self.settings.notification_display_time);
let Some(t) = self.adv.toasts.front_mut() else {
return;
};
let first = t.slide.origin.is_none();
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
let now = origin.elapsed().as_millis() as i64;
let mut slide = std::mem::take(&mut t.slide);
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
t.slide = slide;
if finished {
self.adv.toasts.pop_front();
let mut sound_played = false;
let mut i = 0;
while i < self.adv.toasts.len() {
let t = &mut self.adv.toasts[i];
if t.slot.is_none() {
i += 1;
continue;
}
let origin = *t.slide.origin.get_or_insert_with(Instant::now);
let now = origin.elapsed().as_millis() as i64;
let mut slide = std::mem::take(&mut t.slide);
let (sound, finished) = slide.update(now, |fully| t.wants_show(fully, multiplier));
t.slide = slide;
if finished {
self.adv.toasts.remove(i);
continue;
}
// Only the first visibility change of an update plays its sound.
if let Some(show) = sound
&& !std::mem::replace(&mut sound_played, true)
{
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
i += 1;
}
// The queued toasts take the free slots in order; a challenge's own sound
// plays once as it leaves the queue.
let mut used = self.toast_slots_used();
let mut challenge_sound = false;
for t in &mut self.adv.toasts {
if t.slot.is_some() {
continue;
}
let Some(slot) = (0..Self::TOAST_SLOTS).find(|s| used & (1 << s) == 0) else {
break;
};
used |= 1 << slot;
t.slot = Some(slot);
challenge_sound |= !t.is_recipe() && t.frame == Frame::Challenge;
}
if challenge_sound {
self.audio
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
}
if hidden {
return;
}
for i in 0..self.adv.toasts.len() {
if let Some(slot) = self.adv.toasts[i].slot {
self.draw_toast(batch, i, 32.0 * slot as f32, multiplier);
}
}
}
/// The slots held by shown advancement, recipe and pack toasts, as bits.
pub(super) fn toast_slots_used(&self) -> u32 {
let packs = self.server_packs.toast_slots();
self.adv
.toasts
.iter()
.filter_map(|t| t.slot)
.chain(packs)
.fold(0, |used, slot| used | 1 << slot)
}
/// The `AdvancementToast` or `RecipeToast` at `index` in its slot at `y`.
fn draw_toast(&mut self, batch: &mut HudBatch, index: usize, y: f32, multiplier: f64) {
let t = &self.adv.toasts[index];
let x = batch.width - 160.0 * t.slide.portion;
let fully_for = t.slide.fully_for;
let (title, frame, icon) = (t.title.clone(), t.frame, t.icon.clone());
let recipe = t
.is_recipe()
.then(|| t.recipes[t.displayed_recipe(fully_for, multiplier)].clone());
// ToastManager.update: the sound of the new visibility, and a
// challenge's own sound as it leaves the queue.
if let Some(show) = sound {
let event = if show { "ui.toast.in" } else { "ui.toast.out" };
self.audio
.play(&format!("minecraft:{event}"), None, 1.0, 1.0);
}
if first && recipe.is_none() && frame == Frame::Challenge {
self.audio
.play("minecraft:ui.toast.challenge_complete", None, 1.0, 1.0);
}
if let Some((station, item)) = recipe {
// RecipeToast: black description under a purple title, the
// station at 0.6 scale in the corner over the cycling result.
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/recipe", x, 0.0);
batch.sprite(atlas, "gui/sprites/toast/recipe", x, y);
let title = self.translate("recipe.toast.title", "New Recipe(s) Unlocked!");
let description = self.translate("recipe.toast.description", "Check your recipe book");
let atlas = &self.gui.atlas;
batch.text(atlas, &title, x + 30.0, 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, 18.0, 0xFF00_0000, false);
batch.text(atlas, &title, x + 30.0, y + 7.0, 0xFF50_0050, false);
batch.text(atlas, &description, x + 30.0, y + 18.0, 0xFF00_0000, false);
let start = batch.vertices.len();
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, 3.0);
batch.scale_since(start, x, 0.0, 0.6, 0.6);
self.draw_item_model(batch, &item, x + 8.0, 8.0);
self.draw_item_model(batch, &ItemStack::new(&station, 1), x + 3.0, y + 3.0);
batch.scale_since(start, x, y, 0.6, 0.6);
self.draw_item_model(batch, &item, x + 8.0, y + 8.0);
return;
}
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/toast/advancement", x, 0.0);
batch.sprite(atlas, "gui/sprites/toast/advancement", x, y);
let (key, color) = match frame {
Frame::Task => ("advancements.toast.task", 0xFF00_FFFF),
Frame::Goal => ("advancements.toast.goal", 0xFF00_FFFF),
@@ -867,12 +926,12 @@ impl Game<'_> {
atlas,
&Text::translate(key, &[]),
x + 30.0,
7.0,
y + 7.0,
color,
false,
);
batch.text_styled(atlas, &title, x + 30.0, 18.0, u32::MAX, false);
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, 8.0);
batch.text_styled(atlas, &title, x + 30.0, y + 18.0, u32::MAX, false);
self.draw_item(batch, &ItemStack::new(&icon, 1), x + 8.0, y + 8.0);
}
}
/// `Advancement.name`: the bracketed title in the frame's colour, with the
@@ -1053,6 +1112,7 @@ impl Game<'_> {
frame: d.frame,
title: text_of(&d.title),
slide: Slide::default(),
slot: None,
recipes: Vec::new(),
changed: false,
last_changed: 0,
+38 -3
View File
@@ -8,7 +8,6 @@ use horizon_core::random::RandomSource;
impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let player = self.player.as_ref().map(|p| p.position);
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
@@ -58,7 +57,7 @@ impl Game<'_> {
};
let Some((j, _)) = wanted else {
self.mobs[i].extra.point = None;
self.allay_deliver(i, player);
self.allay_deliver(i);
continue;
};
let e = &self.dropped[j];
@@ -90,14 +89,46 @@ impl Game<'_> {
}
}
}
/// `AllayAi.getLikedPlayer`: the player the allay was given an item by,
/// found by UUID among the host and the guests of the level (the window
/// is a guest on the server thread), when they are not a spectator and
/// within 64 blocks.
fn allay_liked_player(&self, i: usize) -> Option<horizon_core::geometry::Vec3> {
let m = &self.mobs[i];
if !m.extra.tame {
return None;
}
// An allay saved before its player was kept belongs to the host.
let uuid = m
.extra
.owner
.filter(|u| *u != [0, 0, 0, 1])
.or(self.level.player_uuid)
.unwrap_or([0, 0, 0, 1]);
let host = self
.player_view()
.filter(|p| !p.spectator && self.level.player_uuid.unwrap_or([0, 0, 0, 1]) == uuid)
.map(|p| p.position);
let found = host.or_else(|| {
self.lan_present()
.into_iter()
.find(|p| p.uuid == uuid && !p.spectator)
.map(|p| {
horizon_core::geometry::Vec3::new(p.position[0], p.position[1], p.position[2])
})
});
found.filter(|p| p.distance_squared(m.position) < 64.0 * 64.0)
}
/// `GoAndGiveItemsToTarget` / `AllayAi.throwItem`: within 3 blocks of
/// the deposit, one item a tick, each restarting the three-second pause
/// before it picks anything up again.
fn allay_deliver(&mut self, i: usize, player: Option<horizon_core::geometry::Vec3>) {
fn allay_deliver(&mut self, i: usize) {
let m = &self.mobs[i];
if m.extra.ints[1] <= 0 {
return;
}
let player = self.allay_liked_player(i);
let m = &self.mobs[i];
let target = match m.extra.home.filter(|_| m.extra.ints[2] > 0) {
Some(h) => horizon_core::geometry::Vec3::new(
f64::from(h[0]) + 0.5,
@@ -166,11 +197,14 @@ impl Game<'_> {
self.consume_held();
return true;
}
let acting = self.acting_uuid();
let holds = m.extra.main_hand.is_some();
if !holds && !item.is_empty() {
let m = &mut self.mobs[index];
m.extra.main_hand = Some(item.into());
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.consume_held();
@@ -185,6 +219,7 @@ impl Game<'_> {
.unwrap_or_else(|| "minecraft:air".into());
let carried = std::mem::take(&mut m.extra.ints[1]);
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
if carried > 0 {
+32 -16
View File
@@ -627,21 +627,8 @@ impl Game<'_> {
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let mut poses = Poses::new(model);
// ArmorStand.DEFAULT_*_POSE and ArmorStandModel part visibility.
let deg = |v: f32| v.to_radians();
for (name, rot) in [
("left_arm", [-10.0, 0.0, -10.0]),
("right_arm", [-15.0, 0.0, 10.0]),
("left_leg", [-1.0, 0.0, -1.0]),
("right_leg", [1.0, 0.0, 1.0]),
] {
if poses.has(name) {
let pose = poses.get(name);
pose[3] = deg(rot[0]);
pose[4] = deg(rot[1]);
pose[5] = deg(rot[2]);
}
}
// ArmorStandModel.setupAnim: the stand's pose and part visibility.
apply_pose(&mut poses, &a.pose);
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
@@ -711,7 +698,9 @@ impl Game<'_> {
let color = undyed.map_or(0xFFFF_FFFF, |c| {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
let layer_poses = Poses::new(part);
// ArmorStandArmorModel.setupAnim: the same pose as the stand.
let mut layer_poses = Poses::new(part);
apply_pose(&mut layer_poses, &a.pose);
horizon_client::mobs::render::draw(
part,
base,
@@ -743,6 +732,33 @@ impl Game<'_> {
}
}
}
/// The `Pose` rotations on the parts of an armor stand model or its armor
/// (`ArmorStandArmorModel.setupAnim`); the body also turns the sticks.
fn apply_pose(poses: &mut Poses, pose: &[[f32; 3]; 6]) {
let parts: [&[&str]; 6] = [
&["head"],
&[
"body",
"right_body_stick",
"left_body_stick",
"shoulder_stick",
],
&["left_arm"],
&["right_arm"],
&["left_leg"],
&["right_leg"],
];
for (names, rotation) in parts.into_iter().zip(pose) {
for name in names {
if poses.has(name) {
let part = poses.get(name);
part[3] = rotation[0].to_radians();
part[4] = rotation[1].to_radians();
part[5] = rotation[2].to_radians();
}
}
}
}
fn overlaps(a: Aabb, b: Aabb) -> bool {
let (a0, a1, b0, b1) = (
a.min_position(),
@@ -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,
+42 -2
View File
@@ -4,9 +4,13 @@
//! placed item gives its block entity.
use super::Game;
use horizon_client::{
block_entity_item, block_entity_nbt, containers::BlockEntity, item_data, items::ItemStack, save,
Result, block_entity_item, block_entity_nbt, containers::BlockEntity, item_data,
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
registry::BlockStateId,
};
use horizon_world::nbt::{Compound, Tag};
/// The component patch of `stack`.
fn components(stack: &ItemStack) -> Compound {
@@ -77,6 +81,42 @@ impl Game<'_> {
Some(stack)
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
pub(super) fn apply_item_block_state(
&mut self,
pos: [i32; 3],
state: BlockStateId,
stack: &ItemStack,
) -> Result<BlockStateId> {
let Some(Tag::Compound(saved)) =
stack.data().and_then(|d| d.other("minecraft:block_state"))
else {
return Ok(state);
};
let Some(current) = self.pack.assets.registry.state(state) else {
return Ok(state);
};
// Only the properties the block has.
let props: Vec<(String, String)> = saved
.iter()
.filter_map(|(k, v)| match v {
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
_ => None,
})
.filter(|(k, v)| current.properties.get(k).is_some_and(|old| old != v))
.collect();
let props: Vec<(&str, &str)> = props.iter().map(|(k, v)| (&**k, &**v)).collect();
match self.with_props(state, &props) {
Some(new) if !props.is_empty() && new != state => {
self.level_set(pos, new)?;
Ok(new)
}
_ => Ok(state),
}
}
/// `BlockItem.updateCustomBlockEntityTag` and `updateBlockEntityComponents`
/// for the block entity just placed at `pos` from `stack`; `game_master`:
/// the placer can use game master blocks (a dispenser can't).
+6 -1
View File
@@ -1077,7 +1077,12 @@ impl Game<'_> {
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
// BookSignScreen: `owner.getName()`, the account's name on a server.
let owner = self
.remote
.as_ref()
.map_or(super::chat::PLAYER_NAME, |r| r.profile_name.as_str());
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", owner);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
atlas,
+246
View File
@@ -0,0 +1,246 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use horizon_client::{
particles::{ParticleOptions, ParticleType},
save,
};
use horizon_core::random::RandomSource;
use std::collections::HashMap;
/// `BrushItem.USE_DURATION`.
const USE_DURATION: u32 = 200;
/// A brushable block's counters. Vanilla never saves them: the block's
/// `dusted` state is all that is left after a reload.
#[derive(Default)]
pub(super) struct BrushState {
count: i32,
resets_at: i64,
cooldown_ends_at: i64,
hit_face: Option<[i32; 3]>,
}
/// `BrushableBlockEntity.getCompletionState`: the `dusted` property.
fn completion_state(count: i32) -> i32 {
match count {
0 => 0,
1..=2 => 1,
3..=5 => 2,
_ => 3,
}
}
pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
let Some(hit) = self.pick() else {
self.using = None;
return;
};
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
}
/// One stroke on the block aimed at: dust (this window only), the brush
/// sound, and the block entity's count. The brush wears when the block
/// changed state.
fn brush_stroke(&mut self, hit: &Hit, slot: usize) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let name = self.res.facts().get(state).name.clone();
let (sound, brushable) = match &*name {
"minecraft:suspicious_sand" => ("minecraft:item.brush.brushing.sand", true),
"minecraft:suspicious_gravel" => ("minecraft:item.brush.brushing.gravel", true),
_ => ("minecraft:item.brush.brushing.generic", false),
};
if self.lan_acting.is_none() {
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
self.damage_held(1);
}
}
/// `BrushItem.spawnDustParticles`.
pub(super) fn brush_dust(&mut self, hit: &Hit, off_hand: bool) {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let Some((sprite, tint)) = self.particle_sprite(state, hit.pos) else {
return;
};
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
// The arm that brushes: the main arm, or the other one for the off hand.
let flip = if off_hand == self.settings.main_hand_left {
1.0
} else {
-1.0
};
let view = view_vector(self.yaw, self.pitch);
// DustParticlesDelta.fromDirection.
let (dx, dz) = match hit.face {
[0, _, 0] => (view[2], -view[0]),
[0, 0, -1] => (1.0, -0.1),
[0, 0, 1] => (-1.0, 0.1),
[-1, 0, 0] => (-0.1, -1.0),
_ => (0.1, 1.0),
};
let count = 7 + self.particles.level_random().next_int_bound(5).unwrap_or(0);
let x = hit.point[0] - if hit.face == [-1, 0, 0] { 1.0e-6 } else { 0.0 };
let z = hit.point[2] - if hit.face == [0, 0, -1] { 1.0e-6 } else { 0.0 };
for _ in 0..count {
let random = self.particles.level_random();
let v = [
dx * flip * 3.0 * random.next_double(),
0.0,
dz * flip * 3.0 * random.next_double(),
];
self.particles.add(&options, [x, hit.point[1], z], v);
}
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
return false;
}
brush.cooldown_ends_at = time + 10;
self.unpack_brush_loot(pos);
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
return true;
}
self.schedule_brushable_tick(pos);
let after = completion_state(brush.count);
self.brushing.insert(pos, brush);
if before != after {
self.set_dusted(pos, after);
}
false
}
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
return;
};
if tag.get("LootTable").is_none() {
return;
}
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
&mut tag,
loot.as_deref(),
&mut self.world.random,
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
"minecraft:suspicious_gravel" => "minecraft:gravel",
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
item.count = item.count.min(take as u32);
let at = [
f64::from(pos[0] + face[0]) + 0.5,
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
if let Some(base) = self.pack.assets.registry.default_state(turns_into) {
let _ = self.level_set(pos, base);
}
}
fn set_dusted(&mut self, pos: [i32; 3], dusted: i32) {
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
state.properties.insert("dusted".into(), dusted.to_string());
if let Ok(state) = registry.resolve(&state) {
let _ = self.level_set(pos, state);
}
}
fn schedule_brushable_tick(&mut self, pos: [i32; 3]) {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.schedule_tick(level, ticks, at, 2));
}
/// `BrushableBlockEntity.checkReset`, from `BrushableBlock.tick`: a block
/// left alone loses two counts, again every four ticks.
pub(super) fn brushable_tick(&mut self, pos: [i32; 3]) {
let time = self.world.game_time;
let Some(mut brush) = self.brushing.remove(&pos) else {
return;
};
let before = completion_state(brush.count);
if brush.count != 0 && time >= brush.resets_at {
brush.count = (brush.count - 2).max(0);
brush.resets_at = time + 4;
}
let after = completion_state(brush.count);
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
}
if before != after {
self.set_dusted(pos, after);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
let state = horizon_world::registry::BlockStateId(data as u32);
let sound = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:item.brush.brushing.sand.complete",
"minecraft:suspicious_gravel" => "minecraft:item.brush.brushing.gravel.complete",
_ => "",
};
if !sound.is_empty() {
let at = pos.map(|v| f64::from(v) + 0.5);
// Each client plays it from the event itself.
self.audio.play_unshared(sound, Some(at), 1.0, 1.0);
}
self.destroy_particles(pos, state);
}
}
@@ -2,8 +2,60 @@
//! shovels) put on a lit campfire puts it out.
use super::Game;
use horizon_client::{Result, animate_tick::make_campfire_smoke, items::ItemStack};
use horizon_core::position::BlockPos;
use horizon_world::chunk::BlockState;
use winit::keyboard::KeyCode;
impl Game<'_> {
/// The lit campfire at `pos` that `held` puts out, when the item is in
/// `#minecraft:douses_campfires` (`CampfireBlock.useItemOn`).
fn campfire_to_douse(&self, pos: [i32; 3], held: Option<&ItemStack>) -> Option<BlockState> {
held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
})?;
self.pack
.assets
.registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
}
/// `CampfireBlock.useItemOn` as this client runs it before the server's
/// answer (`MultiPlayerGameMode.useItemOn`): the twenty smoke puffs of
/// `CampfireBlock.douse` and the campfire gone out, kept as predicted
/// under `seq`. The level event's sound comes from the server. True when
/// it doused (SUCCESS), which ends the click for both hands.
pub(super) fn predict_campfire_douse(
&mut self,
pos: [i32; 3],
held: Option<&ItemStack>,
seq: i32,
) -> bool {
// Player.isSecondaryUseActive: a held item means both hands count.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let Some(mut state) = self.campfire_to_douse(pos, held) else {
return false;
};
let signal = state.properties.get("signal_fire").map(String::as_str) == Some("true");
for _ in 0..20 {
make_campfire_smoke(&mut self.particles, pos, signal, true);
}
state.properties.insert("lit".into(), "false".into());
if let Ok(unlit) = self.pack.assets.registry.resolve(&state) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.predict_blocks(&vec![(at, unlit)], seq);
}
true
}
/// `CampfireBlock.useItemOn` with `held` in the hand that acts, the block
/// at `pos` clicked: true when it doused a lit campfire (SUCCESS).
pub(super) fn use_campfire_douse(
@@ -11,24 +63,11 @@ impl Game<'_> {
pos: [i32; 3],
held: Option<&ItemStack>,
) -> Result<bool> {
let Some(tool) = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:douses_campfires")
.is_some_and(|members| members.contains(&*s.item))
}) else {
return Ok(false);
};
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.filter(|s| matches!(&*s.name, "minecraft:campfire" | "minecraft:soul_campfire"))
.filter(|s| s.properties.get("lit").map(String::as_str) == Some("true"))
.cloned()
let (Some(used), Some(mut state)) = (held.cloned(), self.campfire_to_douse(pos, held))
else {
return Ok(false);
};
let registry = self.pack.assets.registry;
// Level event 1009 (SOUND_EXTINGUISH_FIRE) goes to every player.
self.level_event_at(1009, pos, 0);
// CampfireBlock.douse: the acting client's own smoke (twenty puffs),
@@ -44,7 +83,6 @@ impl Game<'_> {
if let Ok(unlit) = registry.resolve(&state) {
self.level_set(pos, unlit)?;
}
let used = tool.clone();
self.damage_held(1);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &used);
@@ -0,0 +1,138 @@
//! `CatSpawner`: every 1200 ticks a cat may appear near a random player,
//! in a village with enough occupied beds or inside a swamp hut.
use super::{
Dim, Game,
raids::{section, sections_to_village},
};
use horizon_client::mobs::{Species, spawn_rules};
use horizon_core::{geometry::Vec3, random::RandomSource};
const TICK_DELAY: i32 = 1200;
impl Game<'_> {
/// `CatSpawner.tick` (the overworld's custom spawner).
pub(super) fn tick_cat_spawner(&mut self) {
if self.dim != Dim::Overworld {
return;
}
self.creatures.cat_tick -= 1;
if self.creatures.cat_tick > 0 {
return;
}
self.creatures.cat_tick = TICK_DELAY;
// ServerLevel.getRandomPlayer: one of the living players.
let alive: Vec<Vec3> = self
.level_players()
.into_iter()
.filter(|p| p.alive)
.map(|p| p.position)
.collect();
if alive.is_empty() {
return;
}
let pick = self
.world
.random
.next_int_bound(alive.len() as i32)
.unwrap_or(0);
let p = alive[pick as usize];
let mut offset = || {
let n = 8 + self.world.random.next_int_bound(24).unwrap_or(0);
if self.world.random.next_boolean() {
-n
} else {
n
}
};
let x = offset();
let z = offset();
let pos = [
p.x.floor() as i32 + x,
p.y.floor() as i32,
p.z.floor() as i32 + z,
];
// hasChunksAt(pos +- 10)
let loaded = ((pos[0] - 10) >> 4..=(pos[0] + 10) >> 4).all(|cx| {
((pos[2] - 10) >> 4..=(pos[2] + 10) >> 4)
.all(|cz| self.world.chunks.contains_key(&(cx, cz)))
});
if !loaded {
return;
}
let ok = spawn_rules::is_spawn_position_ok(
Species::Cat,
&self.mob_world_base(&[]),
pos[0],
pos[1],
pos[2],
);
if !ok {
return;
}
if sections_to_village(&self.village_centers(), section(pos)) <= 2 {
self.spawn_cat_in_village(pos);
} else {
let hut = self.structure_tag("minecraft:cats_spawn_in");
if self.structures_at(pos).iter().any(|s| hut.contains(s)) {
self.spawn_cat_in_hut(pos);
}
}
}
/// `spawnInVillage`: more than four occupied beds within 48 blocks and
/// fewer than five cats around.
fn spawn_cat_in_village(&mut self, pos: [i32; 3]) {
// PoiManager.getCountInRange(HOME, IS_OCCUPIED): beds villagers have claimed.
let mut beds: Vec<[i32; 3]> = self
.mobs
.iter()
.filter(|m| m.species() == Species::Villager && m.alive())
.filter_map(|m| m.extra.bed)
.filter(|b| {
let d = |i: usize| i64::from(b[i] - pos[i]);
d(0).abs() <= 48
&& d(2).abs() <= 48
&& d(0).pow(2) + d(1).pow(2) + d(2).pow(2) <= 48 * 48
})
.collect();
beds.sort_unstable();
beds.dedup();
if beds.len() > 4 && self.cats_near(pos, 48.0) < 5 {
self.spawn_cat(pos, false);
}
}
/// `spawnInHut`: no cat within 16 blocks; the cat stays.
fn spawn_cat_in_hut(&mut self, pos: [i32; 3]) {
if self.cats_near(pos, 16.0) == 0 {
self.spawn_cat(pos, true);
}
}
/// Cats in `AABB(pos).inflate(radius, 8, radius)`.
fn cats_near(&self, pos: [i32; 3], radius: f64) -> usize {
let [x, y, z] = pos.map(f64::from);
let min = Vec3::new(x - radius, y - 8.0, z - radius);
let max = Vec3::new(x + 1.0 + radius, y + 9.0, z + 1.0 + radius);
self.mobs
.iter()
.filter(|m| {
m.species() == Species::Cat && m.alive() && m.bounds().intersects_corners(min, max)
})
.count()
}
/// `spawnCat`: finalized for a natural spawn, black inside
/// `#cats_spawn_as_black` structures (`Cat.finalizeSpawn`).
fn spawn_cat(&mut self, pos: [i32; 3], persistent: bool) {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:cat", at, persistent) else {
return;
};
let black = self.structure_tag("minecraft:cats_spawn_as_black");
let in_black = self.structures_at(pos).iter().any(|s| black.contains(s));
if in_black && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.variant = 10;
}
}
}
+177 -73
View File
@@ -81,6 +81,8 @@ pub struct UiState {
pub chat_screen: Option<ChatScreen>,
/// When the chat screen opened (its input took the focus: the cursor blink).
pub chat_opened: Option<std::time::Instant>,
/// The chat screen is `InBedChatScreen`: the sleeper's, with a Leave Bed button.
pub in_bed_chat: bool,
pub registries: Registries,
pub rules: GameRules,
pub clock: WorldClock,
@@ -102,10 +104,25 @@ pub struct UiState {
/// Death screen cause and when the player died (buttons wait 20 ticks).
pub death_text: Option<Text>,
pub died_at: Option<std::time::Instant>,
/// `DeathScreen.TitleConfirmScreen` and when it opened (its buttons wait
/// 20 ticks, `setDelay`).
pub death_confirm: Option<std::time::Instant>,
/// `LivingEntity.deathTime` of the window's player: ticks since it died.
pub death_time: u32,
}
impl UiState {
/// The state a window starts with: the chat remembers the commands kept
/// in the game folder (`ChatComponent`'s constructor).
pub fn new() -> Self {
let mut ui = Self::default();
if let Ok(dir) = std::env::current_dir() {
ui.chat.load_history(&dir);
}
ui
}
}
impl Game<'_> {
/// `PlayerList.isOp` for the single-player owner: the world's Allow Commands.
pub(super) fn allow_commands(&self) -> bool {
@@ -440,6 +457,12 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
self.wake_up();
return;
}
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
if self.screen == Screen::Chat {
@@ -447,6 +470,35 @@ impl Game<'_> {
self.capture(true);
}
}
/// `Gui.tick`: a sleeper gets `InBedChatScreen`; when the player has
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
if self.ui.in_bed_chat {
if self.sleeping.is_none() {
self.ui.in_bed_chat = false;
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
if !text.is_empty() {
self.open_chat(&text);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
/// The Leave Bed button of `InBedChatScreen`: its bounds.
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -559,7 +611,15 @@ impl Game<'_> {
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => self.close_chat(),
ChatAction::Submit(message) => {
self.close_chat();
// InBedChatScreen sends without closing (`closeOnSubmit` is
// false): the box empties and the screen stays.
if self.ui.in_bed_chat && self.sleeping.is_some() {
screen.suggest_command(&m, &c, "");
self.ui.chat.reset_scroll();
self.ui.chat_screen = Some(screen);
} else {
self.close_chat();
}
if !message.is_empty() {
self.handle_chat_input(&message);
}
@@ -661,7 +721,8 @@ impl Game<'_> {
{
return;
}
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// ChatScreen.mouseScrolled: `hasShiftDown`, either Shift key.
let shift = self.has_shift_down();
let amount = amount.clamp(-1.0, 1.0) * if shift { 1.0 } else { 7.0 };
self.ui.chat.scroll_by(amount as i32, true);
}
@@ -675,6 +736,16 @@ impl Game<'_> {
dispatcher: chat_tree(&self.remote, &self.mods),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
let width = self.renderer.as_ref().map_or(1280.0, |r| {
HudBatch::new([r.config.width, r.config.height]).width
});
let (x, y, w, h) = Self::leave_bed_button(width, height);
if horizon_client::screen_draw::inside((cursor.0 as i32, cursor.1 as i32), x, y, w, h) {
self.wake_up();
return;
}
}
if let Some(screen) = self.ui.chat_screen.as_mut() {
if screen.click(&m, &c, cursor.0 as f64, cursor.1 as f64) {
return;
@@ -682,12 +753,25 @@ impl Game<'_> {
} else {
return;
}
match self.ui.chat.click_at(
let Some(style) = self.ui.chat.style_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) {
) else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
// insertion acts, else only the click event.
if self.has_shift_down() {
if let Some(insertion) = style.insertion
&& let Some(screen) = self.ui.chat_screen.as_mut()
{
screen.type_text(&m, &c, &insertion);
}
return;
}
match style.click {
Some(ClickEvent::CopyToClipboard(value)) => {
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&value);
@@ -700,9 +784,26 @@ impl Game<'_> {
screen.suggest_command(&m, &c, &command);
}
}
Some(ClickEvent::RunCommand(command)) => self.click_command(&command),
None => {}
}
}
/// `Screen.clickCommandAction`: run a clicked command as the player
/// without adding it to the chat history. A client mod's command goes
/// first, as `ClientPacketListener.sendUnattendedCommand` lets client
/// commands take it. (Vanilla asks first when the command fails to
/// parse; here the server answers it.)
fn click_command(&mut self, command: &str) {
let command = command.strip_prefix('/').unwrap_or(command);
if self.mod_command(command) {
return;
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
self.run_command(command);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
@@ -737,17 +838,59 @@ impl Game<'_> {
let draws = screen.draw(&m, mouse, blink, background);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
// The hovered message part's tooltip, one row per line of its text.
if let Some(hover) = self.ui.chat.hover_at(
&self.gui.atlas.font,
batch.height as i32,
f64::from(self.cursor.0),
f64::from(self.cursor.1),
) {
let lines = hover_rows(&hover);
if self.ui.in_bed_chat && self.sleeping.is_some() {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
// or the text of the message tag under the cursor.
let height = batch.height as i32;
let (cx, cy) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
let wrap = (batch.width as i32 / 2).max(200);
if let Some(style) = self.ui.chat.style_at(&self.gui.atlas.font, height, cx, cy) {
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, wrap);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = style
.hover_item
.as_deref()
.and_then(horizon_client::item_data::stack_from_hover)
.filter(|s| self.res.items().get(&s.item).is_some())
{
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
} else if self.ui.chat.indicator_at(height, cx, cy) {
// GuiMessageTag.systemSinglePlayer's text, split at 210.
let tag = Text::translate("chat.tag.system_single_player", &[]);
let lines = self.wrapped_rows(&tag, 210);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `Font.split`: a tooltip text wrapped at `width`, one white row per line.
fn wrapped_rows(&self, text: &Text, width: i32) -> Vec<(Text, u32)> {
let split = self
.gui
.atlas
.font
.split(&text.resolve_legacy(), width as f32);
split.into_iter().map(|row| (row, u32::MAX)).collect()
}
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
@@ -801,7 +944,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 +973,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 +1023,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).
@@ -886,6 +1030,7 @@ impl Game<'_> {
let cause = self.ui.death_cause.take();
self.ui.died_at = Some(std::time::Instant::now());
self.ui.death_text = None;
self.ui.death_confirm = None;
if !self.ui.rules.bool("show_death_messages") {
return;
}
@@ -900,9 +1045,19 @@ impl Game<'_> {
None => self.combat_death_message(),
};
self.ui.death_text = Some(message.clone());
// ServerPlayer.die: a team that hides death messages keeps it out of
// the dying player's own chat (the death screen still shows it).
if horizon_client::scoreboard::own_death_message_shown(self.player_team()) {
// ServerPlayer.die: the team's death message visibility decides who
// hears it, the dying player's own chat included (the death screen
// still shows it).
let board = &self.score.board;
if let Some(host) = &self.lan {
host.hub.system_message_to(
horizon_client::lan::data::component_nbt(&message.component()),
|other| {
horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, other)
},
);
}
if horizon_client::scoreboard::death_message_reaches(board, PLAYER_NAME, PLAYER_NAME) {
self.system_message(message);
}
}
@@ -988,60 +1143,9 @@ 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).
fn owner_permission(allow_commands: bool) -> u8 {
if allow_commands { 4 } else { 0 }
}
/// A hover text's rows for the tooltip: split at line breaks, each in the
/// colour of its first styled character (white without one).
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
let mut rows = vec![(String::new(), None)];
for (c, style) in hover.resolve_legacy().chars() {
if c == '\n' {
rows.push((String::new(), None));
continue;
}
let row = rows.last_mut().expect("one row");
if row.0.is_empty() {
row.1 = style.color;
}
row.0.push(c);
}
rows.into_iter()
.map(|(text, color)| {
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
(text, horizon_client::hud::rgba(r, g, b, 255))
})
.collect()
}
@@ -0,0 +1,178 @@
//! Chiseled bookshelves: `ChiseledBookShelfBlock.useItemOn` / `useWithoutItem`
//! (a book into the slot under the crosshair, or out of it) and
//! `ChiseledBookShelfBlockEntity.updateState`, which also follows the books a
//! hopper or dropper moved (`machines::container_changed`). The slots' books
//! show through the block state's `slot_N_occupied`, which reaches a server's
//! clients with the block update.
use super::{Game, Hit, machines::dir_from_name};
use horizon_client::{containers::BlockEntity, items::ItemStack, mobs::Who};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::{flags, hit_slot};
const BLOCK: &str = "minecraft:chiseled_bookshelf";
impl Game<'_> {
/// `useItemOn` with `held` in the acting hand, then `useWithoutItem` on
/// the main hand's turn: None when the block passes the click, else
/// whether the use succeeded and the arm swings (a click on an empty slot
/// without a book only consumes it).
pub(super) fn use_chiseled_bookshelf(
&mut self,
hit: &Hit,
held: Option<&ItemStack>,
) -> Option<bool> {
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
let info = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name == BLOCK)?;
let facing = info
.properties
.get("facing")
.and_then(|f| dir_from_name(f))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let at = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
let slot = hit_slot(
facing,
face,
BlockPos::new(pos[0], pos[1], pos[2]),
at,
2,
3,
)?;
let occupied = info
.properties
.get(&format!("slot_{slot}_occupied"))
.map(String::as_str)
== Some("true");
let book = held.filter(|s| {
self.res
.items()
.item_tags
.get("minecraft:bookshelf_books")
.is_some_and(|m| m.contains(&*s.item))
});
let centre = pos.map(|v| f64::from(v) + 0.5);
if let Some(book) = book
&& !occupied
{
// addBook: one book goes in (a creative player keeps theirs).
let book = book.clone();
self.stats.add("used", &book.item, 1);
let sound = if &*book.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.insert.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.insert"
};
self.bookshelf_items(pos)?[slot] = Some(ItemStack {
count: 1,
..book.clone()
});
self.consume_held();
self.chiseled_bookshelf_update_state(pos, slot);
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.trigger_block("minecraft:item_used_on_block", pos, &book);
return Some(true);
}
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != horizon_client::first_person::Hand::Main {
return None;
}
if !occupied {
return Some(false);
}
// removeBook: the book goes to the inventory, or is dropped.
let retrieved = self.bookshelf_items(pos)?[slot].take()?;
self.chiseled_bookshelf_update_state(pos, slot);
let sound = if &*retrieved.item == "minecraft:enchanted_book" {
"minecraft:block.chiseled_bookshelf.pickup.enchanted"
} else {
"minecraft:block.chiseled_bookshelf.pickup"
};
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.give_or_drop(retrieved);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
Some(true)
}
/// The bookshelf's six slots (made if the block has no block entity yet).
fn bookshelf_items(&mut self, pos: [i32; 3]) -> Option<&mut [Option<ItemStack>; 6]> {
let entity = self
.world
.block_entities
.get_or_insert_with((pos[0], pos[1], pos[2]), || {
BlockEntity::ChiseledBookshelf(Box::default())
});
match entity {
BlockEntity::ChiseledBookshelf(b) => Some(&mut b.items),
_ => None,
}
}
/// `ChiseledBookShelfBlockEntity.updateState`: `slot` becomes the last
/// one touched, the block state takes the slots' occupancy
/// (`setBlockAndUpdate`) and `BLOCK_CHANGE` goes out.
fn chiseled_bookshelf_update_state(&mut self, pos: [i32; 3], slot: usize) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get_mut(&key)
else {
return;
};
b.last_slot = slot as i32;
let occupied: Vec<bool> = b.items.iter().map(Option::is_some).collect();
let registry = self.pack.assets.registry;
let Some(mut state) = registry
.state(self.world.block(pos[0], pos[1], pos[2]))
.cloned()
else {
return;
};
for (i, occupied) in occupied.into_iter().enumerate() {
state
.properties
.insert(format!("slot_{i}_occupied"), occupied.to_string());
}
let Some(new) = registry.id(&state) else {
return;
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, new, flags::UPDATE_ALL));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
/// The books a hopper or dropper put in or took out: the slot whose
/// occupancy no longer matches the block state is the one `setItem` or
/// `removeItem` updated.
pub(super) fn chiseled_bookshelf_synced(&mut self, pos: (i32, i32, i32)) {
let Some(BlockEntity::ChiseledBookshelf(b)) = self.world.block_entities.get(&pos) else {
return;
};
let Some(info) = self
.pack
.assets
.registry
.state(self.world.block(pos.0, pos.1, pos.2))
else {
return;
};
let moved = (0..6).rev().find(|i| {
let shown = info
.properties
.get(&format!("slot_{i}_occupied"))
.map(String::as_str);
b.items[*i].is_some() != (shown == Some("true"))
});
if let Some(slot) = moved {
self.chiseled_bookshelf_update_state([pos.0, pos.1, pos.2], slot);
}
}
}
@@ -17,8 +17,10 @@ impl Game<'_> {
return;
}
let mut events = Vec::new();
let random = self.particles.level_random();
for m in self.mobs.iter_mut().filter(|m| !m.removed) {
ambient::client_ai_step(m, &mut events);
ambient::client_particles(m, random, &mut events);
}
for event in events {
match event {
@@ -37,6 +39,7 @@ impl Game<'_> {
}
}
self.eye_particles();
self.panda_effects();
self.sprint_dust();
self.effect_swirls();
}
+48 -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,
@@ -33,6 +42,10 @@ pub(super) struct CombatTracker {
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
pub(super) player_attacker: Option<([f64; 3], f32)>,
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
@@ -45,6 +58,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 +127,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;
};
+147 -78
View File
@@ -1041,6 +1041,17 @@ impl Game<'_> {
// An air stack is empty: nothing is given (but the message still is).
let empty = id == "minecraft:air";
for &t in targets.iter().filter(|_| !empty) {
// The player's UUID, which an item they could not hold is set to.
let conn = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
let uuid = match conn {
Some(conn) => self
.command_guest(conn)
.map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)),
None => Some(self.level.ensure_player_uuid()),
};
self.for_player(t, |g| {
let mut remaining = count;
while remaining > 0 {
@@ -1057,8 +1068,19 @@ impl Game<'_> {
.target_position(Target::Player)
.unwrap_or(g.source_position());
if let Some(rest) = left {
// Inventory full: drop the rest at the player's feet without pickup delay.
g.spawn_item(rest, [p[0], p[1] + 1.32, p[2]], [0.0, 0.0, 0.0], 0);
// Inventory full: Player.createItemStackToDrop (thrown from
// the eyes), then setNoPickUpDelay and setTarget(player).
let eye = g.eye_position().unwrap_or([
p[0],
p[1] + g.target_eye_height(Target::Player),
p[2],
]);
g.throw_from_eyes(rest, eye, false);
if let Some(item) = g.dropped.last_mut() {
item.thrower = None;
item.pickup_delay = 0;
item.target = uuid;
}
} else {
let r = &mut g.world.random;
let pitch = ((r.next_float() - r.next_float()) * 0.7 + 1.0) * 2.0;
@@ -1311,7 +1333,7 @@ impl Game<'_> {
{
return Err(err("commands.teleport.invalidPosition", &[]));
}
let mut rotation = match ctx.get("rotation") {
let rotation = match ctx.get("rotation") {
Some(Arg::Coords(c)) => Some(c.rotation(self.source_rotation())),
_ => None,
};
@@ -1326,21 +1348,31 @@ impl Game<'_> {
(Some(f), _) => Some(f),
(None, Some(_)) => {
let e = self.entities(ctx, "facingEntity", false)?[0];
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
self.target_position(e)
.map(|p| [p[0], p[1] + if eyes { 1.62 } else { 0.0 }, p[2]])
.map(|p| [p[0], p[1] + eye_height, p[2]])
}
_ => None,
};
if let Some(f) = facing {
let eye = pos[1] + 1.62;
// Entity.lookAt: from the source's anchor (the feet, unless `anchored
// eyes`) on the teleported entity, so the eyes depend on the target.
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
let look_at = |this: &Self, t: Target| {
let f = facing?;
let eye = if anchored_eyes {
pos[1] + this.target_eye_height(t)
} else {
pos[1]
};
let (dx, dy, dz) = (f[0] - pos[0], f[1] - eye, f[2] - pos[2]);
let horizontal = (dx * dx + dz * dz).sqrt();
rotation = Some([
Some([
((dz.atan2(dx).to_degrees()) - 90.0) as f32,
(-(dy.atan2(horizontal).to_degrees())) as f32,
]);
}
])
};
// A server's player in another level than the source's (`execute
// in`, the console's Overworld) moves there (ServerPlayer.teleport
// into another level), after the command.
@@ -1350,6 +1382,7 @@ impl Game<'_> {
None => self.dim,
};
for &t in &targets {
let rotation = look_at(self, t).or(rotation);
if self.teleport_across(t, source_level, pos, rotation) {
continue;
}
@@ -2057,7 +2090,8 @@ impl Game<'_> {
let dmax = [dest[0] + size[0], dest[1] + size[1], dest[2] + size[2]];
let overlap = (0..3).all(|i| dest[i] <= max[i] && dmax[i] >= min[i]);
let moving = ctx.has("move");
if !ctx.has("force") && overlap {
// Mode.canOverlap: only the normal mode refuses overlapping regions.
if !ctx.has("force") && !moving && overlap {
return Err(err("commands.clone.overlap", &[]));
}
let area = (0..3).map(|i| i64::from(size[i] + 1)).product::<i64>();
@@ -2266,26 +2300,54 @@ impl Game<'_> {
let levels = ctx.has("levels");
let unit = if levels { "levels" } else { "points" };
if ctx.has("query") {
let _ = self.entities(ctx, "target", true)?;
let e = &self.experience;
let value = if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32) as i32
};
let target = self.entities(ctx, "target", true)?[0];
// ExperienceCommand.queryExperience: the target's own level or points.
let value = self
.for_player(target, |g| {
let e = &g.experience;
if levels {
e.level
} else {
(e.progress * horizon_client::entities::Experience::needed(e.level) as f32)
as i32
}
})
.unwrap_or(0);
self.system_message(Text::translate(
&format!("commands.experience.query.{unit}"),
&[
Text::literal(self.acting_player_name()),
Text::literal(value.to_string()),
],
&[self.target_name(target), Text::literal(value.to_string())],
));
return Ok(value);
}
let targets = self.entities(ctx, "target", true)?;
let amount = Self::int(ctx, "amount").unwrap_or(0);
let op = if ctx.has("add") { "add" } else { "set" };
if op == "add" {
let add = ctx.has("add");
let op = if add { "add" } else { "set" };
// Every target changes (a set of points can be refused for some).
let changed: Vec<Target> = targets
.iter()
.copied()
.filter(|&t| {
self.for_player(t, |g| g.change_experience(add, levels, amount)) == Some(true)
})
.collect();
if changed.is_empty() {
return Err(err("commands.experience.set.points.invalid", &[]));
}
let (key, who) = match changed[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(changed.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.experience.{op}.{unit}.success.{key}"),
&[Text::literal(amount.to_string()), who],
));
Ok(changed.len() as i32)
}
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
@@ -2299,26 +2361,11 @@ impl Game<'_> {
} else {
let needed = horizon_client::entities::Experience::needed(self.experience.level);
if amount > needed - 1 {
return Err(err("commands.experience.set.points.invalid", &[]));
return false;
}
self.experience.progress = amount as f32 / needed as f32;
}
let key = format!(
"commands.experience.{op}.{unit}.success.{}",
if targets.len() == 1 {
"single"
} else {
"multiple"
}
);
self.feedback(Text::translate(
&key,
&[
Text::literal(amount.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(targets.len() as i32)
true
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
@@ -2403,21 +2450,26 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
// SetSpawnCommand: a forced point in the source's level, for each target.
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
g.respawn_config = None;
} else {
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
}
});
}
let dim = Text::literal(self.dim.id());
@@ -2597,6 +2649,44 @@ impl Game<'_> {
},
};
let max = Self::int(ctx, "maxCount").unwrap_or(-1);
let counting = max == 0;
// ClearInventoryCommands.clearInventory: each player on their own.
let mut removed = 0i32;
let mut found = Vec::new();
for &t in &targets {
let n = self
.for_player(t, |g| g.clear_matching(&predicate, max))
.unwrap_or(0);
removed += n;
if n != 0 {
found.push(t);
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[self.target_name(targets[0])],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
let (key, who) = match found[..] {
[only] => ("single", self.target_name(only)),
_ => ("multiple", Text::literal(found.len().to_string())),
};
self.feedback(Text::translate(
&format!("commands.clear.{kind}.{key}"),
&[Text::literal(removed.to_string()), who],
));
Ok(removed)
}
/// Inventory.clearOrCountMatchingItems for the player in place: `max`
/// of the matching items go (all when negative, none and only counted
/// when 0). Returns how many matched.
fn clear_matching(&mut self, predicate: &ItemInput, max: i32) -> i32 {
let counting = max == 0;
// Inventory slots 0..40: main, armor feet to head, off hand;
// then clearOrCountMatchingItems visits the crafting grid and carried stack.
@@ -2612,10 +2702,7 @@ impl Game<'_> {
};
let matching: Vec<bool> = all(self)
.iter()
.map(|s| {
s.as_ref()
.is_some_and(|s| self.stack_matches(&predicate, s))
})
.map(|s| s.as_ref().is_some_and(|s| self.stack_matches(predicate, s)))
.collect();
let mut slots: Vec<&mut Option<ItemStack>> = Vec::new();
slots.extend(self.inventory.iter_mut());
@@ -2645,25 +2732,7 @@ impl Game<'_> {
}
}
}
if removed == 0 {
return Err(if targets.len() == 1 {
CommandError::new(Text::translate(
"clear.failed.single",
&[Text::literal(self.acting_player_name())],
))
} else {
err("clear.failed.multiple", &[&targets.len()])
});
}
let kind = if counting { "test" } else { "success" };
self.feedback(Text::translate(
&format!("commands.clear.{kind}.single"),
&[
Text::literal(removed.to_string()),
Text::literal(self.acting_player_name()),
],
));
Ok(removed)
removed
}
/// `PoiTypes`: the point of interest type of a block state.
fn poi_type(&self, state: horizon_world::registry::BlockStateId) -> Option<&'static str> {
@@ -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}"), &[])
}
@@ -105,6 +105,7 @@ impl Game<'_> {
match ty.trim_start_matches("minecraft:") {
"player_attack" => DamageKind::PlayerAttack,
"mace_smash" => DamageKind::MaceSmash,
"spear" => DamageKind::Spear,
"mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack,
"arrow" => DamageKind::Arrow,
"thrown" | "trident" | "mob_projectile" => DamageKind::Thrown,
@@ -118,7 +119,9 @@ impl Game<'_> {
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"cactus" => DamageKind::Cactus,
"sweet_berry_bush" => DamageKind::SweetBerryBush,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
@@ -1218,7 +1221,7 @@ impl Game<'_> {
/// LivingEntity.stopRiding for a mob: off its vehicle
/// (Entity.removePassenger), then to where the vehicle lets it out
/// (LivingEntity.dismountVehicle).
fn dismount_mob(&mut self, rider: u32) {
pub(in crate::game) fn dismount_mob(&mut self, rider: u32) {
let Some(i) = self.mobs.iter().position(|m| m.id == rider) else {
return;
};
@@ -0,0 +1,39 @@
//! The creative toolbars, `hotbar.nbt` beside `options.txt`: hold the save or
//! load key and press a hotbar key (`Minecraft.handleKeybinds`).
use super::Game;
use horizon_client::{hotbar, keybinds, text::Text};
impl Game<'_> {
/// Whether the key bound to `mapping` is held.
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
}
/// `CreativeModeInventoryScreen.handleHotbarLoadOrSave` for the hotbar key
/// `index`: load puts the saved row in the hotbar (the creative slot
/// packets follow from the inventory change), save stores the hotbar and
/// says so on the action bar.
pub(super) fn hotbar_load_or_save(&mut self, index: usize, load: bool) {
let path = Self::options_path().with_file_name("hotbar.nbt");
let known = |id: &str| self.res.items().get(id).is_some();
let mut rows = hotbar::load(&path, &known);
if load {
for (slot, stack) in rows[index].iter().enumerate() {
self.inventory[slot] = stack.clone();
}
return;
}
rows[index] = self.inventory[..hotbar::SIZE].to_vec();
let _ = hotbar::save(&path, &rows);
let key = |mapping: &str| keybinds::display_name(self.settings.keys.get(mapping));
let message = Text::translate(
"inventory.hotbarSaved",
&[
key("key.loadToolbarActivator"),
key(&format!("key.hotbar.{}", index + 1)),
],
);
self.titles.set_overlay(message, false);
}
}
File diff suppressed because it is too large. Load diff
+168 -4
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`.
@@ -244,7 +244,8 @@ impl Game<'_> {
if on { "visible" } else { "hidden" }
)
};
let remote = self.remote.is_some();
// `DebugScreenOverlay`: the single-player variants for a local server.
let remote = !self.has_singleplayer_server();
let c = &self.debug_charts;
[
format!(
@@ -463,7 +464,7 @@ impl Game<'_> {
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
self.remote.is_some(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
@@ -766,7 +767,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 +798,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 +983,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,
)
}
+62 -1
View File
@@ -409,6 +409,8 @@ struct ServerState {
paused: bool,
/// `broadcast-console-to-ops`.
broadcast_console: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
op_level: i32,
}
/// The dedicated-only commands' replies vanilla sends with `broadcast`
@@ -570,6 +572,14 @@ impl Game<'_> {
// DedicatedServer.initServer: worldData.setGameType; forceDifficulty.
self.set_default_game_mode(game_mode);
self.ui.difficulty = props.difficulty as u8;
// DedicatedServer.initServer: a legacy `announce-player-achievements`
// sets the show_advancement_messages rule once, as the file is read.
if let Some(announce) = props.announce_player_achievements {
self.ui.rules.set(
"show_advancement_messages",
horizon_client::gamerules::Value::Bool(announce),
);
}
self.settings.render_distance = props.view_distance.clamp(2, 32);
self.settings.simulation_distance = props.simulation_distance.clamp(2, 32);
self.streaming.radius = self.settings.render_distance;
@@ -644,6 +654,9 @@ impl Game<'_> {
return Ok(());
}
self.sync_ops(&lists);
if let Some(host) = self.lan.as_mut() {
host.hub.spawn_protection = props.spawn_protection;
}
log(
"INFO",
&format!(
@@ -666,6 +679,7 @@ impl Game<'_> {
ticks: Default::default(),
paused: false,
broadcast_console: props.broadcast_console_to_ops,
op_level: props.op_permission_level,
};
let pause_after = props.pause_when_empty_seconds * 20;
let idle_timeout = props.player_idle_timeout;
@@ -927,7 +941,7 @@ impl Game<'_> {
let Some(user) = self.resolve(rest[0]) else {
return;
};
let level = 4;
let level = state.op_level;
let changed = {
let mut l = state.lists.lock().unwrap();
if command == "op" {
@@ -953,6 +967,10 @@ impl Game<'_> {
self.console_reply(Text::translate(key, &[lit(&user.name)]));
let lists = state.lists.clone();
self.sync_ops(&lists);
// DeOpCommands.deopPlayers.
if command == "deop" && changed {
self.kick_unlisted_players(&lists);
}
}
"whitelist" => self.console_whitelist(state, &rest),
"ban" if !rest.is_empty() => {
@@ -1164,6 +1182,7 @@ impl Game<'_> {
["on"] | ["off"] => {
let on = rest[0] == "on";
let mut l = state.lists.lock().unwrap();
let turned_on = on && !l.use_whitelist;
let key = match (on, l.use_whitelist) {
(true, true) => "commands.whitelist.alreadyOn",
(true, false) => "commands.whitelist.enabled",
@@ -1174,6 +1193,10 @@ impl Game<'_> {
drop(l);
self.store_property("white-list", on.to_string());
self.console_reply(Text::translate(key, &[]));
if turned_on {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
["list"] => {
let names: Vec<String> = state
@@ -1197,6 +1220,8 @@ impl Game<'_> {
["reload"] => {
let _ = state.lists.lock().unwrap().white.load();
self.console_reply(Text::translate("commands.whitelist.reloaded", &[]));
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
["add", name] | ["remove", name] => {
let Some(user) = self.resolve(name) else {
@@ -1222,6 +1247,10 @@ impl Game<'_> {
Vec::new()
};
self.console_reply(Text::translate(key, &args));
if ok && rest[0] == "remove" {
let lists = state.lists.clone();
self.kick_unlisted_players(&lists);
}
}
_ => self.console_reply(Text::translate("command.unknown.argument", &[])),
}
@@ -1237,6 +1266,38 @@ impl Game<'_> {
}
}
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
/// whitelist on, whoever is online and not on the whitelist file is
/// disconnected (operators are not spared here).
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
let unlisted: Vec<u128> = {
let l = lists.lock().unwrap();
let Some(host) = self.lan.as_ref() else {
return;
};
if !(l.enforce_whitelist && l.use_whitelist) {
return;
}
host.hub
.guests
.iter()
.filter(|g| {
!l.white.contains(&NameAndId {
id: g.uuid,
name: g.name.clone(),
})
})
.map(|g| g.uuid)
.collect()
};
for uuid in unlisted {
self.kick_uuid(
uuid,
translatable("multiplayer.disconnect.not_whitelisted", &[]),
);
}
}
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h
@@ -672,6 +672,7 @@ impl Game<'_> {
}
let source = match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
_ => "minecraft:player_attack",
};
out.push(format!(
@@ -1548,6 +1548,7 @@ impl Game<'_> {
return Ok((Some(stack), FAIL));
};
let target = (placed.x, placed.y, placed.z);
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
self.world.block_entities.insert(target, entity);
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
+109 -21
View File
@@ -48,6 +48,9 @@ pub(super) struct Fight {
respawn: Option<(Respawn, i32)>,
/// The four crystals on the exit portal driving the respawn.
respawn_crystals: Vec<Vec3>,
/// The player behind the crystal blast under way, whose `explosion`
/// damage source names them (`EndCrystal.hurtServer`).
blast_by: Option<FightWho>,
dirty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -262,6 +265,9 @@ pub(super) struct Dragon {
/// only damage above the last hit lands, and only the excess.
invulnerable: i32,
last_hurt: f32,
/// `lastHurtByPlayer` with its 100 tick memory: the player credited with
/// the kill, and the dragon tick of their last hit.
hurt_by_player: Option<(FightWho, i32)>,
pub death_time: i32,
pub flap: f32,
pub old_flap: f32,
@@ -385,6 +391,7 @@ impl Dragon {
hurt_time: 0,
invulnerable: 0,
last_hurt: 0.0,
hurt_by_player: None,
death_time: 0,
flap: 0.0,
old_flap: 0.0,
@@ -426,6 +433,10 @@ impl Dragon {
pub fn alive(&self) -> bool {
self.health > 0.0
}
/// `isDeadOrDying` for the killing blow: the dying flight, or no health.
fn down(&self) -> bool {
matches!(self.phase, Phase::Dying { .. }) || !self.alive()
}
fn part_box(&self, i: usize) -> Aabb {
let (w, h) = PART_SIZES[i];
let p = self.parts[i].0;
@@ -891,8 +902,12 @@ impl Game<'_> {
.any(|p| p.distance_squared(c.position) < 0.01);
if damage == CrystalDamage::Direct {
let at = [c.position.x, c.position.y, c.position.z];
// The blast's damage source carries the player, so the dragon
// remembers them before the blast can kill it.
self.fight.blast_by = by;
let _ =
self.server_explode(at, 6.0, super::explosion::BlockInteraction::Destroy, false);
self.fight.blast_by = None;
}
if respawn_crystal {
self.abort_respawn();
@@ -903,7 +918,7 @@ impl Game<'_> {
if let Some(d) = self.dragon.as_mut() {
if d.nearest_crystal == Some(index) {
d.nearest_crystal = None;
self.hurt_dragon(0, 10.0, true, false);
self.hurt_dragon(0, 10.0, true, by, false);
} else if let Some(n) = d.nearest_crystal.as_mut()
&& *n > index
{
@@ -975,17 +990,43 @@ impl Game<'_> {
self.destroy_crystal(index, by, CrystalDamage::Direct);
Hurt::Landed { full: true }
} else if id >= SPECIAL {
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, arrow)
let by = by_player.then(|| self.acting_player());
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
} else {
Hurt::Refused
}
}
/// `EnderDragon.hurt(part, source, damage)` on the dragon in the level.
fn hurt_dragon(&mut self, part: usize, damage: f32, counts: bool, arrow: bool) -> Hurt {
/// `by` is the player behind the hit, remembered for the kill credit.
fn hurt_dragon(
&mut self,
part: usize,
damage: f32,
counts: bool,
by: Option<FightWho>,
arrow: bool,
) -> Hurt {
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
let was_down = d.down();
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
let hurt = d.hurt(part, damage, counts, arrow);
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
// (100 ticks); a hit the damage cooldown absorbs returns before that.
if counts
&& matches!(hurt, Hurt::Landed { .. })
&& (d.invulnerable != cooldown || d.last_hurt != last_hurt)
&& let Some(who) = by
{
d.hurt_by_player = Some((who, d.ticks));
}
if !was_down && d.down() {
self.credit_dragon_kill();
}
let Some(d) = self.dragon.as_ref() else {
return hurt;
};
if hurt == (Hurt::Landed { full: true }) {
let at = [d.position.x, d.position.y, d.position.z];
let pitch = 0.8 + self.world.random.next_float() * 0.3;
@@ -994,6 +1035,47 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.die`: `getKillCredit().awardKillScore`, for the player
/// who hurt the dragon within the last 100 ticks. A guest's kill is
/// theirs: their statistics, scores and advancements.
fn credit_dragon_kill(&mut self) {
let Some(d) = self.dragon.as_ref() else {
return;
};
let Some((who, _)) = d.hurt_by_player.filter(|(_, hit)| d.ticks - hit <= 100) else {
return;
};
let view = horizon_client::advancements::EntityView {
kind: "minecraft:ender_dragon".to_owned(),
position: [d.position.x, d.position.y, d.position.z],
..Default::default()
};
let id = d.id;
let guest = match who {
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
_ => None,
};
let credited = guest.is_some_and(|conn| {
let Some(mut host) = self.lan.take() else {
return false;
};
let done = self
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
.is_some();
self.lan = Some(host);
done
});
if !credited {
self.award_dragon_kill(id, &view);
}
}
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_entity("minecraft:player_killed_entity", "entity", view);
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
@@ -1031,14 +1113,11 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
let modifiers = self
.attack_item_info()
.map(|i| (i.attack_damage, i.attack_speed))
.unwrap_or((0.0, 0.0));
let speed = (4.0 + modifiers.1).max(0.1);
let scale = ((self.attack_ticks as f32 + 0.5) / (20.0 / speed)).clamp(0.0, 1.0);
let hand_damage = self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let delay = horizon_client::attack_strength::delay(self.main_hand_attack_speed());
let scale = ((self.attack_ticks as f32 + 0.5) / delay).clamp(0.0, 1.0);
let mut amount =
(1.0 + modifiers.0 + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
(1.0 + hand_damage + self.effect_attack_bonus()) * (0.2 + scale * scale * 0.8);
let falling = self
.player
.as_ref()
@@ -1374,7 +1453,16 @@ impl Game<'_> {
}
}
let d2 = (pp.x - pos.x).powi(2) + (pp.y - pos.y).powi(2) + (pp.z - pos.z).powi(2);
if d2 < 4096.0 {
// Entity.hasLineOfSight: eye to eye (the dragon's eye is 0.85 of
// its 8 block height), blocks only.
let sees = d2 < 4096.0 && {
let eye = Vec3::new(pos.x, pos.y + 6.8, pos.z);
let target_eye =
tracked.map_or(pp.y + 1.62, |p| p.bounds.max_position().y - 0.18);
self.mob_world_base(&[])
.line_of_sight(eye, Vec3::new(pp.x, target_eye, pp.z))
};
if sees {
*charge += 1;
let d = self.dragon.as_ref().expect("dragon");
let r = d.yaw.to_radians();
@@ -2127,10 +2215,12 @@ impl Game<'_> {
c.at[1],
c.at[2] + f64::from(a.sin() * d),
];
self.particles.add(
self.particles.add_with(
&ParticleOptions::simple(ParticleType::DragonBreath),
at,
[0.0, 0.0, 0.0],
false,
true,
);
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
@@ -2720,8 +2810,9 @@ impl Game<'_> {
let (lo, hi) = (b.min_position(), b.max_position());
self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
});
let by = self.fight.blast_by;
for (part, amount) in hits {
self.hurt_dragon(part, amount, true, false);
self.hurt_dragon(part, amount, true, by, false);
}
}
}
@@ -2859,14 +2950,11 @@ impl Game<'_> {
let (a, b) = (room.min_position(), room.max_position());
lo.x < b.x && hi.x > a.x && lo.y < b.y && hi.y > a.y && lo.z < b.z && hi.z > a.z
};
let player = self.player.as_ref().is_some_and(|p| {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
touches(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(p.position.x + w, p.position.y + h, p.position.z + w),
)
});
// Level.getEntities: the host's player and every guest in the level.
let player = self
.fight_players()
.iter()
.any(|p| !p.spectator && touches(p.bounds.min_position(), p.bounds.max_position()));
player
|| self.crystals.iter().any(|c| {
touches(
+273 -24
View File
@@ -3,14 +3,18 @@
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
//! the armor attribute effects (respiration, depth strider, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
//! the armor attribute effects (respiration, depth strider, ...), Soul
//! Speed's `location_changed` and `tick` effects and the
//! `minecraft:location_changed` disk effect (frost walker).
use super::Game;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
mobs::Who,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
use serde_json::Value;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -69,7 +73,7 @@ impl Game<'_> {
}
/// `getEnchantedDamage - base`: damage bonus of the held weapon against a mob type.
pub(super) fn enchant_damage_bonus(&mut self, target: &str) -> f32 {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return 0.0;
};
let ctx = EffectContext {
@@ -102,7 +106,7 @@ impl Game<'_> {
/// whether the hit is that knockback attack.
pub(super) fn extra_knockback(&mut self, target_kind: &str, scale: f32) -> (f32, bool) {
let knockback_attack = scale > 0.9 && self.player.as_ref().is_some_and(|p| p.sprinting);
let enchanted = match self.inventory[self.selected].clone() {
let enchanted = match self.held().cloned() {
Some(held) => {
let ctx = EffectContext {
this: Some(target_kind),
@@ -144,7 +148,7 @@ impl Game<'_> {
}
/// After a successful melee hit: fire aspect and wind burst.
pub(super) fn post_attack(&mut self, mob: usize) {
let Some(held) = self.inventory[self.selected].clone() else {
let Some(held) = self.held().cloned() else {
return;
};
let Some(m) = self.mobs.get(mob) else { return };
@@ -157,8 +161,10 @@ impl Game<'_> {
items: Some(self.res.items()),
..Default::default()
};
// minecraft:post_attack ignite effects of the attacker's weapon (fire aspect).
// minecraft:post_attack effects of the attacker's weapon on the victim:
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
@@ -168,24 +174,33 @@ impl Game<'_> {
.into_iter()
.flatten()
{
if effect["enchanted"] == "attacker"
&& effect["affected"] == "victim"
&& effect["effect"]["type"] == "minecraft:ignite"
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if ok {
ignite = ignite.max(horizon_client::enchant::level_value(
&effect["effect"]["duration"],
*level,
));
}
continue;
}
let mut random = self.world.random.clone();
let ok = effect.get("requirements").is_none_or(|r| {
self.res
.stations()
.enchantments
.condition(r, *level, &ctx, &mut random)
});
self.world.random = random;
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
mob_effects.push((e.clone(), *level));
}
}
}
@@ -195,8 +210,41 @@ impl Game<'_> {
{
m.ignite_for_ticks((ignite * 20.0) as i32);
}
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
self.post_attack_bursts(&held, &kind);
}
/// `ApplyMobEffect.apply`: one of `to_apply` (a single id or a list) for a
/// random duration and amplifier between the level-based bounds.
fn apply_mob_effect(&mut self, mob: usize, effect: &serde_json::Value, level: u32) {
use horizon_client::enchant::level_value;
let Some(m) = self.mobs.get_mut(mob) else {
return;
};
let ids: Vec<&str> = match &effect["to_apply"] {
serde_json::Value::String(id) => vec![id.as_str()],
serde_json::Value::Array(list) => list.iter().filter_map(|v| v.as_str()).collect(),
_ => Vec::new(),
};
if ids.is_empty() {
return;
}
// HolderSet.getRandomElement, then Mth.randomBetween for both values.
let pick = m.random.next_int_bound(ids.len() as i32).unwrap_or(0) as usize;
let mut between = |min: &str, max: &str| {
let (lo, hi) = (
level_value(&effect[min], level),
level_value(&effect[max], level),
);
m.random.next_float() * (hi - lo) + lo
};
let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32;
let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0);
if let Some(id) = super::potions::mob_effect_id(ids[pick]) {
m.add_effect(id, ticks, amplifier);
}
}
/// `minecraft:post_attack` explode effects on the attacker (wind burst):
/// a burst at the player's feet that only knocks back and triggers
/// blocks (`ExplodeEffect` with `block_interaction: trigger`).
@@ -307,8 +355,10 @@ impl Game<'_> {
continue;
}
hits.push(1.0 + self.world.random.next_float() * 4.0);
// ChangeItemDamage -> ItemStack.hurtAndBreak: unbreaking applies.
let loss = self.durability_loss(&stack, 2);
if let Some(s) = self.armor[slot].as_mut() {
s.damage += 2;
s.damage += loss;
if self
.res
.items()
@@ -590,6 +640,173 @@ impl Game<'_> {
self.apply_equipment();
}
}
/// `EnchantmentHelper.runLocationChangedEffects` for the worn armor of the
/// player, run on the server once the player has moved: `LivingEntity.baseTick`
/// runs it when the block position changed, `checkFallDamage` when it lands.
/// `from` and `was_on_ground` are where and how the tick began.
pub(super) fn location_changed_effects(&mut self, from: [f64; 3], was_on_ground: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let on_ground = p.ground.on_ground;
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
return;
}
let riding = self.riding.is_some();
let mut disks = Vec::new();
for (stack, slot) in self.armor.iter().zip(["head", "chest", "legs", "feet"]) {
let Some(stack) = stack else { continue };
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
{
continue;
}
for effect in def.effects["minecraft:location_changed"]
.as_array()
.into_iter()
.flatten()
{
if effect["effect"]["type"] == "minecraft:replace_disk"
&& location_requirements(&effect["requirements"], on_ground, riding)
{
disks.push((effect["effect"].clone(), *level));
}
}
}
}
for (effect, level) in disks {
self.replace_disk(&effect, level, at);
}
}
/// `ReplaceDisk.apply`: the blocks of the disk around `at` that pass the
/// predicate become the effect's state, and each placement is a game event.
fn replace_disk(&mut self, effect: &Value, level: u32, at: [f64; 3]) {
let registry = self.pack.assets.registry;
let spec = &effect["block_state"];
let Some(default) = spec["id"].as_str().and_then(|n| registry.default_state(n)) else {
return;
};
let Some(mut state) = registry.state(default).cloned() else {
return;
};
for (k, v) in spec["properties"].as_object().into_iter().flatten() {
state
.properties
.insert(k.clone(), v.as_str().unwrap_or("").to_owned());
}
let new = registry.resolve(&state).unwrap_or(default);
let radius = level_value(&effect["radius"], level) as i32;
let height = level_value(&effect["height"], level) as i32;
let offset = cell_offset(effect);
let center = [0, 1, 2].map(|i| at[i].floor() as i32 + offset[i]);
let event = effect["trigger_game_event"].as_str() == Some("minecraft:block_place");
for dy in (height - 1).min(0)..=0 {
for dz in -radius..=radius {
for dx in -radius..=radius {
let pos = [center[0] + dx, center[1] + dy, center[2] + dz];
let (x, z) = (
f64::from(pos[0]) + 0.5 - at[0],
f64::from(pos[2]) + 0.5 - at[2],
);
if x * x + z * z >= f64::from(radius * radius)
|| !self.disk_predicate(&effect["predicate"], pos, new)
|| self.level_set(pos, new).is_err()
{
continue;
}
if event {
self.post_block_game_event(
"minecraft:block_place",
pos,
Some(Who::Player),
Some(new),
);
}
}
}
}
}
/// The `BlockPredicate` subset the disk effect uses, tested at `pos`; no
/// predicate matches everything.
fn disk_predicate(&mut self, p: &Value, pos: [i32; 3], placed: BlockStateId) -> bool {
if p.is_null() {
return true;
}
let offset = cell_offset(p);
let at = [0, 1, 2].map(|i| pos[i] + offset[i]);
let state = self.world.block(at[0], at[1], at[2]);
let b = self.res.behaviours();
let names = |v: &Value| -> Vec<String> {
match v {
Value::String(s) => vec![s.clone()],
Value::Array(a) => a
.iter()
.filter_map(|n| n.as_str().map(String::from))
.collect(),
_ => Vec::new(),
}
};
match p["type"].as_str().unwrap_or("") {
"minecraft:all_of" => p["predicates"]
.as_array()
.into_iter()
.flatten()
.all(|q| self.disk_predicate(q, pos, placed)),
// `MatchingBlockTagPredicate`
"minecraft:matching_block_tag" => b
.tag(p["tag"].as_str().unwrap_or("").trim_start_matches('#'))
.is_some_and(|t| t.contains(b.block(state))),
// `MatchingBlocksPredicate`
"minecraft:matching_blocks" => {
names(&p["blocks"]).iter().any(|n| n == b.table.name(state))
}
// `MatchingFluidsPredicate`: Fluids.WATER is the source.
"minecraft:matching_fluids" => {
let fluid = b.fluid(state);
names(&p["fluids"]).iter().any(|n| match n.as_str() {
"minecraft:water" => fluid.kind() == FluidKind::Water && fluid.is_source(),
"minecraft:flowing_water" => {
fluid.kind() == FluidKind::Water && !fluid.is_source()
}
_ => false,
})
}
// `UnobstructedPredicate`: no building-blocking entity in the block.
"minecraft:unobstructed" => self.with_level(|_, level, _| {
level.is_unobstructed(BlockPos::new(pos[0], pos[1], pos[2]), placed)
}),
_ => false,
}
}
/// Whether a worn enchantment gives immunity to `#burn_from_stepping`
/// damage (frost walker's `minecraft:damage_immunity`).
pub(crate) fn wears_burn_from_stepping_immunity(&self) -> bool {
self.armor
.iter()
.zip(["head", "chest", "legs", "feet"])
.any(|(stack, slot)| {
stack
.iter()
.flat_map(ItemStack::enchantments)
.any(|(id, _)| {
self.res.stations().enchantments.get(id).is_some_and(|def| {
def.effects.get("minecraft:damage_immunity").is_some()
&& def
.slots
.iter()
.any(|s| s == slot || s == "armor" || s == "any")
})
})
})
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
@@ -638,3 +855,35 @@ fn soul_speed_modifiers(
})
.collect()
}
/// An effect's or predicate's `offset` vector (zero when absent).
fn cell_offset(v: &Value) -> [i32; 3] {
let at = |i: usize| {
v["offset"]
.get(i)
.and_then(Value::as_i64)
.map_or(0, |n| n as i32)
};
[at(0), at(1), at(2)]
}
/// The loot-condition subset a `location_changed` effect requires of the
/// wearer: standing on the ground and not riding (frost walker).
fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
match req["type"].as_str().unwrap_or("") {
"" if req.is_null() => true,
"minecraft:all_of" => req["terms"]
.as_array()
.into_iter()
.flatten()
.all(|t| location_requirements(t, on_ground, riding)),
"minecraft:inverted" => !location_requirements(&req["term"], on_ground, riding),
"minecraft:entity_properties" => {
let p = &req["predicate"];
p["minecraft:flags"]["is_on_ground"]
.as_bool()
.is_none_or(|v| v == on_ground)
&& (p["minecraft:vehicle"].is_null() || riding)
}
_ => false,
}
}
+83 -20
View File
@@ -11,6 +11,7 @@ use horizon_client::{
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
use horizon_core::{
geometry::Vec3,
@@ -253,10 +254,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
};
}
}
@@ -521,7 +529,15 @@ impl Game<'_> {
let position = if let Some(job) = self.end.job.take() {
self.resolve_gateway(job, target)?
} else {
let (position, yaw) = self.end_arrival(target)?;
let mut finder = self.player_spawn.take();
let arrival = self.end_arrival(target, &mut finder);
self.player_spawn = finder;
let Some((position, yaw)) = arrival? else {
// A spawn candidate's chunk is loading: try again next tick.
self.travelling = Some((player, target));
self.end_travel = true;
return Ok(());
};
if let Some(yaw) = yaw {
self.yaw = yaw;
player.yaw = yaw;
@@ -546,7 +562,13 @@ impl Game<'_> {
/// Where an End portal's traveller lands once `target` has loaded, and
/// the yaw it faces there: the platform below the End's spawn point,
/// facing west, or the top of the Overworld's spawn column.
pub(super) fn end_arrival(&mut self, target: Vec3) -> Result<(Vec3, Option<f32>)> {
/// None while a candidate of the world spawn's search loads (`finder`
/// keeps it, see `search_spawn`).
pub(super) fn end_arrival(
&mut self,
target: Vec3,
finder: &mut Option<PlayerSpawnFinder>,
) -> Result<Option<(Vec3, Option<f32>)>> {
if self.dim == Dim::End {
// EndPlatformFeature.createEndPlatform below the spawn point.
let origin = [END_SPAWN[0], END_SPAWN[1] - 1, END_SPAWN[2]];
@@ -566,22 +588,32 @@ impl Game<'_> {
}
}
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
return Ok(Some((target, Some(90.0))));
}
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok((at, Some(yaw)));
return Ok(Some((at, Some(yaw))));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
}
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
Ok((Vec3::new(target.x, y, target.z), None))
let (x, z) = (target.x.floor() as i32, target.z.floor() as i32);
// TeleportTransition.missingRespawnBlock: PlayerSpawnFinder around
// the world spawn.
if let Some(world) = self.level.world_spawn.filter(|w| w[0] == x && w[2] == z) {
return Ok(match self.search_spawn(world, finder) {
Ok(at) => Some((at, None)),
Err(chunk) => {
self.spawn_wait.push(chunk);
None
}
});
}
let y = self.land_column(x, z).map_or(target.y, f64::from);
Ok(Some((Vec3::new(target.x, y, target.z), None)))
}
/// Dying away from the Overworld respawns at the world spawn there.
@@ -599,7 +631,9 @@ impl Game<'_> {
/// EnderDragonFight without a dragon: once the centre of the main island
/// is loaded, place the active exit portal (EndPodiumFeature) and the
/// first gateway (spawnNewGateway) if the portal is not there yet.
/// first gateway (spawnNewGateway) if the portal is not there yet. Like
/// scanState and spawnExitPortal, only while the fight has no exit portal
/// location: a podium that is later broken stays broken.
fn ensure_exit_portal(&mut self) -> Result<()> {
if self.travelling.is_some()
|| !(-1..=0).all(|cz| (-1..=0).all(|cx| self.streaming.is_lit(cx, cz)))
@@ -609,8 +643,14 @@ impl Game<'_> {
}
let won = self.fight.previously_killed;
let gateway = self.first_gateway();
if (0..256).any(|y| &*self.block_name([0, y, 0]) == "minecraft:bedrock") {
// The podium exists; the gateway may have been out of range when it was placed.
if self.exit_portal().is_none() {
// findExitPortal remembers the location of a podium that is already there.
if let Some(level) = self.podium_level() {
self.set_exit_portal([0, level, 0]);
}
}
if self.exit_portal().is_some() {
// The gateway may have been out of range when it was placed.
if won
&& self.world.loaded(gateway[0], gateway[2])
&& &*self.block_name(gateway) != "minecraft:end_gateway"
@@ -641,17 +681,40 @@ impl Game<'_> {
}
Ok(())
}
/// The portal level of the podium around the centre column, read from the
/// bedrock that EndPodiumFeature places (rim at the portal level, disc
/// below it, the torch column) so a broken column does not hide it.
fn podium_level(&self) -> Option<i32> {
let mut offsets: Vec<[i32; 3]> = (0..4).map(|dy| [0, dy, 0]).collect();
for z in -4..=4i32 {
for x in -4..=4i32 {
let flat = f64::from(x * x + z * z);
if (6.25..12.25).contains(&flat) {
offsets.push([x, 0, z]);
} else if flat + 1.0 < 6.25 {
offsets.push([x, -1, z]);
}
}
}
(1..253)
.map(|y| {
let score = offsets
.iter()
.filter(|o| &*self.block_name([o[0], y + o[1], o[2]]) == "minecraft:bedrock")
.count();
(score, y)
})
.filter(|&(score, _)| score >= 3)
.max()
.map(|(_, y)| y)
}
/// `spawnExitPortal(true)` after the dragon dies.
pub(super) fn activate_exit_portal(&mut self) -> Result<()> {
let origin = match self.exit_portal() {
Some(o) => o,
None => {
// The rim's level: the top of the bedrock column minus three.
let top = (0..256)
.rev()
.find(|&y| &*self.block_name([0, y, 0]) == "minecraft:bedrock")
.unwrap_or(64);
[0, top - 3, 0]
// The rim's level, found without assuming the centre column is intact.
[0, self.podium_level().unwrap_or(61), 0]
}
};
self.place_podium(origin, true)
+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,
+111 -12
View File
@@ -1,7 +1,7 @@
//! Fishing: `FishingRodItem.use` (cast / reel in), the `FishingHook` (flight,
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use super::{Game, lan::LanHost, lan_mobs::guest_bounds, view_vector};
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
@@ -14,6 +14,7 @@ use horizon_client::{
};
use horizon_core::{
geometry::{Aabb, Vec3, trig},
position::BlockPos,
random::RandomSource,
};
use horizon_protocol::entity_data::{Value, serializer};
@@ -30,8 +31,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,
@@ -45,6 +46,13 @@ pub(super) struct Hook {
luck: i32,
lure: i32,
}
/// A player a hook can catch (`canHitEntity`): their entity id, which
/// guest they are (none: the host) and their box.
pub(super) struct HookTarget {
id: u32,
conn: Option<u32>,
bounds: Aabb,
}
const SIZE: f64 = 0.25;
fn bounds(p: Vec3) -> Aabb {
let h = SIZE / 2.0;
@@ -166,6 +174,11 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
.hook_pulls
.push((id, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1)));
}
// `hookedIn instanceof ItemEntity ? 3 : 5`.
damage = if hooked_item { 3 } else { 5 };
@@ -236,7 +249,7 @@ impl Game<'_> {
item,
[hook.position.x, hook.position.y, hook.position.z],
v,
10,
0,
);
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
@@ -318,9 +331,12 @@ impl Game<'_> {
let mut speed = 1;
let above = [b[0], b[1] + 1, b[2]];
let sky = self.world.light(above[0], above[1], above[2]) >> 4 == 15;
let raining = self.world.weather.raining;
let r = &mut self.world.random;
if r.next_float() < 0.25 && raining && sky {
// Level.isRainingAt: rain (not snow) reaches the position.
if self.world.random.next_float() < 0.25
&& self.with_level(|b, level, _| {
b.is_raining_at(level, BlockPos::new(above[0], above[1], above[2]))
})
{
speed += 1;
}
let r = &mut self.world.random;
@@ -449,6 +465,15 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
&& matches!(h.state, State::Flying | State::Hooked(_))
&& let Some(host) = self.lan.take()
{
self.refresh_hook_targets(&host);
self.lan = Some(host);
}
h.previous = h.position;
if h.on_ground {
h.life += 1;
@@ -474,8 +499,8 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob it touches, or (via
// `canHitEntity`) a dropped item.
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
@@ -487,6 +512,14 @@ impl Game<'_> {
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
self.dropped
.iter()
@@ -501,12 +534,25 @@ impl Game<'_> {
}
}
State::Hooked(id) => {
// updateHookedEntity: ride the hooked mob or item at 0.8 of its height.
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id && m.alive())
.map(|m| (m.position, f64::from(m.height())))
.or_else(|| {
self.creatures
.hook_targets
.iter()
.find(|t| t.id == id)
.map(|t| {
let (lo, hi) = (t.bounds.min_position(), t.bounds.max_position());
(
Vec3::new((lo.x + hi.x) / 2.0, lo.y, (lo.z + hi.z) / 2.0),
hi.y - lo.y,
)
})
})
.or_else(|| {
self.dropped
.iter()
@@ -575,6 +621,59 @@ impl Game<'_> {
);
self.hook = Some(h);
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
pub(super) fn refresh_hook_targets(&mut self, host: &LanHost) {
let mut targets = Vec::new();
if self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
{
targets.push(HookTarget {
id: host.host_entity as u32,
conn: None,
bounds: p.bounds(),
});
}
for g in &host.hub.guests {
if host.hub.here(g) && g.loaded && !g.dead && !g.spectator() {
targets.push(HookTarget {
id: g.entity_id as u32,
conn: Some(g.conn),
bounds: guest_bounds(g),
});
}
}
self.creatures.hook_targets = targets;
}
/// `FishingHook.pullEntity` on the players the hooks reeled in this tick
/// pulled: their velocity moves, and a guest's reaches their client.
pub(super) fn flush_hook_pulls(&mut self) {
let pulls = std::mem::take(&mut self.creatures.hook_pulls);
let Some(mut host) = self.lan.take() else {
return;
};
for (id, delta) in pulls {
let push = |g: &mut Self| {
if let Some(p) = g.player.as_mut() {
p.velocity = p.velocity.add_vector(delta);
}
};
if id == host.host_entity as u32 {
push(self);
} else if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id)
.map(|g| g.conn)
{
self.guest_hit_by(&mut host, conn, push);
}
}
self.lan = Some(host);
}
/// `FishingRodCast`: the stack in the arm holding the rod
/// (`FishingHookRenderer.getHoldingArm`) while the hook is out.
pub(super) fn cast_rod(&self) -> Option<&ItemStack> {
@@ -770,8 +869,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 {
+15 -1
View File
@@ -7,6 +7,7 @@ use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use std::sync::Arc;
@@ -223,6 +224,7 @@ impl Game<'_> {
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
self.swing_arm();
true
}
@@ -255,8 +257,17 @@ impl Game<'_> {
}
self.drop_frame_items(&f, with_frame, true);
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
true
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
/// the comparators reading the frame take its new signal.
fn frame_output_changed(&mut self, pos: [i32; 3]) {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
@@ -300,6 +311,9 @@ impl Game<'_> {
self.audio
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
self.drop_frame_items(&f, true, false);
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
continue;
}
}
@@ -456,7 +470,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);
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Glowing entities (`EntityRenderer.extractRenderState`'s outlineColor): which entities
//! appear glowing, in which team colour, and their silhouettes for the renderer's entity
//! outline (`OutlineBufferSource`).
use super::{Game, Vertex};
use horizon_client::{mobs::Mob, scoreboard as sb};
/// `Entity.getTeamColor`: the team's colour, else white.
pub(super) fn team_color(team: Option<&sb::Team>) -> u32 {
team.and_then(|t| t.color.as_deref())
.and_then(sb::team_color_rgb)
.unwrap_or(0xFF_FFFF)
}
impl Game<'_> {
/// `Entity.isCurrentlyGlowing` of an entity that is not living (an item, a projectile):
/// its `Glowing` tag, or shared flag 6 as a server sent it.
pub(super) fn entity_glowing(&self, id: u32) -> bool {
self.remote_glowing(id).unwrap_or_else(|| {
self.persist_raw(id)
.and_then(|t| horizon_client::save::number(t.get("Glowing")))
.is_some_and(|b| b != 0)
})
}
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
/// colour (`getTeamColor`; another player's team goes by name).
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
if !m.is_currently_glowing() {
return None;
}
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
};
Some(team_color(team))
}
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
/// alpha kept (`OutlineBufferSource`'s team-coloured copy of each draw).
pub(super) fn mark_outline(&self, vertices: &[Vertex], rgb: u32) {
let color = horizon_client::mobs::render::abgr(rgb);
self.glowing_vertices
.borrow_mut()
.extend(vertices.iter().map(|v| Vertex {
color,
normal: [0; 4],
..*v
}));
}
}
+77 -7
View File
@@ -3,7 +3,7 @@
//! `continueAttack`, `MultiPlayerGameMode` block breaking), the arm swing
//! (`LivingEntity.SwingState`) and the first-person equip heights
//! (`FirstPersonHandsAndItems`) with the item swap ticker they read.
use super::{Game, Mining, Screen};
use super::{Game, Mining, Screen, minecarts::CartKind};
use horizon_client::{
Result,
attack_strength::HeldStack,
@@ -144,6 +144,11 @@ impl Game<'_> {
let harvest = !facts.requires_tool || self.res.items().correct_for_drops(held, &facts.name);
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
}
/// A key for the main-hand stack (`destroyingItem`): its item and components.
pub(super) fn main_hand_key(&self) -> String {
self.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
}
/// Whether an attack would hit an entity rather than the block behind
/// it: the crosshair's `EntityHitResult`.
pub(super) fn entity_targeted(&self) -> bool {
@@ -289,12 +294,79 @@ impl Game<'_> {
// Only alive mobs are hit, so a picked living entity is alive.
Some(true)
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier.
/// `Hud.canRenderCrosshairForSpectator` for the nearest hit in reach: an
/// entity that is a `MenuProvider` (a chest or hopper minecart, a chest
/// boat), or a block whose `showAsInteractableInSpectatorMode` holds.
pub(super) fn spectator_crosshair(&self) -> bool {
let cart = self.cart_hit().map(|(i, d)| {
let kind = &self.carts[i].kind;
(d, matches!(kind, CartKind::Chest | CartKind::Hopper))
});
let boat = self.boat_hit().map(|(i, d)| (d, self.boats[i].chest));
let others = [
self.mob_hit().map(|h| h.1),
self.armor_stand_hit().map(|h| h.1),
self.cushion_hit().map(|h| h.1),
self.painting_hit().map(|h| h.1),
self.frame_hit().map(|h| h.1),
self.dragon_hit().map(|h| h.1),
];
let nearest = cart
.into_iter()
.chain(boat)
.chain(others.into_iter().flatten().map(|d| (d, false)))
.min_by(|a, b| a.0.total_cmp(&b.0));
if let Some((distance, menu)) = nearest
&& self.before_block(distance)
{
return menu;
}
let Some(hit) = self.pick() else {
return false;
};
let name = &*self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name;
let name = name.trim_start_matches("minecraft:");
match name {
// BlockBehaviour.getMenuProvider and `instanceof Portal`.
"crafting_table" | "enchanting_table" | "anvil" | "chipped_anvil" | "damaged_anvil"
| "stonecutter" | "loom" | "cartography_table" | "grindstone" | "smithing_table"
| "barrel" | "furnace" | "blast_furnace" | "smoker" | "brewing_stand" | "hopper"
| "dispenser" | "dropper" | "crafter" | "lectern" | "beacon" | "nether_portal"
| "end_portal" | "end_gateway" => true,
// ChestBlock.getMenuProvider: none while a redstone conductor
// sits on top of either half (a sitting cat aside).
"chest" | "trapped_chest" => {
let key = (hit.pos[0], hit.pos[1], hit.pos[2]);
let blocked = |p: (i32, i32, i32)| {
let above = self.world.block(p.0, p.1 + 1, p.2);
self.res.behaviours().facts(above).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
};
!blocked(key) && self.chest_other_half(key).is_none_or(|o| !blocked(o))
}
_ => name.ends_with("shulker_box"),
}
}
/// `Attributes.ATTACK_SPEED` with the main-hand item's modifier and the
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
/// the attribute's range.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
horizon_client::attack_strength::BASE_ATTACK_SPEED
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
+ self
.attack_item_info()
.map_or(0.0, |i| f64::from(i.attack_speed))
.map_or(0.0, |i| f64::from(i.attack_speed));
self.potions
.effects
.modifiers()
.into_iter()
.filter(|m| m.0 == "minecraft:attack_speed")
.fold(value, |v, m| v * (1.0 + m.2))
.clamp(0.0, 1024.0)
}
/// The item whose attack damage and speed modifiers the player has (`attack_item`).
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
@@ -445,9 +517,7 @@ impl HandWorld for GameHands<'_, '_> {
true
}
fn main_hand_key(&mut self) -> String {
self.game
.held()
.map_or(String::new(), |s| format!("{} {:?}", s.item, s.extra))
self.game.main_hand_key()
}
fn destroy_stage(&mut self, _: [i32; 3], _: Option<i32>) {}
fn swing(&mut self, hand: Hand, animation: SwingAnimation) {
+35 -16
View File
@@ -212,39 +212,58 @@ impl Game<'_> {
)
.is_ok_and(|shape| shape.is_empty())
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
let mut finder = match self.player_spawn {
Some(finder) => finder,
None => self.spawn_finder(suggestion),
};
let found = loop {
/// PlayerSpawnFinder.findSpawn around the world spawn, as far as the loaded
/// chunks allow: the feet of the first free column within the respawn
/// radius, else the suggestion's own column. The finder waits in `state`
/// (None to begin); Err gives the chunk of the candidate being waited for,
/// which the caller must have loaded (the finder tries at most 1024
/// columns).
pub(super) fn search_spawn(
&self,
suggestion: [i32; 3],
state: &mut Option<PlayerSpawnFinder>,
) -> std::result::Result<Vec3, (i32, i32)> {
let mut finder = state
.take()
.unwrap_or_else(|| self.spawn_finder(suggestion));
let suggestion = finder.suggestion;
loop {
let Some(candidate) = finder.next_candidate() else {
break Some(self.fixup_spawn_height(suggestion));
return Ok(self.fixup_spawn_height(suggestion));
};
let (x, z) = match candidate {
SpawnCandidate::Column(x, z) => (x, z),
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
};
if !self.streaming.is_lit(x >> 4, z >> 4) {
self.streaming.center = (x >> 4, z >> 4);
finder.retry();
break None;
*state = Some(finder);
return Err((x >> 4, z >> 4));
}
match candidate {
SpawnCandidate::Column(x, z) => {
if let Some(pos) = level_respawn_pos(&self.spawn_columns(), x, z)
&& self.player_fits(pos)
{
break Some(bottom_center(pos));
return Ok(bottom_center(pos));
}
}
SpawnCandidate::Suggestion => break Some(self.fixup_spawn_height(suggestion)),
SpawnCandidate::Suggestion => return Ok(self.fixup_spawn_height(suggestion)),
}
};
self.player_spawn = found.is_none().then_some(finder);
Ok(found)
}
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
let mut state = self.player_spawn.take();
match self.search_spawn(suggestion, &mut state) {
Ok(feet) => Ok(Some(feet)),
Err(chunk) => {
self.streaming.center = chunk;
self.player_spawn = state;
Ok(None)
}
}
}
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
/// by the world border; the suggestion itself in adventure mode.
+21 -4
View File
@@ -164,12 +164,18 @@ pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> Owner
..Default::default()
}
}
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
/// `UseDuration` of a stack (`Item.getUseDuration`): 72000 for drawn items
/// and for `blocks_attacks` items (shields), the consume time for food.
fn use_duration(components: &Components) -> i32 {
if let Some(c) = components.get("minecraft:consumable") {
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
return (seconds * 20.0) as i32;
}
if components.get("minecraft:blocks_attacks").is_some()
|| components.get("minecraft:kinetic_weapon").is_some()
{
return 72_000;
}
let item_model = components
.get("minecraft:item_model")
.and_then(Value::as_str)
@@ -179,8 +185,9 @@ fn use_duration(components: &Components) -> i32 {
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:goat_horn"
| "minecraft:brush" => 72_000,
| "minecraft:goat_horn" => 72_000,
// BrushItem.USE_DURATION.
"minecraft:brush" => 200,
_ => 0,
}
}
@@ -247,6 +254,11 @@ impl Game<'_> {
owner.main_arm_left = self.settings.main_hand_left;
owner.is_local_player = true;
owner.is_view_entity = true;
owner.last_death_location = self
.level
.last_death
.clone()
.map(|(dimension, pos)| GlobalPos { dimension, pos });
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
&& self.stack_in(slot) == Some(stack)
{
@@ -405,7 +417,12 @@ impl Game<'_> {
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
self.cull_entity_vertices(out, entity_start);
if self.entity_glowing(item.id) {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
} else {
self.cull_entity_vertices(out, entity_start);
}
}
}
}
+34 -3
View File
@@ -6,10 +6,15 @@ use super::Game;
use horizon_client::{
containers::{BlockEntity, Jukebox},
items::ItemStack,
mobs::Species,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
random::RandomSource,
};
use horizon_world::level::flags as level_flags;
impl Game<'_> {
@@ -94,7 +99,7 @@ impl Game<'_> {
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
fn start_song(&mut self, pos: [i32; 3], song: &str) {
self.stop_song(pos);
self.stop_sound(pos);
let Some(s) = self.res.items().songs.get(song).cloned() else {
return;
};
@@ -106,12 +111,38 @@ impl Game<'_> {
self.titles
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
self.update_song_volumes();
self.notify_record_playing(pos, true);
if self.audio.capture.is_some() {
self.lan_songs.push((pos, Some(song.to_owned())));
}
}
fn stop_song(&mut self, pos: [i32; 3]) {
fn stop_sound(&mut self, pos: [i32; 3]) {
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
self.audio.stop_and_release(id);
}
}
/// Level event 1011: the song ends, here and for the guests.
fn stop_song(&mut self, pos: [i32; 3]) {
self.stop_sound(pos);
self.notify_record_playing(pos, false);
if self.audio.capture.is_some() {
self.lan_songs.push((pos, None));
}
}
/// `LevelEventHandler.notifyNearbyEntities` (level events 1010 and 1011):
/// parrots within three blocks of the jukebox start or stop dancing.
pub(super) fn notify_record_playing(&mut self, pos: [i32; 3], playing: bool) {
let min = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
let area = Aabb::new(min, min.add_vector(Vec3::new(1.0, 1.0, 1.0)))
.inflate(Vec3::new(3.0, 3.0, 3.0));
for m in self
.mobs
.iter_mut()
.filter(|m| m.species() == Species::Parrot && m.bounds().intersects(area))
{
m.extra.party_jukebox = playing.then_some(pos);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
fn eject_record(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
+470 -17
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
mobs::{Mob, Species, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -140,6 +140,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:hoglin"
| "minecraft:zoglin"
| "minecraft:goat"
| "minecraft:ocelot"
| "minecraft:turtle"
@@ -191,10 +192,18 @@ fn mob_variant_meta(
"minecraft:zombie_nautilus_variant",
),
};
// Each registry's default (`define(DATA_VARIANT_ID, ... TEMPERATE)`)
// stays out of the pairing data, as other default values do.
let name = horizon_client::mobs::finalize::variant_name(m.species(), e.variant);
if let Some(v) = id(registry, name.trim_start_matches("minecraft:")) {
let name = name.trim_start_matches("minecraft:");
if let Some(v) = id(registry, name)
&& name != "temperate"
{
meta.push((index, serializer, Value::Id(v)));
}
if let Some(v) = id(registry, "temperate") {
defaults.push((index, serializer, Value::Id(v)));
}
}
// Rabbit.DATA_TYPE_ID (after AgeableMob's two) and
// Parrot.DATA_VARIANT_ID (after TamableAnimal's two and its flags and
@@ -288,8 +297,27 @@ fn mob_meta(
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
// Entity.DATA_SHARED_FLAGS_ID: on fire 1, swimming 16 (a drowned under
// water, `Drowned.setSwimming`), invisible 32 (the effect's flag) and
// glowing 64 (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.is_on_fire())
| i8::from(m.species() == Species::Drowned && m.extra.flag) << 4
| i8::from(m.invisible || horizon_client::mobs::trader::is_invisible(m)) << 5
| i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
// pose back at standing goes out by the entity's own default).
let pose = horizon_client::mobs::sync::pose_id(m);
if pose != 0 {
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
// LivingEntity.DATA_SLEEPING_POS_ID: the bed a sleeping villager lies in.
if horizon_client::mobs::villager::sleeping(m)
&& let Some(bed) = m.extra.bed
{
meta.push((14, serializer::OPTIONAL_BLOCK_POS, Value::OptPos(Some(bed))));
}
if let Some(name) = &m.custom_name {
meta.push((
@@ -385,8 +413,14 @@ fn mob_meta(
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
// AbstractPiglin.DATA_IMMUNE_TO_ZOMBIFICATION (with the dimension it
// makes them shake) and Piglin.DATA_IS_DANCING (after the baby flag
// and the crossbow charge).
"minecraft:piglin" => {
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
@@ -400,6 +434,25 @@ fn mob_meta(
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
}
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID (after AgeableMob's two; sleeping 32, ...).
"minecraft:fox" => {
if e.variant != 0 {
meta.push((18, serializer::INT, Value::Int(i32::from(e.variant))));
}
defaults.push((18, serializer::INT, Value::Int(0)));
let flags = (e.ints[0] & 0xff) as u8 as i8;
if flags != 0 {
meta.push((19, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((19, serializer::BYTE, Value::Byte(0)));
}
"minecraft:parrot" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
if flags != 0 {
meta.push((18, serializer::BYTE, Value::Byte(flags)));
}
}
"minecraft:cat" | "minecraft:wolf" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
@@ -421,6 +474,15 @@ fn mob_meta(
if let Some(v) = id(registry, name) {
meta.push((index, serializer, Value::Id(v)));
}
// Cat.IS_LYING and RELAX_STATE_ONE.
if kind == "minecraft:cat" {
for (index, on) in [(21, e.flag), (22, e.relax_one)] {
if on {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
}
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
let red = i32::from(horizon_client::mobs::tame::RED);
@@ -445,10 +507,16 @@ fn mob_meta(
| "minecraft:skeleton_horse"
| "minecraft:zombie_horse"
| "minecraft:camel" => {
// AbstractHorse.DATA_ID_FLAGS: tame 2.
if e.tame {
meta.push((18, serializer::BYTE, Value::Byte(2)));
// AbstractHorse.DATA_ID_FLAGS: tame 2, eating 16 (grazing), standing
// 32 (rearing).
let grazes = horizon_client::mobs::equine::grazes(m.species());
let flags = if e.tame { 2 } else { 0 }
| if grazes && e.flag { 16 } else { 0 }
| if grazes && e.counter > 0 { 32 } else { 0 };
if flags != 0 {
meta.push((18, serializer::BYTE, Value::Byte(flags)));
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
if kind == "minecraft:horse" {
let v = i32::from(e.variant) | i32::from(e.variant2) << 8;
if v != 0 {
@@ -473,6 +541,15 @@ fn mob_meta(
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
"minecraft:villager" | "minecraft:zombie_villager" => {
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
if kind == "minecraft:villager" {
unhappy_counter(m, meta, defaults);
} else {
// ZombieVillager.DATA_CONVERTING_ID: the cure's shaking.
if horizon_client::mobs::zombie_villager::is_converting(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
let villager_type = VILLAGER_TYPES[e.variant as usize % VILLAGER_TYPES.len()];
let profession = PROFESSIONS[e.variant2 as usize % PROFESSIONS.len()];
if let (Some(t), Some(p)) = (
@@ -514,6 +591,29 @@ fn mob_meta(
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
// Panda: UNHAPPY_COUNTER, SNEEZE_COUNTER and EAT_COUNTER (after
// AgeableMob's two), MAIN_GENE_ID, HIDDEN_GENE_ID and DATA_ID_FLAGS;
// the client animates the states from them.
"minecraft:panda" => {
let (flags, counters) = horizon_client::mobs::panda::get(m)
.map_or((0, [0; 3]), |s| (s.flags, [s.unhappy, s.sneeze, s.eat]));
for (index, value) in (18..).zip(counters) {
if value != 0 {
meta.push((index, serializer::INT, Value::Int(value)));
}
defaults.push((index, serializer::INT, Value::Int(0)));
}
for (index, gene) in [(21, e.variant), (22, e.variant2)] {
if gene != 0 {
meta.push((index, serializer::BYTE, Value::Byte(gene as i8)));
}
defaults.push((index, serializer::BYTE, Value::Byte(0)));
}
if flags != 0 {
meta.push((23, serializer::BYTE, Value::Byte(flags as i8)));
}
defaults.push((23, serializer::BYTE, Value::Byte(0)));
}
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
// rears up; the client animates it.
"minecraft:polar_bear" => {
@@ -529,7 +629,122 @@ fn mob_meta(
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
}
// Enderman.DATA_CARRY_STATE.
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
"minecraft:warden" => {
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
if anger != 0 {
meta.push((16, serializer::INT, Value::Int(anger)));
}
defaults.push((16, serializer::INT, Value::Int(0)));
}
// Sniffer.DATA_STATE (after AgeableMob's two).
"minecraft:sniffer" => {
if e.variant != 0 {
meta.push((
18,
serializer::SNIFFER_STATE,
Value::Id(i32::from(e.variant)),
));
}
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
}
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
// for none).
"minecraft:shulker" => {
if e.variant3 != 0 {
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
}
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
if e.counter != 0 {
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
if e.color != 16 {
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
}
defaults.push((18, serializer::BYTE, Value::Byte(16)));
}
// Vex.DATA_FLAGS_ID: charging in bit 0 (the arms and the glowing
// texture).
"minecraft:vex" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
"minecraft:creaking" => {
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(false)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(true)));
if e.flag {
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
if horizon_client::mobs::creaking::tearing_down(m) {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (the held item is its
// main hand equipment).
"minecraft:allay" => {
if e.flag {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
if e.egg_time > 0 {
meta.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT).
"minecraft:axolotl" => {
if horizon_client::mobs::axolotl::is_playing_dead(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
"minecraft:copper_golem" => {
if e.variant3 != 0 {
meta.push((
17,
serializer::COPPER_GOLEM_STATE,
Value::Id(i32::from(e.variant3)),
));
}
defaults.push((17, serializer::COPPER_GOLEM_STATE, Value::Id(0)));
}
// Armadillo.ARMADILLO_STATE (after AgeableMob's two).
"minecraft:armadillo" => {
let state = horizon_client::mobs::armadillo::state(m);
if state != 0 {
meta.push((18, serializer::ARMADILLO_STATE, Value::Id(i32::from(state))));
}
defaults.push((18, serializer::ARMADILLO_STATE, Value::Id(0)));
}
// Frog.DATA_TONGUE_TARGET_ID (after the variant): the entity id plus
// one, 0 for none.
"minecraft:frog" => {
if let Some(target) = e.frog.tongue_target {
let id = i32::try_from(target).map_or(0, |t| t + 1);
meta.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(id)));
}
defaults.push((19, serializer::OPTIONAL_UNSIGNED_INT, Value::Id(0)));
}
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN (after
// DATA_IS_SCREAMING_GOAT): both true by default.
"minecraft:goat" => {
for (index, lost) in [(19, e.lost_horns[0]), (20, e.lost_horns[1])] {
if lost {
meta.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
}
// Enderman.DATA_CARRY_STATE and DATA_CREEPY (angry: the open mouth).
"minecraft:enderman" => {
if let Some(state) = e.carried {
meta.push((
@@ -538,9 +753,112 @@ fn mob_meta(
Value::Id(state.0 as i32),
));
}
if e.flag {
meta.push((17, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((16, serializer::OPTIONAL_BLOCK_STATE, Value::Id(0)));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the shaking of the conversion under water.
"minecraft:zombie" | "minecraft:husk" => {
if e.counter2 > 0 {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two).
"minecraft:wandering_trader" => unhappy_counter(m, meta, defaults),
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's, the first entry of its subclasses: with the
// dimension it makes them shake.
"minecraft:hoglin" | "minecraft:piglin_brute" => {
let index = if kind == "minecraft:hoglin" { 18 } else { 16 };
if e.flag2 {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
"minecraft:blaze" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
"minecraft:turtle" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// WitherBoss.DATA_TARGET_B and DATA_TARGET_C (after DATA_TARGET_A, the
// main target): the entities the side heads look at, by id.
"minecraft:wither" => {
for (k, index) in [(0, 17), (1, 18)] {
let target = match e.ints[k] {
0 => None,
-1 => player_entity,
id => Some(id),
};
if let Some(target) = target {
meta.push((index, serializer::INT, Value::Int(target)));
}
defaults.push((index, serializer::INT, Value::Int(0)));
}
}
_ => {}
}
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
if horizon_client::mobs::camel::is_camel(m.species()) {
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
if tick != 0 {
meta.push((20, serializer::LONG, Value::Long(tick)));
}
defaults.push((20, serializer::LONG, Value::Long(0)));
}
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// 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)));
}
}
/// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two): the head
/// shake of a villager saying no counts it down from 40.
fn unhappy_counter(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.extra.ints[0] > 0 {
meta.push((18, serializer::INT, Value::Int(m.extra.ints[0])));
}
defaults.push((18, serializer::INT, Value::Int(0)));
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
@@ -551,21 +869,33 @@ 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 {
let stack = ItemStack {
item: item.clone(),
count: 1,
damage: 0,
extra: None,
// A wolf's armor shows its cracks.
damage: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
e.body_damage.max(0) as u32
} else {
0
},
extra: if slot == 6 && m.species() == horizon_client::mobs::Species::Wolf {
e.body_extra.clone()
} else {
None
},
};
out.push((slot, hub.stack(&Some(stack))));
}
@@ -846,6 +1176,7 @@ impl Game<'_> {
self.world.light_log = None;
self.audio.capture = None;
self.lan_events.clear();
self.lan_songs.clear();
self.lan_critical_hits.clear();
self.streaming.extra.clear();
if announce {
@@ -1067,6 +1398,7 @@ impl Game<'_> {
&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;
@@ -1077,6 +1409,10 @@ impl Game<'_> {
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
// Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag).
if self.entity_glowing(d.id) {
e.meta.push((0, serializer::BYTE, Value::Byte(0x40)));
}
e.meta.push((8, serializer::ITEM_STACK, Value::Item(stack)));
}
for o in &self.orbs {
@@ -1183,6 +1519,8 @@ impl Game<'_> {
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
host.hub
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
// A server with several levels runs a pass for each
// (`tick_server`): the network is read once, before the first.
let pass = self.level_pass;
@@ -1235,6 +1573,16 @@ impl Game<'_> {
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
@@ -1266,6 +1614,13 @@ impl Game<'_> {
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
@@ -1305,6 +1660,10 @@ impl Game<'_> {
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
// A sweep attack's reach to the other players from a guest's attack.
self.flush_sweeps();
// A reeled-in hook pulling a player.
self.flush_hook_pulls();
}
fn lan_guest_joined(
@@ -1405,9 +1764,11 @@ impl Game<'_> {
match e {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
// particles, bone meal's growth, anvil result sounds, or a
// finished brushing (these levelEvent calls name no
// excluded player), nor the digger's own crack and hit
// sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020 | 3008) {
None
} else {
self.lan_acting
@@ -1441,6 +1802,11 @@ impl Game<'_> {
if self.audio.capture.is_some() {
self.lan_entity_events.push((id, event, at));
}
// Event 21: a window that simulates the world itself is its own
// client, and hears the guardian's hum too.
if event == 21 && self.options.execution_role.is_client() {
self.start_guardian_sound(id as u32);
}
}
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
@@ -1469,6 +1835,7 @@ impl Game<'_> {
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
let kinetic_hits = std::mem::take(&mut self.lan_kinetic_hits);
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
@@ -1476,6 +1843,16 @@ impl Game<'_> {
}
host.hub.level_event(kind, pos, data, acting);
}
// Level events 1010 and 1011: the guests' parrots dance or stop.
for (pos, song) in std::mem::take(&mut self.lan_songs) {
match song {
Some(name) => {
let id = host.hub.registry_id("minecraft:jukebox_song", &name);
host.hub.level_event(1010, pos, id.unwrap_or(0), None);
}
None => host.hub.level_event(1011, pos, 0, None),
}
}
for (pos, b0, b1, state) in std::mem::take(&mut self.lan_block_events) {
host.hub.block_event(pos, b0, b1, state);
}
@@ -1520,7 +1897,12 @@ impl Game<'_> {
// A projectile is the direct entity of its hit: not tracked here.
let melee = matches!(
source.kind,
K::PlayerAttack | K::MaceSmash | K::MobAttack | K::Sting | K::SonicBoom
K::PlayerAttack
| K::MaceSmash
| K::Spear
| K::MobAttack
| K::Sting
| K::SonicBoom
);
let direct = cause.filter(|_| melee);
host.hub.damage_event(m.id as i32, kind, cause, direct, at);
@@ -1541,6 +1923,19 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
// Level.broadcastEntityEvent(player, 2) of a spear's stab.
for attacker in kinetic_hits {
let hit = match attacker {
None => self
.player
.as_ref()
.map(|p| (host.host_entity, [p.position.x, p.position.y, p.position.z])),
Some(conn) => host.hub.guest(conn).map(|g| (g.entity_id, g.position)),
};
if let Some((id, at)) = hit {
host.hub.entity_event(id, 2, at);
}
}
for (attacker, target, magic) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
@@ -1630,13 +2025,45 @@ 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;
// Entity.DATA_POSE and the shared and living flags the others
// get (hub::guest_body): gliding, swimming, Riptide's spin.
let body = lan::hub::guest_body(g);
m.extra.fall_flying = body.gliding;
m.extra.synced_pose = if body.swimming || body.crawling { 3 } else { 0 };
m.extra.auto_spin_attack = body.spin_attack;
m.velocity = g
.motion
.as_ref()
.map_or(Vec3::new(dx, dy, dz), |motion| motion.velocity);
m.tick_avatar_pose(
g.motion
.as_ref()
.is_some_and(|motion| motion.fluids.was_touching_water),
);
// CrossbowItem.isCharged: the held crossbow's projectiles.
m.extra.charged = g
.inventory
.get(g.selected)
.and_then(|s| s.as_ref())
.is_some_and(|s| s.data().is_some_and(|d| !d.charged.is_empty()));
// ClientInformation.mainHand (HumanoidArm: 0 left, 1 right).
m.extra.main_hand_left = g.info.main_hand == 0;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Entity.isInvisible: the invisibility effect hides the body (the
// model pass, the name tag rules and `trader::is_invisible` read it).
m.extra.effects.retain(|e| e.0 != "minecraft:invisibility");
if let Some(e) = g.effects.get("invisibility") {
m.extra
.effects
.push(("minecraft:invisibility", e.duration, e.amplifier));
}
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
@@ -1647,6 +2074,18 @@ impl Game<'_> {
let using = host.play.get(&g.conn).and_then(|p| p.using());
m.extra.using_item = using.map_or(0, |(_, ticks)| ticks as i32 + 1);
m.extra.using_off_hand = using.is_some_and(|(slot, _)| slot == OFFHAND_SLOT);
// AvatarRenderer.getArmPose: the used item's BLOCK animation (an
// item with `blocks_attacks`) raises the arm.
m.extra.blocking = using
.and_then(|(slot, _)| {
if slot == OFFHAND_SLOT {
m.extra.off_hand.as_ref()
} else {
m.extra.main_hand.as_ref()
}
})
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
}
@@ -1697,8 +2136,22 @@ impl Game<'_> {
),
out,
);
self.player_elytra_geometry(
m,
m.extra.chest.as_deref(),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
if !m.glowing {
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
} else {
self.cull_entity_vertices(out, entity_start);
}
}
+342 -43
View File
@@ -5,7 +5,9 @@
//! blocks, attacking and the commands all behave exactly as they do for the
//! host, and every world change they cause reaches all players through the
//! block log, the level events and the entity tracker.
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
use super::{
Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig,
};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
@@ -378,6 +380,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
@@ -610,6 +613,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.changed, &mut g.advancements_changed);
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.level.player_extra, &mut g.saved);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
@@ -926,6 +930,45 @@ impl Game<'_> {
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
}
/// DedicatedServer.isUnderSpawnProtection: no operators, an operator or a
/// radius of 0 or less is never under it; else a block within the radius
/// of the world spawn (square, in the Overworld) is.
fn under_spawn_protection(&self, host: &LanHost, gi: usize, pos: [i32; 3]) -> bool {
let g = &host.hub.guests[gi];
let radius = host.hub.spawn_protection;
if self.options.dedicated.is_none() || radius <= 0 || g.dimension != "minecraft:overworld" {
return false;
}
let Some(spawn) = self.level.world_spawn else {
return false;
};
if host.hub.ops.is_empty() || host.hub.ops.contains_key(&g.uuid) {
return false;
}
let dx = pos[0].abs_diff(spawn[0]);
let dz = pos[2].abs_diff(spawn[2]);
dx.max(dz) <= radius as u32
}
/// ServerPlayer.sendSpawnProtectionMessage.
fn spawn_protection_message(&self, host: &LanHost, conn: u32, pos: [i32; 3]) {
let message = horizon_client::text::Text::translate(
"build.spawn_protection",
&[horizon_client::text::Text::literal(format!(
"{}, {}, {}",
pos[0], pos[1], pos[2]
))],
)
.with_color(horizon_client::text::RED);
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&message.component()),
overlay: true,
}),
);
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -950,6 +993,12 @@ impl Game<'_> {
if spectator {
return;
}
// ServerPlayerGameMode.handleBlockBreakAction: no dig starts
// under spawn protection (the ack puts the block back).
if self.under_spawn_protection(host, gi, a.pos) {
self.spawn_protection_message(host, conn, a.pos);
return;
}
let face = direction(i32::from(a.direction));
let play = host.play.entry(conn).or_default();
play.digging = Some((a.pos, face));
@@ -1063,19 +1112,25 @@ impl Game<'_> {
],
};
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.forced_hit = None;
g.guest_use = None;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_use_sounds(host, conn, logged, heard);
// handleUseItemOn: under spawn protection nothing is used or
// placed; the player is told and the blocks below go back.
if self.under_spawn_protection(host, gi, p.pos) {
self.spawn_protection_message(host, conn, p.pos);
} else {
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let swung = self.as_guest(host, conn, |g| {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.forced_hit = None;
g.guest_use = None;
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
self.guest_use_sounds(host, conn, logged, heard);
}
// The client predicted a result: correct it with what really is.
for pos in [
p.pos,
@@ -1097,14 +1152,25 @@ impl Game<'_> {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (p.yaw, p.pitch);
let swung = self.as_guest(host, conn, |g| {
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
// A horn's sound, inline instruments included.
let horn = g.with_hand(hand, |g| {
let held = g.held()?;
g.res.items().instrument(held).map(|i| i.sound)
});
g.guest_use = Some(GuestUse::Item(hand));
let swings = g.swings_started;
let _ = g.use_item();
g.guest_use = None;
g.swings_started != swings
(g.swings_started != swings, horn)
});
let (swung, horn) = match used {
Some((swung, horn)) => (Some(swung), horn),
None => (None, None),
};
self.lan_swing_back(host, conn, swung);
self.guest_instrument_sound(host, conn, heard, horn);
}
S::Attack(p) => self.guest_attack(host, conn, p.id),
S::Interact(p) => {
@@ -1200,6 +1266,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);
@@ -1324,6 +1391,9 @@ impl Game<'_> {
}
self.sync_rules();
}
// handleTeleportToEntityPacket: a spectator goes to the player,
// in whichever level they are.
S::TeleportToEntity(p) if spectator => self.guest_spectate_teleport(host, gi, p.uuid),
S::ChangeGameMode(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(mode) = usize::try_from(p.mode)
.ok()
@@ -1374,19 +1444,68 @@ impl Game<'_> {
.unwrap_or_default();
host.hub.send_stats(conn, &stats);
}
// handlePickItemFromBlock: a block within the block reach plus 1, its
// data only with infinite materials.
S::PickItemFromBlock(p) => {
let state = self.world.block(p.pos[0], p.pos[1], p.pos[2]);
let item = self.res.facts().get(state).item.clone();
let g = &mut host.hub.guests[gi];
if g.creative() && &*item != "minecraft:air" {
let slot = g.selected;
g.inventory[slot] = Some(ItemStack::new(&item, 1));
}
self.guest_pick_item(host, conn, |g| {
let eye = g.eye_position()?;
let d2: f64 = (0..3)
.map(|a| {
let lo = f64::from(p.pos[a]);
(eye[a] - eye[a].clamp(lo, lo + 1.0)).powi(2)
})
.sum();
let range = g.reach() + 1.0;
if d2 >= range * range {
return None;
}
g.picked_stack(p.pos, g.creative && p.include_data)
});
}
// handlePickItemFromEntity.
S::PickItemFromEntity(p) => {
self.guest_pick_item(host, conn, |g| {
g.within_entity_range(p.id)
.then(|| g.entity_pick_result(p.id))
.flatten()
});
}
_ => {}
}
}
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
/// is selected, else a creative player gets the item; the held slot is sent
/// back and the changed slots follow with the tick's inventory changes.
fn guest_pick_item(
&mut self,
host: &mut LanHost,
conn: u32,
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
) {
let picked = self.as_guest(host, conn, |g| {
let stack = stack(g)?;
let infinite = g.creative;
horizon_client::pick_item::pick(
&mut g.inventory[..36],
&mut g.selected,
&stack,
infinite,
);
Some(())
});
if picked.flatten().is_some()
&& let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn)
{
host.hub.send_to(
conn,
&C::SetHeldSlot(play::SetHeldSlot {
slot: g.selected as i32,
}),
);
}
}
fn item_from_network(
&self,
host: &LanHost,
@@ -1402,6 +1521,51 @@ impl Game<'_> {
/// and `Player.remove`'s `inventoryMenu.removed` drops what the cursor
/// held (`dropOrPlaceInInventory` for a removed player). Respawning
/// sooner removes it too.
/// `ServerGamePacketListenerImpl.handleTeleportToEntityPacket` for a
/// player: the host's own, or a guest in the world.
fn guest_spectate_teleport(&mut self, host: &mut LanHost, gi: usize, uuid: u128) {
let own = host.hub.guests[gi].conn;
let host_player = self
.player
.as_ref()
.filter(|_| self.options.dedicated.is_none() && uuid == host.host_uuid)
.map(|p| {
(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim,
)
});
let target = host_player.or_else(|| {
host.hub
.guests
.iter()
.find(|g| g.placed && g.conn != own && g.uuid == uuid)
.map(|g| (g.position, g.yaw, g.pitch, Dim::from_id(&g.dimension)))
});
let Some((at, yaw, pitch, dim)) = target else {
return;
};
if Dim::from_id(&host.hub.guests[gi].dimension) != dim {
// Only a dedicated server keeps its players in different levels.
if self.options.dedicated.is_some() {
self.level_teleports
.push(super::server_travel::LevelTeleport {
conn: own,
dim,
at,
rotation: Some([yaw, pitch]),
});
}
return;
}
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (yaw, pitch);
// A new body there: no fall distance or motion carried over.
g.motion = None;
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
g.death_time += 1;
@@ -1859,6 +2023,10 @@ impl Game<'_> {
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
// TeleportTransition.createDefault: no point of its own, so the
// world spawn's free column once its chunks have loaded.
self.world_spawn_trip(conn);
}
if let Some(c) = in_frame_bed
&& self.dim == super::Dim::Overworld
@@ -1877,6 +2045,10 @@ impl Game<'_> {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
if found.is_none() {
// TeleportTransition.missingRespawnBlock.
self.world_spawn_trip(conn);
}
host.hub.teleport(i, Some(at));
}
if away && let Err(e) = self.enter_level(Some(host), back) {
@@ -1899,6 +2071,10 @@ impl Game<'_> {
.filter(|g| host.hub.here(g) && g.loaded)
.map(|g| g.conn)
.collect();
// The players the guests' hooks can catch.
if host.play.values().any(|p| p.hook.is_some()) {
self.refresh_hook_targets(host);
}
for conn in conns {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
continue;
@@ -1935,7 +2111,13 @@ impl Game<'_> {
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
if digging.is_some() || using || delayed {
// ServerPlayer.getKnownMovement: the move their client just sent.
let movement = host.hub.guests[gi]
.moves
.last()
.map_or(Vec3::ZERO, |m| Vec3::new(m.0[0], m.0[1], m.0[2]));
self.as_guest(host, conn, |g| {
g.known_movement = movement;
g.attack_ticks = g.attack_ticks.saturating_add(1);
if let Some((pos, face)) = digging {
g.forced_hit = Some(Hit {
@@ -1989,6 +2171,7 @@ impl Game<'_> {
self.as_guest(host, conn, |g| {
g.tick_fishing();
g.tick_maps();
g.tick_lodestone_compasses();
g.count_tick_stats(sleeping);
g.tick_advancements();
g.update_player_attributes();
@@ -2015,7 +2198,7 @@ impl Game<'_> {
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
g.last_death = Some(g.position.map(|v| v.floor() as i32));
g.last_death = Some((g.dimension.clone(), g.position.map(|v| v.floor() as i32)));
let (id, at) = (g.entity_id, g.position);
let reward = Experience {
level: g.xp_level,
@@ -2041,14 +2224,25 @@ impl Game<'_> {
None => self.guest_death_message(conn, me),
};
let message = horizon_client::lan::data::component_nbt(&text.component());
let team = self.score.board.players_team(&name);
if horizon_client::scoreboard::own_death_message_shown(team) {
host.hub.system_message(message.clone());
if self.options.dedicated.is_some() {
let board = &self.score.board;
host.hub.system_message_to(message.clone(), |other| {
horizon_client::scoreboard::death_message_reaches(board, &name, other)
});
if self.options.dedicated.is_some() {
// Only a message for everyone is logged (`broadcastSystemMessage`).
if board.players_team(&name).is_none_or(|t| {
t.death_message_visibility == horizon_client::scoreboard::Visibility::Always
}) {
super::dedicated::log("INFO", &format!("System chat: {}", text.plain()));
} else if self.player.is_some() {
self.system_message(text);
}
} else if self.player.is_some()
&& horizon_client::scoreboard::death_message_reaches(
board,
&name,
super::chat::PLAYER_NAME,
)
{
self.system_message(text);
}
message
} else {
@@ -2096,11 +2290,10 @@ impl Game<'_> {
}
}
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
/// host's player and the guests: of the players not in spectator mode,
/// players_sleeping_percentage of them (at least one) are in bed, and as
/// many have slept 100 ticks.
pub(super) fn enough_slept(&self) -> bool {
/// `SleepStatus.update`'s counts over the host's player and the guests:
/// the players not in spectator mode, how many are in bed, and how many
/// of those have slept 100 ticks.
fn sleep_counts(&self) -> (i64, i64, i64) {
let (mut active, mut sleeping, mut deep) = (0i64, 0i64, 0i64);
let mut count = |in_bed: bool, timer: u32| {
active += 1;
@@ -2124,11 +2317,64 @@ impl Game<'_> {
count(g.bed.is_some(), g.sleep_timer);
}
}
(active, sleeping, deep)
}
/// `SleepStatus.sleepersNeeded`.
fn sleepers_needed(&self, active: i64) -> i64 {
let percent = i64::from(self.ui.rules.int("players_sleeping_percentage").max(0));
let needed = ((active * percent + 99) / 100).max(1);
((active * percent + 99) / 100).max(1)
}
/// `SleepStatus.areEnoughSleeping` and `areEnoughDeepSleeping` over the
/// host's player and the guests: of the players not in spectator mode,
/// players_sleeping_percentage of them (at least one) are in bed, and as
/// many have slept 100 ticks.
pub(super) fn enough_slept(&self) -> bool {
let (active, sleeping, deep) = self.sleep_counts();
let needed = self.sleepers_needed(active);
sleeping >= needed && deep >= needed
}
/// `ServerLevel.updateSleepingPlayerList` and `announceSleepStatus`: when
/// the players or the sleepers change, a published or dedicated world
/// shows everyone "n/m players sleeping", or "Sleeping through this
/// night" once enough are in bed, on the action bar.
pub(super) fn announce_sleep_status(&mut self) {
let (active, sleeping, _) = self.sleep_counts();
let (old_active, old_sleeping) = self.sleep_seen;
self.sleep_seen = (active, sleeping);
let changed = (old_sleeping > 0 || sleeping > 0)
&& (old_active != active || old_sleeping != sleeping);
let published = self.lan.is_some() || self.options.dedicated.is_some();
if !changed || !published {
return;
}
// ServerLevel.canSleepThroughNights.
if self.ui.rules.int("players_sleeping_percentage") > 100 {
return;
}
let needed = self.sleepers_needed(active);
let text = if sleeping >= needed {
horizon_client::text::Text::translate("sleep.skipping_night", &[])
} else {
horizon_client::text::Text::translate(
"sleep.players_sleeping",
&[
horizon_client::text::Text::literal(sleeping.to_string()),
horizon_client::text::Text::literal(needed.to_string()),
],
)
};
if let Some(host) = self.lan.as_ref() {
host.hub
.overlay_message(horizon_client::lan::data::component_nbt(&text.component()));
}
if self.options.dedicated.is_none() {
self.titles.set_overlay(text, false);
}
}
/// `ServerLevel.wakeUpAllPlayers` for the guests.
pub(super) fn wake_guests(&mut self) {
if let Some(host) = self.lan.as_mut() {
@@ -2184,16 +2430,18 @@ impl Game<'_> {
if !g.effects.list.is_empty() {
self.as_guest(host, conn, |game| game.tick_mob_effects());
}
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
return;
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = 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);
let health = &motion.health;
let freeze_immune = game.wears_freeze_immune();
let burn_immune = game.wears_burn_from_stepping_immunity();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2257,7 +2505,9 @@ 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;
w.player.burn_from_stepping_immune = burn_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
let _ = w.tick_environment_damage();
@@ -2273,6 +2523,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) {
@@ -2284,12 +2536,16 @@ impl Game<'_> {
for d in travelled {
game.movement_stats(Vec3::new(d[0], d[1], d[2]));
}
// LivingEntity.baseTick: a new block position runs the worn
// enchantments' location_changed effects (frost walker).
game.location_changed_effects([start.x, start.y, start.z], from_ground);
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
// Soul Speed's effects (LivingEntity.baseTick's location_changed
// and tick effects); its particle goes to the players around.
soul = game.tick_soul_speed();
game.handle_shoulder_entities();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2321,7 +2577,10 @@ impl Game<'_> {
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
self.as_guest(host, conn, |game| {
game.sculk_step_on_player();
game.tick_player_sculk_events();
});
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
@@ -2333,6 +2592,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.
@@ -2616,9 +2884,15 @@ impl Game<'_> {
/// Dropped items touching a guest go into their inventory.
fn guest_pickups(&mut self, host: &mut LanHost, gi: usize) {
let (conn, mode, position, id) = {
let (conn, mode, position, id, uuid) = {
let g = &host.hub.guests[gi];
(g.conn, g.game_mode, g.position, g.entity_id)
(
g.conn,
g.game_mode,
g.position,
g.entity_id,
horizon_client::lan::playerdata::uuid_ints(g.uuid),
)
};
if mode == SPECTATOR || self.dropped.is_empty() {
return;
@@ -2629,6 +2903,7 @@ impl Game<'_> {
.enumerate()
.filter(|(_, d)| {
d.pickup_delay == 0
&& d.target.is_none_or(|t| t == uuid)
&& (d.position.x - position[0]).abs() < 1.5
&& (d.position.z - position[2]).abs() < 1.5
&& d.position.y > position[1] - 0.6
@@ -2668,8 +2943,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();
@@ -2811,6 +3085,30 @@ impl Game<'_> {
}
}
/// `InstrumentItem.play`'s `level.playSound(player, ...)`: the others hear
/// a guest's horn now, the guest's own client played it as it used it.
fn guest_instrument_sound(
&mut self,
host: &mut LanHost,
conn: u32,
heard: usize,
horn: Option<String>,
) {
let (Some(horn), Some(capture)) = (horn, self.audio.capture.as_mut()) else {
return;
};
let mut i = heard.min(capture.len());
while i < capture.len() {
if capture[i].0 == horn {
let (name, at, volume, pitch) = capture.remove(i);
let source = horizon_client::settings::sound_source(&name) as i32;
host.hub.sound(&name, at, volume, pitch, source, Some(conn));
} else {
i += 1;
}
}
}
/// Positional sounds and level events of the tick, for the guests.
pub(super) fn lan_broadcast_effects(&mut self, host: &mut LanHost) {
let sounds = self.audio.capture.take().unwrap_or_default();
@@ -2872,7 +3170,8 @@ impl Game<'_> {
let packet = swing_packet(id, c.hand, c.animation);
host.hub.broadcast_near(at, 128.0, &packet, Some(conn));
if let Some(m) = host.models.get_mut(&conn) {
m.swing();
// The duration already counts the guest's haste or fatigue.
m.swing_hand(c.hand == Hand::Off, c.duration_ticks);
}
}
play.swing.tick();
@@ -24,6 +24,37 @@ fn slot_in_group(group: &str, slot: &str) -> bool {
}
impl Game<'_> {
/// `getAttributeValue(KNOCKBACK_RESISTANCE)` of a mob: its own value plus
/// the modifiers of the items it wears or holds in matching slots.
pub(super) fn mob_knockback_resistance(&self, m: &Mob) -> f64 {
let e = &m.extra;
let slots = [
("mainhand", &e.main_hand),
("offhand", &e.off_hand),
("feet", &e.feet),
("legs", &e.legs),
("chest", &e.chest),
("head", &e.head),
];
let mut extra = Vec::new();
for (slot, item) in slots {
let Some(info) = item.as_ref().and_then(|i| self.res.items().get(i)) else {
continue;
};
for (attribute, _, amount, op, group) in &info.modifiers {
if attribute == "minecraft:knockback_resistance" && slot_in_group(group, slot) {
let op = match operation_named(op) {
1 => Op::AddMultipliedBase,
2 => Op::AddMultipliedTotal,
_ => Op::AddValue,
};
extra.push((*amount, op));
}
}
}
finalize::value_with(m, Attr::KnockbackResistance, &extra)
}
/// The registry id of a client-syncable attribute the entity type has
/// (`AttributeMap.getInstance` is null for the others), by name.
fn synced_attribute(&self, entity: &str, name: &str) -> Option<i32> {
+35 -38
View File
@@ -11,18 +11,6 @@ use horizon_client::lan::{
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::nbt::Tag;
/// `ArmorStand`'s default poses (head, body, left arm, right arm, left leg,
/// right leg), DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21),
/// under their `Pose` keys.
const STAND_POSES: [(&str, [f32; 3]); 6] = [
("Head", [0.0, 0.0, 0.0]),
("Body", [0.0, 0.0, 0.0]),
("LeftArm", [-10.0, 0.0, -10.0]),
("RightArm", [-15.0, 0.0, 10.0]),
("LeftLeg", [-1.0, 0.0, -1.0]),
("RightLeg", [1.0, 0.0, 1.0]),
];
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
id: id as i32,
@@ -61,28 +49,21 @@ fn vehicle_meta(hurt_time: i32, hurt_dir: i32, damage: f32) -> Vec<(u8, i32, Val
meta
}
/// An armor stand's pose from its compound (`ArmorStandPose`), each part
/// that differs from the default.
fn stand_poses(raw: Option<&Tag>) -> Vec<(u8, i32, Value)> {
let Some(pose) = raw.and_then(|t| t.get("Pose")) else {
return Vec::new();
};
/// An armor stand's pose (`ArmorStandPose`), each part that differs from the
/// default, as DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE (entries 16 to 21).
fn stand_poses(a: &horizon_client::entities::ArmorStand) -> Vec<(u8, i32, Value)> {
let mut meta = Vec::new();
for (i, (key, default)) in STAND_POSES.iter().enumerate() {
let Some(Tag::List(values)) = pose.get(key) else {
continue;
};
let mut r = *default;
for (k, v) in values.iter().take(3).enumerate() {
if let Tag::Float(bits) = v {
r[k] = f32::from_bits(*bits);
}
}
if r != *default {
for (i, (rotation, default)) in a
.pose
.iter()
.zip(horizon_client::entities::ArmorStand::DEFAULT_POSE)
.enumerate()
{
if *rotation != default {
meta.push((
16 + i as u8,
serializer::ROTATIONS,
Value::Floats(r.to_vec()),
Value::Floats(rotation.to_vec()),
));
}
}
@@ -185,7 +166,19 @@ impl Game<'_> {
if flags != 0 {
e.meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
e.meta.extend(stand_poses(self.persist.raw.get(&a.id)));
e.meta.extend(stand_poses(a));
// A pose put back to its default (`/data`) is sent as that default.
e.defaults.push((15, serializer::BYTE, Value::Byte(0)));
for (i, default) in horizon_client::entities::ArmorStand::DEFAULT_POSE
.iter()
.enumerate()
{
e.defaults.push((
16 + i as u8,
serializer::ROTATIONS,
Value::Floats(default.to_vec()),
));
}
// Stored main hand, off hand, feet, legs, chest, head: the wire's slots 0 to 5.
for (slot, item) in a.equipment.iter().enumerate() {
if item.is_some() {
@@ -403,6 +396,9 @@ impl Game<'_> {
let mut e = state(t.id, "minecraft:trident", at(t.position), t.yaw, false);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
// Projectile.getAddEntityPacket: the owner's id; a trident here is
// always the host player's.
e.data = owner(Some(horizon_client::mobs::Who::Player));
e.meta = arrow_meta(false, t.no_physics, 0, t.in_ground);
// ThrownTrident.ID_LOYALTY and ID_FOIL.
if t.loyalty != 0 {
@@ -469,7 +465,9 @@ impl Game<'_> {
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
// The hub is out of `self.lan` meanwhile, so it is passed in.
let breath = Self::hub_particle(
hub,
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::DragonBreath,
),
@@ -527,11 +525,9 @@ mod tests {
#[test]
fn armor_stand_poses_send_only_what_differs() {
let tag =
horizon_world::nbt::parse_snbt("{Pose:{Head:[10f,20f,0f],LeftArm:[-10f,0f,-10f]}}")
.unwrap()
.0;
let meta = stand_poses(Some(&tag));
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
stand.pose[0] = [10.0, 20.0, 0.0];
let meta = stand_poses(&stand);
assert_eq!(
meta,
vec![(
@@ -540,6 +536,7 @@ mod tests {
Value::Floats(vec![10.0, 20.0, 0.0])
)]
);
assert!(stand_poses(None).is_empty());
let plain = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
assert!(stand_poses(&plain).is_empty());
}
}
+52 -12
View File
@@ -5,7 +5,7 @@
//! (hits, pushes, fire, its projectiles) runs with that guest swapped in.
use super::{Game, lan::LanHost};
use horizon_client::{
mobs::{Mob, MobEvent, PlayerView, Who},
mobs::{Mob, MobEvent, PlayerView, Species, Who},
projectiles::{Hit, Projectile},
};
use horizon_core::geometry::{Aabb, Vec3};
@@ -26,8 +26,38 @@ pub(super) fn player_for(
guests: &GuestViews,
bound: Option<u32>,
) -> Option<u32> {
// A tame cat reads its owner wherever they are while they sleep, or just
// woke up (`CatRelaxOnOwnerGoal` uses `getOwner`, not the nearest player).
if m.species() == Species::Cat
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
let asleep = |v: &PlayerView| v.uuid == owner && (v.sleeping || v.sleep_timer > 0);
if host.is_some_and(asleep) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| asleep(v)) {
return Some(*conn);
}
}
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf, cat or parrot sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
// a player: then `Who::Player` is that attacker, whoever it is, and the
// owner goals ignore it until the wolf lets go.
if !targeting
&& matches!(m.species(), Species::Wolf | Species::Cat | Species::Parrot)
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
if host.is_some_and(|v| v.alive && v.uuid == owner) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
return Some(*conn);
}
}
choose(m.position, targeting, host, guests, bound)
}
@@ -65,11 +95,20 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
matches!(
e,
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::DamagePlayer { .. }
| MobEvent::SonicBoom {
target: Who::Player,
..
}
| MobEvent::Push {
target: Who::Player,
..
}
| MobEvent::Knockback {
target: Who::Player,
..
}
| MobEvent::Ignite {
target: Who::Player,
..
@@ -80,6 +119,7 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
| MobEvent::ParrotToShoulder { .. }
)
}
@@ -175,9 +215,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 +359,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 +376,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)]
@@ -358,6 +393,7 @@ mod tests {
alive: true,
creative,
sneaking: false,
swimming: false,
look: [0.0; 3],
main_hand: None,
off_hand: None,
@@ -369,11 +405,15 @@ mod tests {
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
sprinting: false,
riding: false,
hero: false,
effects: Vec::new(),
uuid: [0; 4],
allies: Default::default(),
remote: false,
sleeping: false,
sleep_timer: 0,
}
}
@@ -82,6 +82,40 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` of a mob's particles: a packet to each
/// player within 32 blocks. The window draws its own copy.
pub(super) fn lan_level_particles(
&self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
/// landing block's crumbs: drawn in this window, when it has one, and
/// sent to each player within 32 blocks. `host` is the hub of a guest's
@@ -156,7 +190,7 @@ impl Game<'_> {
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
/// guest's turn).
fn hub_particle(
pub(super) fn hub_particle(
hub: &Hub,
options: &ParticleOptions,
state: Option<BlockStateId>,
@@ -247,14 +281,10 @@ impl Game<'_> {
return;
};
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
// (`force`) or the type overrides the limiter.
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
// option, unless the packet or the type overrides the limiter.
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
if p.override_limiter {
particles.create(&options, at, v);
} else {
particles.add(&options, at, v);
}
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
};
if p.count == 0 {
add(
+149 -1
View File
@@ -12,6 +12,7 @@ use horizon_client::{
mobs::Who,
projectiles::{Hit, Projectile, ProjectileKind},
};
use horizon_core::geometry::{Aabb, Vec3};
/// The player projectiles that meet other players: arrows (their hurt), and
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
@@ -24,7 +25,7 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
}
/// Who a player's attack lands on.
#[derive(Clone, Copy)]
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Victim {
Host,
Guest(u32),
@@ -40,6 +41,8 @@ pub(super) struct Strike {
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
/// `getSecondsToDisableBlocking` of the attacker's weapon.
disable_blocking: f32,
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
/// yaw), and whether the hit is a sprinting knockback attack.
@@ -47,6 +50,26 @@ pub(super) struct Strike {
knockback_attack: bool,
/// The attacker's team, for the victim's `Player.canHarmPlayer`.
team: Option<horizon_client::scoreboard::Team>,
/// The sweep's damage to the players around the victim.
sweep_hit: Option<Sweep>,
}
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
/// worked out as the attacker.
#[derive(Clone)]
pub(super) struct Sweep {
/// The attacking guest (none: the host).
attacker: Option<u32>,
from: [f64; 3],
/// The damage after the enchantments and the attack strength.
damage: f32,
/// The push along the attacker's yaw.
knockback: (f64, f64),
team: Option<horizon_client::scoreboard::Team>,
/// The target's box grown by (1, 0.25, 1).
pub(super) hit_box: Aabb,
/// The player the sweep started from, which it spares.
skip: Option<Victim>,
}
impl Strike {
@@ -113,6 +136,10 @@ impl Game<'_> {
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let sweep_hit = sweep.map(|_| {
let base = self.sweep_base_damage(scale);
self.player_sweep(base, scale)
});
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
@@ -122,6 +149,14 @@ impl Game<'_> {
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
// LivingEntity.getSecondsToDisableBlocking: the held weapon's value,
// none while another item is in use.
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(Strike {
attacker: self.lan_acting,
amount,
@@ -129,10 +164,12 @@ impl Game<'_> {
crit,
magic,
from,
disable_blocking,
sweep,
knockback: (strength, xd, zd),
knockback_attack,
team: self.player_team().cloned(),
sweep_hit,
})
}
@@ -243,7 +280,9 @@ impl Game<'_> {
// Player.attack: the hurt (with its knockback away from the
// attacker), then causeExtraKnockback along the attacker's yaw.
let land = |g: &mut Self| {
g.combat.player_attacker = Some((s.from, s.disable_blocking));
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
@@ -300,9 +339,118 @@ impl Game<'_> {
};
if let Some(at) = s.sweep.filter(|_| hurt) {
self.sweep_attack(at, Some(s.from));
// The other players next to the victim (Player.doSweepAttack).
let bounds = match victim {
Victim::Host => self.player.as_ref().map(|p| p.bounds()),
Victim::Guest(conn) => host.hub.guest(conn).map(guest_bounds),
};
if let (Some(sweep), Some(bounds)) = (&s.sweep_hit, bounds) {
self.sweep_players(
host,
&Sweep {
hit_box: bounds.inflate(Vec3::new(1.0, 0.25, 1.0)),
skip: Some(victim),
..sweep.clone()
},
);
}
} else {
self.audio.play(sound, Some(s.from), 1.0, 1.0);
}
hurt
}
/// The sweep of the player in place, for the players it reaches: the
/// damage (`1 + ratio * baseDamage`, plus the enchantments against a
/// player, scaled by the attack strength) and who hits. The box is set
/// by the caller.
pub(super) fn player_sweep(&mut self, base: f32, scale: f32) -> Sweep {
let from = self
.player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
let damage = (base + self.enchant_damage_bonus("minecraft:player")) * scale;
Sweep {
attacker: self.lan_acting,
from,
damage,
knockback: self.extra_knockback_direction(),
team: self.player_team().cloned(),
hit_box: Aabb::new(Vec3::ZERO, Vec3::ZERO),
skip: None,
}
}
/// A sweep from a mob hit reaches the other players. Inside a guest's
/// turn the host holds the other players (`as_guest`), so it waits for
/// the turn to end.
pub(super) fn sweep_other_players(&mut self, sweep: Sweep) {
if self.lan_acting.is_some() {
self.creatures.sweeps.push(sweep);
} else if let Some(mut host) = self.lan.take() {
self.sweep_players(&mut host, &sweep);
self.lan = Some(host);
}
}
/// The sweeps guests' turns put off.
pub(super) fn flush_sweeps(&mut self) {
for sweep in std::mem::take(&mut self.creatures.sweeps) {
self.sweep_other_players(sweep);
}
}
/// `Player.doSweepAttack` on the players: each other player in the box,
/// closer than 3 blocks (`distanceToSqr < 9`) and not an ally
/// (`isAlliedTo`), is hurt (`ServerPlayer.canHarmPlayer`: the `pvp` rule)
/// and pushed 0.4 along the attacker's yaw.
pub(super) fn sweep_players(&mut self, host: &mut LanHost, sweep: &Sweep) {
let from = Vec3::new(sweep.from[0], sweep.from[1], sweep.from[2]);
let mut victims = Vec::new();
if sweep.attacker.is_some()
&& self.options.dedicated.is_none()
&& !self.spectator()
&& let Some(p) = self.player.as_ref()
&& !p.health.dead_or_dying()
&& p.bounds().intersects(sweep.hit_box)
&& p.position.distance_squared(from) < 9.0
{
victims.push((Victim::Host, self.player_team().cloned()));
}
for g in &host.hub.guests {
if host.hub.here(g)
&& g.loaded
&& !g.dead
&& !g.spectator()
&& sweep.attacker != Some(g.conn)
&& guest_bounds(g).intersects(sweep.hit_box)
&& Vec3::new(g.position[0], g.position[1], g.position[2]).distance_squared(from)
< 9.0
{
let team = self.score.board.players_team(&g.name).cloned();
victims.push((Victim::Guest(g.conn), team));
}
}
let (xd, zd) = sweep.knockback;
let land = |g: &mut Self| {
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
if hurt {
g.knock_back_player(0.4, xd, zd);
}
};
for (victim, team) in victims {
if sweep.skip == Some(victim)
|| horizon_client::scoreboard::teams_allied(team.as_ref(), sweep.team.as_ref())
|| !self.ui.rules.bool("pvp")
{
continue;
}
match victim {
Victim::Host => land(self),
Victim::Guest(conn) => {
self.guest_struck_by(host, conn, land);
}
}
}
}
}
+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},
@@ -1,7 +1,7 @@
//! 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).
//! consumable's teleport (2017), an enderman's (2018), electric sparks (3002),
//! waxing (3003), wax off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
@@ -10,13 +10,17 @@ use horizon_world::level::LevelEvent;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
self.particles.block_teleportation(pos, data, [16, 8, 16]);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [127, 127, 127], self.settings.particles);
.block_teleportation(pos, data, [127, 127, 127]);
}
/// Event 2018: an enderman's portal trail.
pub(super) fn enderman_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.enderman_teleportation(pos, data, self.settings.particles);
}
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
/// level's random.
@@ -135,6 +139,30 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `ClientLevel.addBreakingBlockEffects`: the block's hit sound (only for
/// `play_sound`) and one crumb on the face being mined; nothing for air.
pub(super) fn breaking_block_effects(
&mut self,
pos: [i32; 3],
face: [i32; 3],
play_sound: bool,
) {
let state = self.world.block(pos[0], pos[1], pos[2]);
if self.res.facts().is_air(state) {
return;
}
if play_sound {
let sounds = self.res.facts().get(state).sounds.clone();
// Each guest plays it from the level event itself.
self.audio.play_unshared(
&sounds.hit,
Some(pos.map(|v| f64::from(v) + 0.5)),
(sounds.volume + 1.0) / 8.0,
sounds.pitch * 0.5,
);
}
self.crack_particle(pos, face, state);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
+6 -2
View File
@@ -46,8 +46,12 @@ impl Game<'_> {
if !self.with_level(|b, level, _| b.is_raining_at(level, p)) {
continue;
}
// Skeleton trap: effective difficulty / 100 (regional difficulty approximated).
let difficulty = f64::from(self.ui.difficulty) * 0.75;
// Skeleton trap: the local effective difficulty / 100
// (`getCurrentDifficultyAt(pos)`).
let difficulty = f64::from(
self.local_difficulty([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])])
.effective,
);
let below = &self
.res
.facts()
+113 -35
View File
@@ -6,6 +6,7 @@
use super::Game;
use horizon_client::{
enchant::EffectContext,
items::ItemStack,
mobs::{Damage, Mob, Species},
particles::{ParticleOptions, ParticleType},
};
@@ -37,6 +38,41 @@ pub(super) fn ally_of_player(m: &Mob, player: Option<[i32; 4]>) -> bool {
|| m.species() == Species::Allay && m.extra.tame
}
/// A smash's knockback on the players, put off while a guest's turn holds
/// the other players.
pub(super) struct SmashPush {
center: Vec3,
heavy: f64,
attacker: Option<u32>,
team: Option<horizon_client::scoreboard::Team>,
}
/// `MaceItem.getKnockbackPower` and the push of one entity at `at`, `heavy`
/// being 2 after a fall of more than five blocks.
fn smash_push(center: Vec3, heavy: f64, at: Vec3, velocity: &mut Vec3, resistance: f64) {
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
return;
}
let length = distance_sqr.sqrt();
let power =
(KNOCKBACK_RADIUS - length) * f64::from(KNOCKBACK_POWER) * heavy * (1.0 - resistance);
if power <= 0.0 {
return;
}
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let (x, z) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(d[0] / length * power, d[2] / length * power)
};
*velocity = Vec3::new(
velocity.x + x,
velocity.y + f64::from(KNOCKBACK_POWER),
velocity.z + z,
);
}
impl Game<'_> {
fn holds_mace(&self) -> bool {
self.inventory[self.selected]
@@ -100,7 +136,7 @@ impl Game<'_> {
/// `LivingEntity.hurtServer` of the mob at `index` by the player's hit:
/// the weapon's `armor_effectiveness` enchantments (Breach) thin its armor.
pub(super) fn hurt_by_player(&mut self, index: usize, amount: f32, source: Damage) -> bool {
let held = self.inventory[self.selected].clone();
let held = self.held().cloned();
let kind = self.mobs[index].kind.id.clone();
let ctx = EffectContext {
this: Some(&kind),
@@ -109,6 +145,7 @@ impl Game<'_> {
is_direct: true,
damage_type: Some(match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
_ => "minecraft:player_attack",
}),
items: Some(self.res.items()),
@@ -138,7 +175,7 @@ impl Game<'_> {
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
/// wear.
pub(super) fn weapon_hit(&mut self, index: usize) {
let Some(stack) = self.inventory[self.selected].clone() else {
let Some(stack) = self.held().cloned() else {
return;
};
let weapon = self
@@ -158,16 +195,26 @@ impl Game<'_> {
if smash && let Some(p) = self.player.as_mut() {
p.fall_distance = 0.0;
}
// ItemStack.postHurtEnemy: hurtAndBreak(weapon.itemDamagePerAttack).
self.weapon_wear(&stack);
}
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
/// the weapon in the acting hand.
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
let weapon = self
.res
.items()
.get(&stack.item)
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
if let Some((per_attack, max)) = weapon
&& max > 0
&& !self.creative
{
let loss = self.durability_loss(&stack, per_attack);
if let Some(held) = self.inventory[self.selected].as_mut() {
let loss = self.durability_loss(stack, per_attack);
let cell = self.held_mut();
if let Some(held) = cell.as_mut() {
held.damage += loss;
if held.damage >= max {
self.inventory[self.selected] = None;
*cell = None;
}
}
}
@@ -220,44 +267,75 @@ impl Game<'_> {
];
self.level_event_at(2013, on_pos, 750);
let heavy = if fall > HEAVY_THRESHOLD { 2.0 } else { 1.0 };
// getKnockbackPower and the push of one entity at `at`.
let push = |at: Vec3, velocity: &mut Vec3, resistance: f64| {
let d = [at.x - center.x, at.y - center.y, at.z - center.z];
let distance_sqr = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if distance_sqr > KNOCKBACK_RADIUS.powi(2) {
return;
}
let length = distance_sqr.sqrt();
let power = (KNOCKBACK_RADIUS - length)
* f64::from(KNOCKBACK_POWER)
* heavy
* (1.0 - resistance);
if power <= 0.0 {
return;
}
// Vec3.normalize: a vector shorter than 1e-5 has no direction.
let (x, z) = if length < f64::from(1.0e-5f32) {
(0.0, 0.0)
} else {
(d[0] / length * power, d[2] / length * power)
};
*velocity = Vec3::new(
velocity.x + x,
velocity.y + f64::from(KNOCKBACK_POWER),
velocity.z + z,
);
};
let player = self.level.player_uuid.or(Some([0, 0, 0, 1]));
for m in self
.mobs
.iter_mut()
.filter(|m| m.id != id && m.alive() && !m.removed && !ally_of_player(m, player))
{
push(m.position, &mut m.velocity, m.attrs.knockback_resistance);
smash_push(
center,
heavy,
m.position,
&mut m.velocity,
m.attrs.knockback_resistance,
);
}
for s in self.armor_stands.iter_mut().filter(|s| !s.marker) {
push(s.position, &mut s.velocity, 0.0);
smash_push(center, heavy, s.position, &mut s.velocity, 0.0);
}
// The host and the LAN guests are living entities too. A guest's turn
// holds the other players (`as_guest`): the push waits for it to end.
let push = SmashPush {
center,
heavy,
attacker: self.lan_acting,
team: self.player_team().cloned(),
};
if self.lan_acting.is_some() {
self.creatures.smash_pushes.push(push);
} else {
self.smash_push_players(&push);
}
}
/// `MaceItem.knockback` on the players but the attacker: the host unless
/// it attacked, and every guest but the attacker.
fn smash_push_players(&mut self, push: &SmashPush) {
if push.attacker.is_some() {
self.smash_push_player(push);
}
self.each_guest_hit_except(push.attacker, |g| g.smash_push_player(push));
}
/// The pushes guests' smashes put off.
pub(super) fn flush_smash_pushes(&mut self) {
for push in std::mem::take(&mut self.creatures.smash_pushes) {
self.smash_push_players(&push);
}
}
/// `MaceItem.knockback` on the player here (the host, or the guest swapped
/// in): not a spectator, an ally of the attacker (`isAlliedTo`: the same
/// team) or a creative player flying.
fn smash_push_player(&mut self, push: &SmashPush) {
if self.spectator()
|| horizon_client::scoreboard::teams_allied(push.team.as_ref(), self.player_team())
{
return;
}
let creative = self.creative;
let Some(p) = self.player.as_mut() else {
return;
};
if creative && p.flying {
return;
}
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
smash_push(
push.center,
push.heavy,
p.position,
&mut p.velocity,
resistance,
);
}
/// `ServerPlayer.checkFallDamage` after a smash on a grounded target:
/// the landing block's particles, 50 for every block fallen (200 at most).
+282 -47
View File
@@ -41,6 +41,8 @@ enum Kind {
Crafter,
/// `BrewingStandBlockEntity` (a `WorldlyContainer`).
Brewing,
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
Bookshelf,
/// `ComposterBlock.getContainer` at this level (a `WorldlyContainerHolder`
/// without a block entity): input below 7, empty at 7, bone meal at 8.
Composter(i32),
@@ -82,7 +84,7 @@ impl View {
}
}
fn dir_from_name(name: &str) -> Option<Direction> {
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
Some(match name {
"down" => Direction::Down,
"up" => Direction::Up,
@@ -169,6 +171,7 @@ impl Containers<'_> {
(BlockEntity::Furnace(_), _) => Kind::Furnace,
(BlockEntity::Crafter(_), _) => Kind::Crafter,
(BlockEntity::Brewing(_), _) => Kind::Brewing,
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
(_, "minecraft:hopper") => Kind::Hopper,
_ => Kind::Generic,
};
@@ -250,6 +253,16 @@ impl Containers<'_> {
_ => false,
};
}
if view.kind == Kind::Bookshelf {
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
// and a free slot, as `getMaxStackSize` is 1.
return self
.items
.item_tags
.get("minecraft:bookshelf_books")
.is_some_and(|m| m.contains(&*stack.item))
&& self.get(view, slot).is_none();
}
if view.kind == Kind::Brewing {
// BrewingStandBlockEntity.canPlaceItem
return match slot {
@@ -399,6 +412,45 @@ pub(super) fn container_signal(
.map_or(0, |v| c.signal(&v))
}
/// `ChiseledBookShelfBlock.getAnalogOutputSignal` and
/// `ShelfBlock.getAnalogOutputSignal`, from the block entity Horizon keeps as
/// it was loaded; `None` for any other block. `direction` points from the
/// block to the comparator.
pub(super) fn shelf_signal(
world: &World,
registry: &'static horizon_world::registry::BlockRegistry,
state: BlockStateId,
pos: BlockPos,
direction: Direction,
) -> Option<i32> {
let info = registry.state(state)?;
let chiseled = info.name == "minecraft:chiseled_bookshelf";
if !chiseled && !info.name.ends_with("_shelf") {
return None;
}
if chiseled {
// The last slot touched, plus one (`lastInteractedSlot` starts at -1).
return Some(match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::ChiseledBookshelf(b)) => b.last_slot + 1,
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
Some(
items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
.sum(),
)
}
impl Game<'_> {
fn containers(&self) -> Containers<'_> {
Containers {
@@ -433,6 +485,7 @@ impl Game<'_> {
if pos.1 == super::minecarts::CART_KEY_Y {
return;
}
self.chiseled_bookshelf_synced(pos);
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
@@ -595,27 +648,41 @@ impl Game<'_> {
self.container_changed(pos);
}
}
/// `HopperBlockEntity.ejectItems`.
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
};
let Some(target) = c.view(offset(pos, facing), false) else {
let front = offset(pos, facing);
let face = facing.opposite();
if let Some(target) = c.view(front, false) {
return self.eject_into(pos, hopper, &target, face);
}
let Some(id) = self.entity_container_at([
f64::from(front.0) + 0.5,
f64::from(front.1) + 0.5,
f64::from(front.2) + 0.5,
]) else {
return false;
};
let face = facing.opposite();
if c.is_full(&target, face) {
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
.unwrap_or(false)
}
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
if self.containers().is_full(target, face) {
return false;
}
for slot in 0..hopper.len() {
let Some(one) = self.take_one(hopper, slot) else {
continue;
};
match self.add_item(Some(pos), &target, one, Some(face)) {
match self.add_item(Some(pos), target, one, Some(face)) {
None => {
self.view_changed(&target);
self.view_changed(target);
return true;
}
Some(rest) => self.put_back(hopper, slot, rest),
@@ -626,46 +693,13 @@ impl Game<'_> {
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
let above = (pos.0, pos.1 + 1, pos.2);
let c = self.containers();
if let Some(source) = c.view(above, false) {
if let Kind::Composter(level) = source.kind {
if level != 8 {
return false;
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return moved;
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return true;
}
Some(rest) => self.put_back(&source, slot, rest),
}
}
return false;
let at = [
f64::from(pos.0) + 0.5,
f64::from(pos.1) + 1.5,
f64::from(pos.2) + 0.5,
];
if let Some(took) = self.suck_from_container(above, at, hopper) {
return took;
}
let above_state = self.world.block(above.0, above.1, above.2);
let blocked = self.res.facts().get(above_state).full_collision
@@ -695,6 +729,168 @@ impl Game<'_> {
{
return false;
}
self.suck_item_entities(hopper, min, max)
}
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
/// container at `above`, through its bottom face; with no block container
/// there, from a container entity whose box meets the one around `at`
/// (`getSourceContainerOrHandler`). `None` when there is no container.
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
let c = self.containers();
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
return self
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
};
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return Some(moved);
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
Some(self.suck_from_slots(source_hopper, &source, hopper))
}
/// `HopperBlockEntity.tryTakeInItemFromSlot` over the `DOWN` face of `source`.
fn suck_from_slots(
&mut self,
source_hopper: Option<Pos>,
source: &View,
hopper: &View,
) -> bool {
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(source);
return true;
}
Some(rest) => self.put_back(source, slot, rest),
}
}
false
}
/// `HopperBlockEntity.getEntityContainer`: one of the alive container
/// entities (`EntitySelector.CONTAINER_ENTITY_SELECTOR`: chest and hopper
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
/// picked at random.
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < at[0] + 0.5
&& hi.x > at[0] - 0.5
&& lo.y < at[1] + 0.5
&& hi.y > at[1] - 0.5
&& lo.z < at[2] + 0.5
&& hi.z > at[2] - 0.5
};
let mut found: Vec<u32> = self
.carts
.iter()
.filter(|c| {
matches!(
c.kind,
super::minecarts::CartKind::Chest | super::minecarts::CartKind::Hopper
) && meets(c.bounds())
})
.map(|c| c.id)
.collect();
found.extend(
self.boats
.iter()
.filter(|b| b.chest && meets(b.bounds()))
.map(|b| b.id),
);
if found.is_empty() {
return None;
}
let pick = self
.world
.random
.next_int_bound(found.len() as i32)
.unwrap_or(0);
found.get(pick as usize).copied()
}
/// Runs `f` on a container entity's items as a container, under the
/// placeholder key its menu uses (the open menu's copy when it is open),
/// then hands the items back to the entity.
fn with_entity_container<R>(
&mut self,
id: u32,
f: impl FnOnce(&mut Self, &View) -> R,
) -> Option<R> {
let mut items = if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
std::mem::take(&mut c.items)
} else {
std::mem::take(&mut self.boats.iter_mut().find(|b| b.id == id)?.items)
};
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, id as i32);
let (view, open) = self.lend_items(key, &mut items);
let result = f(self, &view);
self.reclaim_items(key, open, &mut items);
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.items = items;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.items = items;
}
Some(result)
}
/// A vehicle's slots as a container under its placeholder `key`. When its
/// menu is open the placeholder is the live copy and stays as it is.
/// Returns the view and whether the menu was open.
fn lend_items(&mut self, key: Pos, items: &mut Vec<Option<ItemStack>>) -> (View, bool) {
let open = self.world.block_entities.contains_key(&key);
if !open {
let slots = BlockEntity::Chest(std::mem::take(items));
self.world.block_entities.insert(key, slots);
}
let size = self
.world
.block_entities
.get(&key)
.map_or(0, |e| e.slots().len());
let view = View {
parts: vec![(key, size)],
kind: Kind::Generic,
};
(view, open)
}
/// Gives `lend_items`' slots back to the vehicle, so its own items stay
/// the one source of truth: a menu copy that is still around (open, or
/// left by a guest's close) gets the changed items copied to `items`.
fn reclaim_items(&mut self, key: Pos, open: bool, items: &mut Vec<Option<ItemStack>>) {
let slots = if open {
self.world.block_entities.get(&key).cloned()
} else {
self.world.block_entities.remove(&key)
};
if let Some(BlockEntity::Chest(slots)) = slots {
*items = slots;
}
}
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
/// adds the item entities that meet the box, until one is taken whole.
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
let mut i = 0;
while i < self.dropped.len() {
let b = self.dropped[i].bounds();
@@ -719,6 +915,44 @@ impl Game<'_> {
}
false
}
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
/// hopper minecart: one item from the container above, else the item
/// entities over it or touching it, each tick without a cooldown.
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
// The cart's slots are edited as a container under the placeholder key
// its menu uses; when that menu is open the placeholder is the live copy.
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
let (view, open) = self.lend_items(key, &mut cart.items);
let p = cart.position;
// getLevelY is the cart's y + 0.5, so the container is the block at
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
// above the cart.
let above = (
p.x.floor() as i32,
(p.y + 1.5).floor() as i32,
p.z.floor() as i32,
);
let at = [p.x, p.y + 1.5, p.z];
let took = match self.suck_from_container(above, at, &view) {
Some(took) => took,
None => self.suck_item_entities(
&view,
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
),
};
if !took {
// The cart's own box, inflated by 0.25 sideways.
let b = cart.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
self.suck_item_entities(
&view,
[lo.x - 0.25, lo.y, lo.z - 0.25],
[hi.x + 0.25, hi.y, hi.z + 0.25],
);
}
self.reclaim_items(key, open, &mut cart.items);
}
// ---- dispensers and droppers ----
@@ -955,6 +1189,7 @@ mod item_column_tests {
id,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
target: None,
position: at,
previous: at,
velocity: Vec3::ZERO,
+107 -6
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> {
@@ -714,6 +721,93 @@ impl Game<'_> {
self.swing_arm();
true
}
/// `CompassItem.useOn`: a compass used on a lodestone locks onto it (a
/// single one in survival changes in place, else one is split off).
pub(super) fn use_lodestone_compass(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:compass" {
return false;
}
let [x, y, z] = hit.pos;
if self
.res
.facts()
.get(self.world.block(x, y, z))
.name
.as_ref()
!= "minecraft:lodestone"
{
return false;
}
let at = [x, y, z].map(|v| f64::from(v) + 0.5);
self.audio
.play("minecraft:item.lodestone_compass.lock", Some(at), 1.0, 1.0);
let target = Tag::compound([(
"target",
Tag::compound([
("dimension", Tag::String(self.dimension_id().into())),
("pos", Tag::IntArray(vec![x, y, z])),
]),
)]);
if !self.creative && stack.count == 1 {
if let Some(held) = self.held_mut() {
held.data_mut()
.set_other("minecraft:lodestone_tracker", Some(target));
}
} else {
let mut locked = stack.clone();
locked.count = 1;
locked
.data_mut()
.set_other("minecraft:lodestone_tracker", Some(target));
self.consume_held();
self.give_or_drop_stack(locked);
}
self.stats.add("used", &stack.item, 1);
self.swing_arm();
true
}
/// `CompassItem.inventoryTick` / `LodestoneTracker.tick`: a compass whose
/// lodestone is gone loses its target. A target in an unloaded chunk waits.
pub(super) fn tick_lodestone_compasses(&mut self) {
let dim = self.dimension_id();
let gone = |game: &Self, stack: &ItemStack| -> bool {
let Some(tracker) = stack
.data()
.and_then(|d| d.other("minecraft:lodestone_tracker"))
else {
return false;
};
let (Some(target), true) = (
tracker.get("target"),
horizon_client::save::number(tracker.get("tracked")).is_none_or(|v| v != 0),
) else {
return false;
};
let Some(Tag::IntArray(pos)) = target.get("pos") else {
return false;
};
let [x, y, z] = [pos[0], pos[1], pos[2]];
horizon_client::save::text(target.get("dimension")).as_deref() == Some(dim)
&& game.world.chunks.contains_key(&(x >> 4, z >> 4))
&& game
.res
.facts()
.get(game.world.block(x, y, z))
.name
.as_ref()
!= "minecraft:lodestone"
};
for slot in (0..self.inventory.len()).chain([super::shield::OFFHAND_SLOT]) {
if self.stack_in(slot).is_some_and(|s| gone(self, s))
&& let Some(s) = self.stack_in_mut(slot)
{
s.data_mut().set_other(
"minecraft:lodestone_tracker",
Some(Tag::Compound(Default::default())),
);
}
}
}
fn give_or_drop_stack(&mut self, stack: ItemStack) {
if let Some(rest) = self.give(stack)
&& let Some(eye) = self.eye_position()
@@ -774,9 +868,8 @@ impl Game<'_> {
(d * 2.0 + 0.5) as i8
}
};
let y = f64::from(yaw);
let adjusted = if y < 0.0 { y - 8.0 } else { y + 8.0 };
let rot = ((adjusted * 16.0 / 360.0) as i32 & 15) as u8;
// calculateRotation: the arrow spins in the Nether.
let rot = decoration_rotation(nether, game_time, f64::from(yaw));
let sprite = if inside {
Some("map/decorations/player")
} else if dx.abs() < 320.0 && dz.abs() < 320.0 {
@@ -1114,9 +1207,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 +1254,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 +1287,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 +1356,7 @@ impl Game<'_> {
}
}
if has_data {
self.map_vertices(id, m, light, out);
self.map_vertices(id, false, m, light, out);
}
}
}
+3 -52
View File
@@ -646,50 +646,6 @@ impl Game<'_> {
self.carts.retain(|x| x.id != c.id);
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
}
/// `MinecartHopper.suckInItems`: items above, into the five slots.
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
if !c.enabled {
return;
}
let p = c.position;
let zone = Aabb::new(
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
);
let mut i = 0;
while i < self.dropped.len() {
let e = &self.dropped[i];
if e.pickup_delay > 0 || !zone.contains(e.position) {
i += 1;
continue;
}
let mut stack = e.stack.clone();
for slot in c.items.iter_mut() {
match slot {
None => {
*slot = Some(stack.clone());
stack.count = 0;
}
Some(s) if s.same_item_same_components(&stack) => {
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
let moved = stack.count.min(max.saturating_sub(s.count));
s.count += moved;
stack.count -= moved;
}
_ => {}
}
if stack.count == 0 {
break;
}
}
if stack.count == 0 {
self.dropped.remove(i);
} else {
self.dropped[i].stack = stack;
i += 1;
}
}
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
/// player (single player's own on its server thread, LAN guests). A
@@ -713,12 +669,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 {
@@ -827,8 +778,8 @@ impl Game<'_> {
}
let mut c = self.carts[i].clone();
let alive = self.tick_cart(&mut c, [0.0; 2]);
if c.kind == CartKind::Hopper {
self.hopper_cart_pickup(&mut c);
if c.kind == CartKind::Hopper && c.enabled {
self.hopper_cart_suck(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
+7 -5
View File
@@ -384,13 +384,15 @@ impl Game<'_> {
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
owner.crossbow_charge_duration = 25;
}
let context = if left {
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
} else {
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
use horizon_assets::item_model::DisplayContext as Context;
// PandaHoldsItemLayer shows the item as it lies on the ground.
let (context, ordinal) = match (mob.species(), left) {
(Species::Panda, _) => (Context::Ground, 7),
(_, true) => (Context::ThirdPersonLeftHand, 1),
(_, false) => (Context::ThirdPersonRightHand, 2),
};
// `updateForLiving`: seeded by the entity id plus the context ordinal.
let seed = mob.id as i32 + if left { 1 } else { 2 };
let seed = mob.id as i32 + ordinal;
let state = self.resolve_item(&stack, context, Some(&owner), seed);
if lit {
self.submit_item_world(&state, t, light, out);
@@ -5,11 +5,15 @@ use super::Game;
use horizon_client::{
mobs::{
Species,
drops::{self, SLOTS},
finalize::equip_stack,
gear::{self, ARMOR, MAIN},
spawner::equipment_table,
},
raid::{self, RaiderType},
};
use horizon_core::random::RandomSource;
use horizon_world::{loot::EntityLootParams, nbt::Tag};
/// `EnchantmentsByCostWithDifficulty` of `mob_spawn_equipment`.
const MIN_COST: i32 = 5;
@@ -47,6 +51,58 @@ fn slot_item(m: &horizon_client::mobs::Mob, slot: usize) -> Option<std::sync::Ar
}
impl Game<'_> {
/// `Mob.equip(EquipmentTable)` for a spawner's `equipment`: the loot table
/// (the mob as `this`, its random sequence) is rolled and each stack goes
/// in the slot `EquipmentUser.resolveSlot` picks, with the table's drop
/// chance for that slot.
pub(super) fn equip_from_table(&mut self, id: u32, table: &Tag) {
let Some((name, chances)) = equipment_table(table) else {
return;
};
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
else {
return;
};
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
if !loot.has_table(&name) {
return;
}
let params = EntityLootParams {
this: drops::loot_entity(m, None),
..EntityLootParams::default()
};
let seed = self.level.seed;
let stacks = match loot.random_sequence(&name) {
Some(sequence) => {
let random = self.rand_sequences.get(sequence, seed);
loot.roll_entity(&name, random, &params)
}
None => loot.roll_entity(&name, &mut self.world.random, &params),
};
let mut inserted: Vec<usize> = Vec::new();
for stack in stacks.iter().filter(|s| !s.is_empty()) {
// `resolveSlot`: an `equippable` item goes in its own slot, any
// other in the main hand, each slot once.
let slot = match self
.res
.items()
.get(&stack.item)
.and_then(|i| i.equip_slot.as_deref())
{
Some(name) => SLOTS.iter().position(|s| *s == name),
None => Some(0),
};
let Some(slot) = slot.filter(|s| !inserted.contains(s)) else {
continue;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
equip_stack(m, slot, stack, chances[slot]);
}
inserted.push(slot);
}
}
/// `Mob.populateDefaultEquipmentEnchantments`: the weapon one time in
/// four and each piece of armour one time in two, scaled by the local
/// difficulty, enchanted as if at an enchanting table of cost 5 to 22.
@@ -0,0 +1,82 @@
//! The looping sound instances of mobs: a bee's buzz
//! (`ClientPacketListener.postAddEntitySoundInstance`) and a guardian's
//! attack hum (entity event 21). The sound engine ticks them with what
//! `start_mob_sounds` reads off the mobs.
use super::Game;
use horizon_client::{
mobs::{Species, bee, guardian},
sound_engine::{MobSound, SoundEngine, SoundInstance, Sounds},
};
impl Game<'_> {
/// Start the buzz of each bee that has none (or whose anger changed, which
/// swaps the flying loop for the aggressive one and back), forget the mobs
/// that are gone, and read what the instances follow.
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
let game_time = self.world.game_time;
let mobs: Vec<MobSound> = self
.mobs
.iter()
.filter(|m| {
!m.removed
&& matches!(
m.species(),
Species::Bee | Species::Guardian | Species::ElderGuardian
)
})
.map(|m| MobSound {
id: m.id,
position: [m.position.x, m.position.y, m.position.z],
// Vec3.horizontalDistance.
speed: m.velocity.x.hypot(m.velocity.z),
baby: m.is_baby(),
// NeutralMob.isAngry: the anger end time is ahead.
angry: m.species() == Species::Bee
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
silent: m.silent,
attack_scale: guardian::attack_scale(m),
})
.collect();
self.guardian_sounds
.retain(|id| mobs.iter().any(|m| m.id == *id));
self.bee_sounds
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
for sound in &mobs {
let is_bee = self
.mobs
.iter()
.any(|m| m.id == sound.id && m.species() == Species::Bee);
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
mobs
}
/// `ClientPacketListener.handleEntityEvent` 21: a guardian's attack
/// hum starts (one loop per guardian; the loops of its earlier beams are
/// the same loop, as each one follows the guardian's charge).
pub(super) fn start_guardian_sound(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| {
m.id == id && matches!(m.species(), Species::Guardian | Species::ElderGuardian)
}) else {
return;
};
if !self.guardian_sounds.insert(id) {
return;
}
let sound = MobSound {
id,
position: [m.position.x, m.position.y, m.position.z],
speed: 0.0,
baby: false,
angry: false,
silent: m.silent,
attack_scale: guardian::attack_scale(m),
};
let instance = SoundInstance::guardian_attack(&sound, SoundEngine::unseeded());
self.audio.play_instance(instance, false);
}
}
+15 -8
View File
@@ -253,10 +253,7 @@ impl Game<'_> {
/// `LocalPlayer.aiStep`'s riding jump: the bar charges while jump is
/// held; on release, the jump's strength (0 when there is none).
pub(super) fn charge_mount_jump(&mut self, i: usize, keys: Keys) -> f32 {
// PlayerRideableJumping: horses, camels and nautili (the dash).
let saddled = self.mobs[i].extra.saddled
&& (equine::is_equine(self.mobs[i].species())
|| horizon_client::mobs::nautilus::is_nautilus(self.mobs[i].species()));
let saddled = self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species());
let mut jump = 0.0;
if saddled {
let st = &mut self.mount;
@@ -463,10 +460,15 @@ impl Game<'_> {
_ => {}
}
}
/// `Player.jumpableVehicle`: a saddled mount that jumps.
/// `PlayerRideableJumping`: horses, camels and nautili (the dash).
fn jumps(species: Species) -> bool {
equine::is_equine(species) || horizon_client::mobs::nautilus::is_nautilus(species)
}
/// `Player.jumpableVehicle`: a saddled mount that jumps (not a pig or a
/// strider, which a saddle only steers).
pub(super) fn jumpable_mount(&self) -> bool {
self.mount_index()
.is_some_and(|i| self.mobs[i].extra.saddled)
.is_some_and(|i| self.mobs[i].extra.saddled && Self::jumps(self.mobs[i].species()))
}
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
pub(super) fn mount_jumping(&self) -> bool {
@@ -491,8 +493,13 @@ impl Game<'_> {
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
horizon_client::hud_layout::paint(batch, atlas, &draws);
if jump_bar {
// JumpableVehicleBar: the cooldown shows while the jump recharges.
let cooldown = i32::from(self.mount.jump_ticks < 0);
// JumpableVehicleBar: `getJumpCooldown`, which only the camel's and
// the nautilus' dash have; a horse always shows the progress.
let cooldown = i32::from(
self.mount.jump_ticks < 0
&& (matches!(m.species(), Species::Camel | Species::CamelHusk)
|| horizon_client::mobs::nautilus::is_nautilus(m.species())),
);
let draws =
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
horizon_client::hud_layout::paint(batch, atlas, &draws);
+2
View File
@@ -34,11 +34,13 @@ impl Game<'_> {
self.sound_paused = self.pause;
}
let (vehicle, carts) = self.start_cart_sounds();
let mobs = self.start_mob_sounds();
let ctx = TickContext {
player_underwater: self.ambience.was_underwater(),
player_removed: self.player.is_none(),
vehicle,
carts,
mobs,
frozen: !self.clock.runs_normally(),
};
self.audio.tick(self.pause, &ctx);
+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| {
@@ -504,7 +504,8 @@ impl<'a> Game<'a> {
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
})
.collect();
let players = players
@@ -505,6 +505,17 @@ impl Game<'_> {
);
}
}
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
/// portal: it stands in the portal for 300 ticks without going through.
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
self.portals.entities.insert(
id,
PortalState {
cooldown: ENTITY_DELAY,
process: None,
},
);
}
/// `canUsePortal(false)` of a mob: alive, riding nothing, not asleep,
/// not carrying the player, and not a boss that never travels.
fn mob_can_use_portal(&self, m: &horizon_client::mobs::Mob) -> bool {
+165
View File
@@ -0,0 +1,165 @@
//! Pandas on the game side: the food they find on the ground and pick up
//! (`Mob.aiStep`, `Panda.pickUpItem`), the bamboo a player gives them
//! (`Panda.mobInteract`), the start a sneeze gives the grown pandas about,
//! and the crumbs and the sneeze a window draws.
use super::{
Game,
pickups::{Collector, Taken},
};
use horizon_client::{
items::ItemStack,
mobs::{Species, panda},
particles::{ParticleOptions, ParticleType},
};
use horizon_core::geometry::Vec3;
impl Game<'_> {
/// `Panda.canPickUpAndEat`'s tag.
fn panda_eats_from_ground(&self, item: &str) -> bool {
self.res
.items()
.item_tags
.get("minecraft:panda_eats_from_ground")
.is_some_and(|tag| tag.contains(item))
}
/// A panda takes what lies at its feet when its mouth is free, and
/// looks for food within 6 and 8 blocks for `PandaSitGoal`.
pub(super) fn tick_pandas(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Panda || !m.alive() {
continue;
}
let id = m.id;
let wants = |g: &Self, e: &horizon_client::entities::ItemEntity| {
e.pickup_delay == 0 && g.panda_eats_from_ground(&e.stack.item)
};
// Mob.aiStep: the items within the box grown by (1, 0, 1).
if griefing && m.can_pick_up_loot() && m.extra.main_hand.is_none() {
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
let taken = self
.dropped
.iter()
.position(|e| wants(self, e) && reach.intersects(e.bounds()));
if let Some(j) = taken {
let e = self.dropped.remove(j);
self.add_pickup(Taken::Item(Box::new(e.clone())), Collector::Mob(id));
panda::hold(&mut self.mobs[i], &e.stack.item, e.stack.count, true);
}
}
let m = &self.mobs[i];
let area = |r: f64| m.bounds().inflate(Vec3::new(r, r, r));
let (near, wide) = (area(6.0), area(8.0));
let food_near = self
.dropped
.iter()
.any(|e| wants(self, e) && near.intersects(e.bounds()));
let food_at = self
.dropped
.iter()
.find(|e| wants(self, e) && wide.intersects(e.bounds()))
.map(|e| [e.position.x, e.position.y, e.position.z]);
panda::set_food(&mut self.mobs[i], food_near, food_at);
}
}
/// `Panda.mobInteract` for a click with `item`; whether the click is taken.
pub(super) fn interact_panda(&mut self, index: usize, item: &str) -> bool {
let creative = self.creative;
let m = &mut self.mobs[index];
if panda::scared(m) {
return false;
}
if panda::get_up(m) {
return true;
}
if !m.kind.food.iter().any(|f| f == item) {
return false;
}
if m.ai.target.is_some() {
panda::got_bamboo(m);
}
let at = [m.position.x, m.position.y, m.position.z];
let mut dropped = None;
if m.is_baby() {
// ageUp((int) (-getAge() / 20 * 0.1F), true).
let seconds = ((-m.extra.age / 20) as f32 * 0.1) as i32;
horizon_client::mobs::ai::age_up(m, seconds, true);
} else if m.extra.age == 0 && m.extra.in_love <= 0 {
m.extra.in_love = 600;
m.extra.love_player = true;
} else {
if panda::is_sitting(m) || m.fluids.in_water {
return false;
}
panda::try_to_sit(m);
panda::start_eating(m);
// What it held falls, unless the player has infinite materials.
if let Some(held) = m.extra.main_hand.clone()
&& !creative
{
dropped = Some(ItemStack::new(&held, panda::held_count(m)));
}
panda::hold(m, item, 1, false);
}
self.consume_held();
if let Some(stack) = dropped {
self.spawn_item(stack, at, [0.0, 0.2, 0.0], 10);
}
true
}
/// `Panda.afterSneeze`: the grown pandas within 10 blocks of a cub jump.
pub(super) fn panda_sneezed(&mut self, id: u32) {
let Some(area) = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.bounds().inflate(Vec3::new(10.0, 10.0, 10.0)))
else {
return;
};
for m in &mut self.mobs {
if m.species() == Species::Panda
&& !m.is_baby()
&& m.on_ground
&& !m.fluids.in_water
&& panda::can_perform_action(m, panda::scared(m))
&& area.intersects(m.bounds())
{
// LivingEntity.jumpFromGround.
m.needs_sync = true;
let power = f64::from(m.jump_power());
if power > 1.0e-5 {
m.velocity.y = power.max(m.velocity.y);
}
}
}
}
/// What a window draws of its pandas: the crumbs of a bite
/// (`addEatingParticles`) and the sneeze's particle.
pub(super) fn panda_effects(&mut self) {
let mut bites = Vec::new();
let mut sneezes = Vec::new();
for m in self
.mobs
.iter_mut()
.filter(|m| m.species() == Species::Panda)
{
let (crumbs, sneeze) = panda::effects(m);
if let (Some(crumbs), Some(item)) = (crumbs, m.extra.main_hand.clone()) {
bites.push((item, crumbs));
}
sneezes.extend(sneeze);
}
for (item, crumbs) in bites {
let options = self.item_particle(&ItemStack::new(&item, 1));
for (at, velocity) in crumbs {
self.particles.add(&options, at, velocity);
}
}
let options = ParticleOptions::simple(ParticleType::Sneeze);
for (at, velocity) in sneezes {
self.particles.add(&options, at, velocity);
}
}
}
+11 -6
View File
@@ -279,11 +279,13 @@ impl Game<'_> {
/// horse is a trap for fifteen minutes; a player within ten blocks springs
/// it.
pub(super) fn tick_skeleton_traps(&mut self) {
let player = self
.player
.as_ref()
.filter(|p| !p.health.dead_or_dying())
.map(|p| p.position);
// `Level.hasNearbyAlivePlayer`: alive, not a spectator, in the level.
let players: Vec<_> = self
.level_players()
.into_iter()
.filter(|p| p.alive && !p.spectator)
.map(|p| p.position)
.collect();
let mut sprung = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::SkeletonHorse || !m.extra.trap || !m.alive() {
@@ -296,7 +298,10 @@ impl Game<'_> {
continue;
}
}
if player.is_some_and(|p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE) {
if players
.iter()
.any(|&p| m.distance_sqr(p) < TRAP_RANGE * TRAP_RANGE)
{
sprung.push(m.id);
}
}
+32 -7
View File
@@ -443,6 +443,7 @@ impl Game<'_> {
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let start = out.len();
horizon_client::mobs::render::render_with(
m,
alpha as f32,
@@ -453,9 +454,25 @@ impl Game<'_> {
&self.player_armor_layers(),
out,
);
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.player_elytra_geometry(
m,
self.armor[1].as_ref().map(|s| &*s.item),
camera,
alpha as f32,
packed,
out,
);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
// in its team by its profile name.
if self.potions.effects.get("glowing").is_some() {
let team = match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
};
self.mark_outline(&out[start..], super::glowing::team_color(team));
}
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
@@ -509,7 +526,14 @@ impl Game<'_> {
&self.player_armor_layers(),
&mut verts,
);
self.player_elytra_geometry(&m, camera, 1.0, 240 | 240 << 8, &mut verts);
self.player_elytra_geometry(
&m,
self.armor[1].as_ref().map(|s| &*s.item),
camera,
1.0,
240 | 240 << 8,
&mut verts,
);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
// PictureInPictureRenderer: the box centre, (size, size, -size);
@@ -548,17 +572,18 @@ impl Game<'_> {
}
impl Game<'_> {
fn player_elytra_geometry(
/// `WingsLayer` for a player wearing `chest`.
pub(super) fn player_elytra_geometry(
&self,
m: &Mob,
chest: Option<&str>,
camera: glam::DVec3,
partial: f32,
light: u32,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(asset) = self.armor[1]
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
let Some(asset) = chest
.and_then(|item| self.res.items().get(item))
.and_then(|i| i.equip_asset.as_ref())
else {
return;
@@ -598,7 +623,7 @@ impl Game<'_> {
}
/// `SpinAttackEffectLayer`: Riptide's two boxes turning around a spinning
/// player (`SpinAttackEffectModel.setupAnim`).
fn spin_attack_geometry(
pub(super) fn spin_attack_geometry(
&self,
m: &Mob,
camera: glam::DVec3,
@@ -0,0 +1,98 @@
//! Pick Block on an entity (`Minecraft.pickBlockOrEntity`'s entity case and
//! `ServerGamePacketListenerImpl.handlePickItemFromEntity`): the client aims,
//! the server answers with `Entity.getPickResult`.
use super::Game;
use horizon_client::items::ItemStack;
impl Game<'_> {
/// The entity the crosshair hits before any block, as `Entity.getId`; an
/// entity with nothing to pick (a projectile, a player, the dragon) is
/// `Some(None)`. `None` when the view hits a block or nothing.
pub(super) fn pick_entity_hit(&self) -> Option<Option<i32>> {
let mut best: Option<(f64, Option<i32>)> = None;
let mut offer = |distance: f64, id: Option<i32>| {
if self.before_block(distance) && best.is_none_or(|b| distance < b.0) {
best = Some((distance, id));
}
};
if let Some(hit) = self.remote_object_hit(true) {
offer(hit.distance, Some(hit.id));
}
if let Some((i, d)) = self.mob_hit() {
offer(d, Some(self.mobs[i].id as i32));
}
if let Some((_, d)) = self.guest_hit() {
offer(d, None);
}
if let Some((_, d)) = self.redirectable_hit() {
offer(d, None);
}
if let Some((_, d)) = self.dragon_hit() {
offer(d, None);
}
best.map(|b| b.1)
}
/// `Entity.getPickResult` of the entity `id`: a mob's spawn egg, the item
/// of a boat, minecart, armor stand, cushion, painting, item frame (the
/// framed item when it holds one) or end crystal.
pub(super) fn entity_pick_result(&self, id: i32) -> Option<ItemStack> {
let id = u32::try_from(id).ok()?;
let item = if let Some(m) = self.mobs.iter().find(|m| m.id == id) {
// Mob.getPickResult: SpawnEggItem.byId.
let egg = format!("{}_spawn_egg", m.kind.id);
return self.res.items().get(&egg).map(|_| ItemStack::new(&egg, 1));
} else if let Some(b) = self.boats.iter().find(|b| b.id == id) {
b.entity_type()
} else if let Some(c) = self.carts.iter().find(|c| c.id == id) {
c.kind.id().to_owned()
} else if self.armor_stands.iter().any(|a| a.id == id) {
"minecraft:armor_stand".to_owned()
} else if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
c.item()
} else if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
// ItemFrame.getPickResult: what it frames, else the frame itself.
return Some(
f.item
.clone()
.unwrap_or_else(|| ItemStack::new(f.kind(), 1)),
);
} else if self.paintings.iter().any(|p| p.id == id) {
"minecraft:painting".to_owned()
} else if self.crystals.iter().any(|c| c.id == id) {
"minecraft:end_crystal".to_owned()
} else {
return None;
};
Some(ItemStack::new(&item, 1))
}
/// `Player.isWithinEntityInteractionRange(entity, 3.0)`: the entity's box
/// within the interaction range plus 3 of the eyes.
pub(super) fn within_entity_range(&self, id: i32) -> bool {
let Some(eye) = self.eye_position() else {
return false;
};
let mob = u32::try_from(id)
.ok()
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.bounds());
let Some(b) = self
.object_target(id)
.map(|(lo, hi, _)| (lo, hi))
.or_else(|| {
mob.or_else(|| self.tracked_special_box(id)).map(|b| {
let (lo, hi) = (b.min_position(), b.max_position());
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
})
})
else {
return false;
};
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(b.0[a], b.1[a])).powi(2))
.sum();
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
d2 < range * range
}
}
+22 -3
View File
@@ -32,7 +32,7 @@ pub(super) struct ThrownPotion {
owner: Option<Text>,
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let id = id.trim_start_matches("minecraft:");
@@ -806,9 +806,28 @@ impl Game<'_> {
}
}
}
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row.
/// `Screen.showsActiveEffects`: the survival and creative inventories
/// draw their own effect list when `EffectsInInventory.canSeeEffects`
/// (32 or more pixels right of the screen).
fn screen_shows_active_effects(&self, batch: &HudBatch) -> bool {
let (ox, image_width) = match self.screen {
Screen::Inventory
if self.container.is_none() && self.craft_size == 2 && !self.mount_active() =>
{
(self.inventory_origin(batch).0, 176)
}
Screen::Creative => (
Self::creative_origin(batch).0,
horizon_client::creative::Creative::WIDTH as i32,
),
_ => return false,
};
batch.width as i32 - (ox as i32 + image_width + 2) >= 32
}
/// `Hud.extractEffects`: icons top-right, beneficial row then harmful row;
/// every screen but the inventories keeps them.
pub(super) fn draw_effect_icons(&self, batch: &mut HudBatch) {
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
if self.potions.effects.list.is_empty() || self.screen_shows_active_effects(batch) {
return;
}
let icons: Vec<hud_overlays::EffectIcon> = self
+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 {
+211 -34
View File
@@ -23,6 +23,7 @@ use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct Raiding {
patrol: PatrolSpawner,
siege: Siege,
/// The raids of the dimension the player is in, and that dimension.
raids: Raids,
loaded: Option<Dim>,
@@ -38,6 +39,17 @@ pub(super) struct Raiding {
homes: HashMap<i32, (i64, std::sync::Arc<Vec<[i32; 3]>>)>,
}
/// `VillageSiege`: whether tonight is a siege, whether the spot is chosen,
/// and the zombies still to spawn there.
#[derive(Default)]
struct Siege {
tonight: bool,
set_up: bool,
zombies_to_spawn: i32,
next_spawn_time: i32,
spot: [i32; 3],
}
impl Raiding {
/// Exchange the level-held parts (`ServerLevel.getRaids`) with another
/// level's (game/levels.rs).
@@ -102,11 +114,12 @@ impl RaidLevel for Lvl<'_, '_> {
&& g.res.facts().is_air(g.world.block(x, y, z))
}
fn player(&self) -> Option<RaidPlayer> {
let p = self.game.player.as_ref()?;
// The first player in the level: a server has no player of its own.
let p = self.game.level_players().into_iter().next()?;
Some(RaidPlayer {
position: [p.position.x, p.position.y, p.position.z],
block: block_of(p.position),
alive: !p.health.dead_or_dying(),
alive: p.alive,
})
}
}
@@ -174,12 +187,13 @@ impl PatrolLevel for PatrolCx<'_, '_, '_> {
fn bright_outside(&self) -> bool {
self.level.game.dim == Dim::Overworld && self.level.game.sky_darken() < 4
}
/// `PatrolSpawner.tick` picks from `level.players()`: the local player
/// and every guest in the level.
fn patrol_players(&self) -> Vec<([i32; 3], bool)> {
let g = self.level.game;
g.player
.as_ref()
.map(|p| (block_of(p.position), g.spectator()))
.into_iter()
self.level
.players
.iter()
.map(|p| (block_of(p.position), p.spectator))
.collect()
}
fn sections_to_village(&self, pos: [i32; 3]) -> i32 {
@@ -633,7 +647,7 @@ impl Game<'_> {
/// `Raid.playSound`: the horn, 13 blocks away from the player toward the
/// wave, for players within 64 blocks or in the raid.
fn raid_horn(&mut self, id: i32, pos: [i32; 3]) {
let Some(p) = self.player.as_ref().map(|p| p.position) else {
let Some(p) = self.level_players().first().map(|p| p.position) else {
return;
};
let in_raid = self.raiding.raids.get(id).is_some_and(|r| r.bar.player);
@@ -762,7 +776,7 @@ impl Game<'_> {
}
positions.push((m.id, m.position, s.active_raid));
}
let banners: Vec<(usize, Vec3)> = self
let banners: Vec<(usize, u32, Vec3)> = self
.dropped
.iter()
.enumerate()
@@ -771,7 +785,7 @@ impl Game<'_> {
&& e.stack.data().is_some_and(|d| d.ominous_banner)
&& e.pickup_delay == 0
})
.map(|(i, e)| (i, e.position))
.map(|(i, e)| (i, e.id, e.position))
.collect();
let mut taken: Vec<usize> = Vec::new();
let mut collectors: Vec<(usize, u32)> = Vec::new();
@@ -791,7 +805,7 @@ impl Game<'_> {
let has_rider = ridden.contains(&m.id);
let target_golem = matches!(m.ai.target, Some(horizon_client::mobs::Who::Mob(t)) if golems.contains(&t));
let target_player = m.ai.target == Some(horizon_client::mobs::Who::Player);
let (health, age, range) = (m.health, m.age, m.attrs.follow_range);
let (health, range) = (m.health, m.attrs.follow_range);
let raids = &mut self.raiding.raids;
let s = m.extra.raider.get_or_insert_with(Box::default);
s.rider = has_rider;
@@ -821,14 +835,11 @@ impl Game<'_> {
if !alive {
continue;
}
// Raider.updateNoActionTime adds two per tick, and a raider
// fighting a player or golem in a raid is never idle.
if s.raid_id.is_some() {
m.ai.no_action_time = if target_player || target_golem {
0
} else {
m.ai.no_action_time + 1
};
// Raider.aiStep: a raider fighting a player or golem in a raid is
// never idle. The per-tick growth (+1 serverAiStep, +2
// Raider.updateNoActionTime) is added by the mob's own step.
if s.raid_id.is_some() && (target_player || target_golem) {
m.ai.no_action_time = 0;
}
// Raider.aiStep: join the raid the raider stands in.
if s.raid_id.is_none()
@@ -839,11 +850,9 @@ impl Game<'_> {
{
joins.push((m.id, id, health));
}
// PathfindToRaidGoal.recruitNearby.
if s.active_raid
&& !s.raid_over
&& !s.in_village
&& age % 20 == 0
// PathfindToRaidGoal.tick: recruitNearby, only while the goal runs.
if std::mem::take(&mut s.recruit_due)
&& s.active_raid
&& let Some(id) = s.raid_id
{
recruit.push((id, position));
@@ -858,20 +867,33 @@ impl Game<'_> {
&& raids.get(id).is_some_and(|r| r.leader(s.wave).is_none())
&& !leaders.contains(&(id, s.wave))
{
let in_reach = |(i, _, p): &&(usize, u32, Vec3)| {
!taken.contains(i)
&& (p.x - position.x).abs() <= range
&& (p.z - position.z).abs() <= range
&& (p.y - position.y).abs() <= 8.0
};
let by_distance = |a: &&(usize, u32, Vec3), b: &&(usize, u32, Vec3)| {
a.2.distance_squared(position)
.total_cmp(&b.2.distance_squared(position))
};
// The unreachable cache only picks the navigation target;
// a banner next to the raider is still picked up.
let near = banners
.iter()
.filter(|(i, p)| {
!taken.contains(i)
&& (p.x - position.x).abs() <= range
&& (p.z - position.z).abs() <= range
&& (p.y - position.y).abs() <= 8.0
.filter(in_reach)
.filter(|(_, item, _)| {
!s.unreachable_banners
.iter()
.any(|&(b, until)| b == *item && now < until)
})
.min_by(|a, b| {
a.1.distance_squared(position)
.total_cmp(&b.1.distance_squared(position))
});
if let Some((i, p)) = near {
.min_by(by_distance);
let nearest = banners.iter().filter(in_reach).min_by(by_distance);
let adjacent =
nearest.filter(|(_, _, p)| p.distance_squared(position) < 1.414 * 1.414);
if let Some((i, item, p)) = adjacent.or(near) {
s.banner_at = Some([p.x, p.y, p.z]);
s.banner_item = *item;
if p.distance_squared(position) < 1.414 * 1.414 {
// pickUpItem: the banner becomes the wave's leader.
taken.push(*i);
@@ -1036,6 +1058,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 +1084,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,
@@ -1187,6 +1230,140 @@ impl Game<'_> {
self.spawn_event_mob("minecraft:pillager", at, m.leader, raider::Reason::Patrol);
}
}
/// `VillageSiege.tick`: at midnight one night in ten, 20 zombies spawn
/// two ticks apart near a village 32 blocks from a player standing in it.
pub(super) fn tick_village_siege(&mut self) {
if self.dim != Dim::Overworld {
return;
}
let enemies = self.ui.rules.bool("spawn_mobs") && self.ui.rules.bool("spawn_monsters");
if self.sky_darken() < 4 || !enemies {
self.raiding.siege.tonight = false;
self.raiding.siege.set_up = false;
return;
}
// ClockTimeMarkers.ROLL_VILLAGE_SIEGE: tick 18000 of the 24000 tick day.
if self.world.day_time.rem_euclid(24000) == 18000 {
self.raiding.siege.tonight = self.world.random.next_int_bound(10).unwrap_or(1) == 0;
}
if !self.raiding.siege.tonight {
return;
}
if !self.raiding.siege.set_up {
if !self.try_to_setup_siege() {
return;
}
self.raiding.siege.set_up = true;
}
if self.raiding.siege.next_spawn_time > 0 {
self.raiding.siege.next_spawn_time -= 1;
return;
}
self.raiding.siege.next_spawn_time = 2;
if self.raiding.siege.zombies_to_spawn > 0 {
self.try_spawn_siege_zombie();
self.raiding.siege.zombies_to_spawn -= 1;
} else {
self.raiding.siege.tonight = false;
}
}
/// `ServerLevel.isVillage`: within one section of a village.
fn is_village(centers: &HashSet<(i32, i32, i32)>, pos: [i32; 3]) -> bool {
sections_to_village(centers, section(pos)) <= 1
}
/// `VillageSiege.tryToSetupSiege`: the first player standing in a village
/// picks a spot 32 blocks away (ten tries); true when one stood in a
/// village, even if no spot was found.
fn try_to_setup_siege(&mut self) -> bool {
let centers = self.village_centers();
for player in self.level_players() {
if player.spectator {
continue;
}
let center = block_of(player.position);
if !Self::is_village(&centers, center)
|| self.natural_level().biome_tag(
"minecraft:without_zombie_sieges",
center[0],
center[1],
center[2],
)
{
continue;
}
for _ in 0..10 {
let angle = self.world.random.next_float() * std::f32::consts::TAU;
let spot = [
center[0] + (angle.cos() * 32.0).floor() as i32,
center[1],
center[2] + (angle.sin() * 32.0).floor() as i32,
];
if self.find_siege_spawn_pos(&centers, spot).is_some() {
self.raiding.siege.spot = spot;
self.raiding.siege.next_spawn_time = 0;
self.raiding.siege.zombies_to_spawn = 20;
break;
}
}
return true;
}
false
}
/// `VillageSiege.trySpawn`: one zombie, in a random yaw.
fn try_spawn_siege_zombie(&mut self) {
let centers = self.village_centers();
let spot = self.raiding.siege.spot;
let Some(at) = self.find_siege_spawn_pos(&centers, spot) else {
return;
};
let Some(id) = self.spawn_event_mob("minecraft:zombie", at, false, raider::Reason::Event)
else {
return;
};
let yaw = self.world.random.next_float() * 360.0;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
(m.yaw, m.previous_yaw, m.body_yaw, m.previous_body_yaw) = (yaw, yaw, yaw, yaw);
(m.head_yaw, m.previous_head_yaw) = (yaw, yaw);
}
self.spawn_jockeys(id, false);
}
/// `VillageSiege.findRandomSpawnPos`: ten tries within 8 blocks of the
/// spot for a village column where a zombie may spawn.
fn find_siege_spawn_pos(
&mut self,
centers: &HashSet<(i32, i32, i32)>,
spot: [i32; 3],
) -> Option<[f64; 3]> {
let mut random = std::mem::replace(
&mut self.world.random,
horizon_core::random::LegacyRandom::new(0),
);
let mut found = None;
for _ in 0..10 {
let x = spot[0] + random.next_int_bound(16).unwrap_or(0) - 8;
let z = spot[2] + random.next_int_bound(16).unwrap_or(0) - 8;
if !self.world.chunks.contains_key(&(x >> 4, z >> 4)) {
continue;
}
let y = self.surface_height(x, z);
if Self::is_village(centers, [x, y, z])
&& spawn_rules::check_spawn_rules(
Species::Zombie,
&self.natural_level(),
spawn_rules::Reason::Event,
&mut random,
x,
y,
z,
)
{
found = Some([f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]);
break;
}
}
self.world.random = random;
found
}
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
/// patrol captain's ominous bottle (`entities/pillager`).
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
+489 -45
View File
@@ -182,16 +182,23 @@ pub(super) struct Remote {
online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
pub(super) profile_name: String,
destroying: Option<([i32; 3], u8, f32)>,
/// `MultiPlayerGameMode.destroyingItem`: the held item key the break began with.
destroying_item: String,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
death_message: Option<String>,
death_message: Option<horizon_client::text::Text>,
/// `LocalPlayer.showDeathScreen`: off with `immediate_respawn`.
show_death_screen: bool,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
synced: Vec<Option<ItemStack>>,
@@ -468,14 +475,17 @@ impl Game<'_> {
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
glowing: Default::default(),
profile: 0,
profile_name: String::new(),
destroying: None,
destroying_item: String::new(),
destroy_delay: 0,
input: Default::default(),
stop_mining: false,
placing: None,
death_message: None,
show_death_screen: true,
player_type,
synced: vec![None; 36],
decode_errors: 0,
@@ -666,10 +676,14 @@ impl Game<'_> {
}
let text = content.plain();
log::info!("[chat] {text}");
// addServerSystemMessage and addClientSystemMessage both carry
// GuiMessageTag.systemSinglePlayer: the grey bar at the line's left.
let indicator = (source != horizon_client::chat::ChatSource::Player)
.then_some(horizon_client::chat::SYSTEM_INDICATOR);
self.ui.chat.add(
&self.gui.atlas,
content,
None,
indicator,
source,
self.ticks,
self.screen == Screen::Chat,
@@ -995,6 +1009,12 @@ impl Game<'_> {
r.predictions.clear();
self.set_mode_id(spawn.game_type);
self.dim = Dim::from_id(&spawn.dimension);
// ClientPacketListener.handleLogin / handleRespawn: the server's
// Player.getLastDeathLocation, for the recovery compass.
self.level.last_death = spawn
.last_death
.as_ref()
.map(|d| (d.dimension.clone(), d.pos));
if !same {
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
@@ -1408,6 +1428,17 @@ impl Game<'_> {
{
return;
}
// A thrown trident is an arrow: placed at once, flown on by its tick.
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
if path.is_some() {
t.position = Vec3::new(position[0], position[1], position[2]);
}
if let Some((yaw, pitch)) = rotation {
t.yaw = yaw;
t.pitch = pitch;
}
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
@@ -1466,7 +1497,9 @@ impl Game<'_> {
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.lerps.remove(&id);
r.stepped.remove(&id);
@@ -1609,6 +1642,41 @@ impl Game<'_> {
}
}
/// `ClientPacketListener.handleUpdateAttributes` for a mob: its maximum
/// health (the hearts drawn over its mount) takes the server's value, from
/// `AttributeInstance.calculateValue`.
fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) {
let catalog = &self.motion.attributes;
let Some(max_health) = catalog.id("minecraft:max_health") else {
return;
};
let Some(s) = values
.iter()
.find(|s| s.attribute == max_health.registry_id())
else {
return;
};
let amounts = |op: i32| {
s.modifiers
.iter()
.filter(move |m| m.operation == op)
.map(|m| m.amount)
};
let base = s.base + amounts(0).sum::<f64>();
let mut value = base + amounts(1).map(|a| base * a).sum::<f64>();
for amount in amounts(2) {
value *= 1.0 + amount;
}
let value = catalog.attribute(max_health).definition.sanitize(value);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MaxHealth,
value,
);
}
}
fn handle_packet(&mut self, packet: C) -> Result<()> {
match packet {
// ClientCommonPacketListenerImpl.handlePing runs on the game
@@ -1625,6 +1693,7 @@ impl Game<'_> {
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
r.show_death_screen = p.show_death_screen;
}
self.streaming.radius =
self.settings.render_distance.min(p.chunk_radius + 1).max(2);
@@ -1777,6 +1846,11 @@ impl Game<'_> {
r.level_players.remove(&p.id);
}
}
// Entity.getTeam: a mob is on a team by its UUID.
self.persist.uuids.insert(
p.id as u32,
horizon_client::lan::playerdata::uuid_ints(p.uuid),
);
self.spawn_entity(p.id, &kind, p.position, rotation);
// Entity.recreateFromPacket: setDeltaMovement(packet.getMovement()).
self.remote_motion(p.id, p.velocity);
@@ -1895,7 +1969,13 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
(0, Value::Byte(flags)) => {
// An invisible illusioner draws its mirror
// images instead (`illusioner::set_invisible`).
m.invisible = flags & 0x20 != 0
&& m.species() != horizon_client::mobs::Species::Illusioner;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
@@ -1912,6 +1992,15 @@ impl Game<'_> {
}
}
}
if let Some((_, _, Value::Byte(flags))) = p.values.iter().find(|v| v.0 == 0)
&& let Some(r) = self.remote.as_mut()
{
if flags & 0x40 != 0 {
r.glowing.insert(p.id);
} else {
r.glowing.remove(&p.id);
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
@@ -1926,6 +2015,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
@@ -1946,6 +2051,13 @@ impl Game<'_> {
m.extra.ints[1] = code;
}
}
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
}
}
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): this
@@ -2024,13 +2136,38 @@ 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();
// CrossbowItem.isCharged: the main hand's `charged_projectiles`
// (AvatarRenderer.getArmPose holds a loaded crossbow up).
let charged = p.slots.iter().find(|(slot, _)| *slot == 0).map(|(_, s)| {
s.as_ref().is_some_and(|s| {
matches!(
s.component(horizon_protocol::item::component::CHARGED_PROJECTILES),
Some(horizon_protocol::item::Component::Stacks(list))
if !list.is_empty()
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (slot, item) in items {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
m.extra.charged = charged;
}
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
@@ -2038,7 +2175,13 @@ 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 => {
// A wolf's armor shows its cracks.
e.body_damage = stack.as_ref().map_or(0, |s| s.damage as i32);
e.head = item;
}
7 => m.set_saddle(stack),
_ => {}
}
}
@@ -2123,6 +2266,10 @@ impl Game<'_> {
self.remote_firework_explosion(p.id);
}
self.remote_fangs_event(p.id as u32, p.event);
// LivingEntity.handleEntityEvent 2: a spear's hit felt.
if p.event == 2 {
self.remote_kinetic_hit(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -2130,6 +2277,14 @@ impl Game<'_> {
self.ui.registries.permission = (p.event - 24) as u8;
self.remote_world_options_permission(p.event >= 26);
}
// Ravager.handleEntityEvent 69: the roar's puff and push.
if p.event == 69 {
self.remote_ravager_roar(p.id);
}
// ClientPacketListener.handleEntityEvent 21: a guardian's hum.
if p.event == 21 {
self.start_guardian_sound(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
@@ -2147,6 +2302,10 @@ impl Game<'_> {
{
m.health = 0.0;
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
@@ -2157,6 +2316,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 {
@@ -2187,7 +2349,7 @@ impl Game<'_> {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.swing();
m.swing_hand_modified(hand == Hand::Off, animation.duration);
}
}
// The player's own instances: its maximum health (the hearts the
@@ -2199,6 +2361,7 @@ impl Game<'_> {
}
self.apply_remote_health();
}
C::UpdateAttributes(p) => self.remote_mob_attributes(p.id, &p.values),
C::UpdateMobEffect(p) => self.remote_effect(&p),
C::RemoveMobEffect(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -2223,9 +2386,14 @@ impl Game<'_> {
pl.health.flash_health_change(p.health);
}
self.apply_remote_health();
if p.health <= 0.0 && self.screen != Screen::Dead && self.player.is_some() {
// The death screen opens (or the respawn is asked for) with the
// death packet when the rule hides it.
let shown = self.remote.as_ref().is_none_or(|r| r.show_death_screen);
if p.health <= 0.0 && shown && self.screen != Screen::Dead && self.player.is_some()
{
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
}
}
@@ -2233,17 +2401,23 @@ impl Game<'_> {
// The component resolves in the client's current language, as
// the chat line does, not in the language the session joined
// with (`Remote.lang` is the vanilla server-language table).
let message = remote_text(&p.message).plain();
let message = remote_text(&p.message);
let me = self.remote.as_ref().is_some_and(|r| r.entity_id == p.id);
if let Some(r) = self.remote.as_mut() {
r.death_message = Some(message);
}
// ClientPacketListener.handlePlayerCombatKill: the death
// screen, whatever health was last sent.
// screen, whatever health was last sent, or `respawn()` at
// once when it is hidden.
if me && self.screen != Screen::Dead && self.player.is_some() {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.capture(false);
if self.remote.as_ref().is_none_or(|r| r.show_death_screen) {
self.screen = Screen::Dead;
self.ui.death_time = 0;
self.ui.death_confirm = None;
self.capture(false);
} else {
self.respawn_player()?;
}
}
}
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
@@ -2319,6 +2493,12 @@ impl Game<'_> {
r.game_mode = mode;
}
}
// IMMEDIATE_RESPAWN: LocalPlayer.setShowDeathScreen.
11 => {
if let Some(r) = self.remote.as_mut() {
r.show_death_screen = p.param == 0.0;
}
}
7 => {
self.world.weather.rain_level = (p.param, p.param);
self.world.weather.raining = p.param > 0.0;
@@ -2358,6 +2538,20 @@ impl Game<'_> {
r.state_id = p.state;
}
let stack = self.to_stack(&p.item);
// ClientPacketListener.handleContainerSetSlot: a hotbar slot that
// gains items squeezes for 5 ticks.
if p.container == 0
&& (36..=45).contains(&p.slot)
&& let Some(new) = &stack
{
let (pop, old) = match p.slot {
45 => (9, self.offhand.as_ref()),
s => (s as usize - 36, self.inventory[s as usize - 36].as_ref()),
};
if old.is_none_or(|old| old.count < new.count) {
self.hotbar_pop[pop] = 5;
}
}
self.set_menu_slot(p.container, i32::from(p.slot), stack);
self.remote_synced();
}
@@ -3021,6 +3215,7 @@ impl Game<'_> {
health: 5,
fire_ticks: 0,
thrower: None,
target: None,
id: id as u32,
stack,
position: at,
@@ -3057,6 +3252,8 @@ impl Game<'_> {
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id as u32) {
t.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3177,6 +3374,10 @@ impl Game<'_> {
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Inventory.tick's ItemStack.inventoryTick: the pop time runs down.
for pop in &mut self.hotbar_pop {
*pop = pop.saturating_sub(1);
}
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
@@ -3216,6 +3417,7 @@ impl Game<'_> {
self.border.tick();
self.player_border();
self.tick_remote_entities(running);
self.tick_remote_guardian_eyes(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
@@ -3348,13 +3550,12 @@ impl Game<'_> {
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
} => {
let sounds = self.res.facts().get(state).sounds.clone();
// Player.playStepSound: muffled under the swim sound in water.
let (volume, pitch) = if in_water {
@@ -3368,6 +3569,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).
@@ -3510,6 +3719,43 @@ impl Game<'_> {
}
/// ClientLevel.tickEntities; a frozen level only ticks the players
/// (`TickRateManager.isEntityFrozen`).
/// `GuardianRenderer.getEntityToLookAt`: a guardian's eye follows its
/// synched attack target, else the camera.
fn tick_remote_guardian_eyes(&mut self, running: bool) {
use horizon_client::mobs::{Species, Who, guardian};
if !running {
return;
}
let camera = self.player.as_ref().map(|p| {
[
p.position.x,
p.position.y + f64::from(p.dimensions.eye_height),
p.position.z,
]
});
let looks: Vec<_> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
matches!(m.species(), Species::Guardian | Species::ElderGuardian) && m.alive()
})
.filter_map(|(i, m)| {
let look = match m.ai.target.filter(|_| m.extra.flag) {
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.eye_y(), t.position.z]),
_ => camera,
};
look.map(|look| (i, look))
})
.collect();
for (i, look) in looks {
guardian::look_at(&mut self.mobs[i], look);
}
}
fn tick_remote_entities(&mut self, running: bool) {
if running {
self.tick_text_displays();
@@ -3518,22 +3764,53 @@ impl Game<'_> {
// server never sends the fluid contact, and the renderers read it (cod, salmon and
// tropical fish lie on their side without `in_water`, #1591).
// A frozen level ticks only the players, as below.
let fluids: Vec<_> = {
let (fluids, thundering): (Vec<_>, bool) = {
let w = self.mob_world_base(&[]);
let players = self.remote.as_ref().map(|r| &r.level_players);
self.mobs
let fluids = self
.mobs
.iter()
.map(|m| {
(running || players.is_some_and(|p| p.contains_key(&(m.id as i32))))
.then(|| w.fluids(m.bounds(), m.eye_y()))
})
.collect()
.collect();
(fluids, w.thundering)
};
for (m, fluids) in self.mobs.iter_mut().zip(fluids) {
if let Some(fluids) = fluids {
m.fluids = fluids;
}
}
let game_time = self.world.game_time;
// `AbstractPiglin.isConverting`: the dimension is not piglin safe.
let nether = self.dim == super::Dim::Nether;
// What a wither's side heads can look at, by network entity id: the
// mobs and the player (position and eye height).
let wither_targets: Vec<(i32, [f64; 3], f64)> = if self
.mobs
.iter()
.any(|m| m.species() == horizon_client::mobs::Species::Wither)
{
let own = self
.remote
.as_ref()
.zip(self.eye_position())
.map(|(r, eye)| (r.entity_id, eye, eye[1]));
self.mobs
.iter()
.map(|m| {
(
m.id as i32,
[m.position.x, m.position.y, m.position.z],
m.eye_y(),
)
})
.chain(own)
.collect()
} else {
Vec::new()
};
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3542,6 +3819,7 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
@@ -3581,6 +3859,17 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
// The particles these mobs' own ticks draw on the client side.
{
use horizon_client::mobs::{Species as S, cube, illusioner, nautilus, wither};
match m.species() {
S::Slime | S::MagmaCube | S::SulfurCube => cube::client_tick(m, &mut mob_fx),
S::Nautilus | S::ZombieNautilus => nautilus::client_tick(m, &mut mob_fx),
S::Illusioner => illusioner::client_tick(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
_ => {}
}
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3604,6 +3893,15 @@ impl Game<'_> {
m.head_yaw += wrap_degrees(*yaw - m.head_yaw) / *steps as f32;
*steps -= 1;
}
// Fox.tick: the head tilt and crouch follow the synched flags.
if m.species() == horizon_client::mobs::Species::Fox {
horizon_client::mobs::fox::client_tick(m);
}
// Cat.tick: the lying and relaxing amounts follow the synched flags.
if m.species() == horizon_client::mobs::Species::Cat {
m.anim.vo = m.anim.v;
horizon_client::mobs::tame::cat_amounts(m);
}
// Chicken.aiStep: the wings follow the server's on-ground flag.
if m.species() == horizon_client::mobs::Species::Chicken {
horizon_client::mobs::farm::chicken_flap(m);
@@ -3611,6 +3909,20 @@ impl Game<'_> {
// LivingEntity.updatingUsingItem: the ticks the item has been
// used before this one. Another player's use particles; a mob's
// drawn bow only needs the count (its pull follows it).
if player {
// AvatarRenderer.getArmPose: the used item's BLOCK animation
// (an item with `blocks_attacks`) raises the arm.
let used = if m.extra.using_off_hand {
&m.extra.off_hand
} else {
&m.extra.main_hand
};
m.extra.blocking = m.extra.using_item > 0
&& used
.as_ref()
.and_then(|i| self.res.items().get(i))
.is_some_and(|i| i.blocks_attacks.is_some());
}
if m.extra.using_item > 0 {
if player {
let item = if m.extra.using_off_hand {
@@ -3619,7 +3931,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,
@@ -3637,6 +3949,8 @@ impl Game<'_> {
// a player's body following its walk and look.
if player {
remote_player_head_turn(m);
// LivingEntity.tick: the glide count, wings and swim amount.
m.tick_avatar_pose(m.fluids.in_water);
} else if m.alive() {
m.body_rotation_tick();
}
@@ -3653,6 +3967,29 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
// The animation states a server computes in its own `ai_step`.
horizon_client::mobs::sync::client_tick(m, game_time);
if m.species() == horizon_client::mobs::Species::Panda {
horizon_client::mobs::panda::client_tick(m, thundering);
}
match m.species() {
// WitherBoss.aiStep: the side heads turn toward their targets.
horizon_client::mobs::Species::Wither => {
horizon_client::mobs::wither::client_tick(m, |id| {
wither_targets
.iter()
.find(|t| t.0 == id)
.map(|t| (t.1, t.2))
});
}
// Hoglin and piglin `isConverting` outside the Nether.
horizon_client::mobs::Species::Hoglin
| horizon_client::mobs::Species::Piglin
| horizon_client::mobs::Species::PiglinBrute => {
horizon_client::mobs::piglin::client_converting(m, nether);
}
_ => {}
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
@@ -3677,10 +4014,12 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
let _ = self.handle_mob_events(mob_fx);
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
@@ -3842,6 +4181,8 @@ impl Game<'_> {
}
}
/// `MultiPlayerGameMode.stopDestroyBlock`: aborts the break and starts
/// the attack strength again.
pub(super) fn stop_remote_mining(&mut self) {
if let Some((pos, _, _)) = self.remote.as_mut().and_then(|r| r.destroying.take()) {
self.send(S::PlayerAction(play::PlayerAction {
@@ -3850,6 +4191,7 @@ impl Game<'_> {
direction: 0,
sequence: 0,
}));
self.reset_attack_strength();
}
}
@@ -3903,11 +4245,11 @@ impl Game<'_> {
} else {
self.destroy_progress(hit.pos)
};
let same = self
.remote
.as_ref()
.and_then(|r| r.destroying)
.is_some_and(|(p, _, _)| p == hit.pos);
// sameDestroyTarget: the same block and still the item the break began with.
let held = self.main_hand_key();
let same = self.remote.as_ref().is_some_and(|r| {
r.destroying.is_some_and(|(p, _, _)| p == hit.pos) && r.destroying_item == held
});
if start && same {
return;
}
@@ -3934,7 +4276,11 @@ impl Game<'_> {
}
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
r.destroying_item = held;
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3947,20 +4293,6 @@ impl Game<'_> {
};
let done = done + progress;
let ticks = self.mining.as_ref().map_or(0, |m| m.ticks) + 1;
if ticks % 4 == 1 {
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&facts.sounds.hit,
Some(c),
(facts.sounds.volume + 1.0) / 8.0,
facts.sounds.pitch * 0.5,
);
}
self.crack_particle(pos, hit.face, state);
if done >= 1.0 {
let seq = self.next_sequence();
self.predict_break(pos, state, seq);
@@ -4128,6 +4460,21 @@ 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 held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
self.with_hand(hand, |g| {
if g.using.is_none() {
g.using = Some((g.held_slot(), 0));
}
});
return true;
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
self.with_hand(hand, Game::swing_arm);
return true;
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
@@ -4295,7 +4642,7 @@ impl Game<'_> {
/// predicted under the use's sequence until the server acknowledges it
/// (`BlockStatePredictionHandler.retainKnownServerState`): a use the
/// server refused goes back.
fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
pub(super) fn predict_blocks(&mut self, changes: &DryChanges, seq: i32) {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
@@ -4383,7 +4730,10 @@ impl Game<'_> {
if self.using.is_some() {
return;
}
let bundle = self.res.items().get(&held.item).is_some_and(|i| i.bundle);
let start = match &*held.item {
// BundleItem.use: always starts; the server drops the contents.
_ if bundle => true,
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
@@ -4399,6 +4749,14 @@ impl Game<'_> {
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// InstrumentItem.use: the horn plays here too (`playSound(player, ...)`
// skips the player's own client), the server's copy tells the others.
"minecraft:goat_horn" => {
self.remote_use_instrument(held);
return;
}
// Item.use: a kinetic weapon starts being used.
id if horizon_client::spear::Kinetic::of(id).is_some() => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
_ => {
self.raise_shield(self.held_slot());
@@ -4409,13 +4767,35 @@ impl Game<'_> {
self.using = Some((self.held_slot(), 0));
}
}
/// `InstrumentItem.use` on this client: unless the horn is on cooldown,
/// it is held up, plays its sound at the player and starts its cooldown.
fn remote_use_instrument(&mut self, held: &ItemStack) {
let Some(instrument) = self.res.items().instrument(held) else {
return;
};
let group = horizon_client::cooldowns::group(held, None);
if self.cooldowns.is_on_cooldown(&group) {
return;
}
self.using = Some((self.held_slot(), 0));
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
if instrument.use_duration > 0.0 {
self.add_cooldown(group, instrument.use_ticks());
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
matches!(
&*stack.item,
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
) || self.blocks_attacks(stack).is_some()
) || horizon_client::spear::Kinetic::of(&stack.item).is_some()
|| self.blocks_attacks(stack).is_some()
}
/// The rest of Minecraft.startUseItem once the main hand's target passed:
@@ -4448,12 +4828,53 @@ impl Game<'_> {
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
// BrushItem.onUseTick: with no block aimed the use ends (the server
// ends it too); a stroke shows its dust. Its sounds are the server's.
if stack
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:brush")
{
let elapsed = ticks + 1;
let hit = self.pick().filter(|_| in_hand);
if let Some(hit) = &hit
&& elapsed % 10 == 5
{
self.brush_dust(hit, slot == super::shield::OFFHAND_SLOT);
}
if hit.is_none() && in_hand {
self.send(S::PlayerAction(play::PlayerAction {
action: 6,
pos: [0; 3],
direction: 0,
sequence: 0,
}));
}
self.using = hit.and(Some((slot, elapsed))).filter(|_| elapsed < 200);
return;
}
// BundleItem.getUseDuration: 200 ticks, the server drops the contents.
if stack
.as_ref()
.is_some_and(|s| self.res.items().get(&s.item).is_some_and(|i| i.bundle))
{
self.using = (in_hand && ticks + 1 < 200).then_some((slot, ticks + 1));
return;
}
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go.
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = in_hand.then_some((slot, ticks + 1));
return;
}
// A goat horn is held up for its instrument's use duration.
if let Some(duration) = stack
.as_ref()
.and_then(|s| self.res.items().instrument(s))
.map(|i| i.use_ticks().max(0) as u32)
{
self.using = (in_hand && ticks + 1 < duration).then_some((slot, ticks + 1));
return;
}
let consumable = stack
.as_ref()
.and_then(|s| self.res.items().get(&s.item))
@@ -5230,10 +5651,23 @@ impl Game<'_> {
if self.screen == Screen::Dead
&& let Some(message) = &r.death_message
{
let tw = HudBatch::text_width(atlas, message);
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
// DeathScreen draws the styled cause component.
let tw = atlas.font.width_text(message);
batch.text_styled(
atlas,
message,
((w - tw) / 2.0).floor(),
85.0,
u32::MAX,
true,
);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (from the
/// player list).
@@ -5521,7 +5955,8 @@ fn hand_id(hand: Hand) -> i32 {
/// server decides; this is the client's guess of whether to go on.
fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
info.consumable.is_some()
id.ends_with("_spear")
|| info.consumable.is_some()
|| info.equip_slot.is_some()
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
@@ -5600,7 +6035,16 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
self.0.remote_use();
}
fn pick(&mut self) {
if let Some(hit) = self.0.pick() {
// Minecraft.pickBlockOrEntity: an entity before the block is asked
// for (`handlePickItemFromEntity`), else the block.
if let Some(entity) = self.0.pick_entity_hit() {
if let Some(id) = entity {
self.0.send(S::PickItemFromEntity(play::PickItemFromEntity {
id,
include_data: self.0.has_control_down(),
}));
}
} else if let Some(hit) = self.0.pick() {
self.0.send(S::PickItemFromBlock(play::PickItemFromBlock {
pos: hit.pos,
include_data: self.0.has_control_down(),
@@ -4,10 +4,14 @@
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, Who, bat, bee, finalize, tame};
use horizon_client::mobs::{
Mob, Species, Who, armadillo, bat, bee, camel, creaking, equine, finalize, illusioner, panda,
sniffer, sync, tame, zombie_villager,
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::{Builtin, Dynamic};
use horizon_world::registry::BlockStateId;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
@@ -15,12 +19,37 @@ use horizon_protocol::registry::{Builtin, Dynamic};
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
if &*m.kind.id == "minecraft:player" {
player_poses(m, values);
}
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
(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.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
(0, Value::Byte(flags)) => {
m.fire_ticks = i32::from(flags & 1);
// Drowned.setSwimming: the shared flag of swimming.
if species == Species::Drowned {
m.extra.flag = flags & 16 != 0;
}
// The shared flag of invisibility: Entity.isInvisible hides
// the model (an illusioner shows its mirror images).
let invisible = flags & 32 != 0;
if species == Species::Illusioner {
illusioner::set_invisible(m, invisible);
} else if &*m.kind.id != "minecraft:player" {
m.invisible = invisible;
}
}
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
// off hand 2. The flag starts and stops the use; the client ticks
// the count itself (a bow's pull follows it).
@@ -64,10 +93,23 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
Who::Mob(*target as u32)
});
}
// Panda: the counters, the two genes and DATA_ID_FLAGS (after
// AgeableMob's two entries).
(Species::Panda, 18, Value::Int(n)) => panda::set_unhappy(m, *n),
(Species::Panda, 19, Value::Int(n)) => panda::set_sneeze(m, *n),
(Species::Panda, 20, Value::Int(n)) => panda::set_eat(m, *n),
(Species::Panda, 21, Value::Byte(gene)) => m.extra.variant = (*gene).clamp(0, 6) as u8,
(Species::Panda, 22, Value::Byte(gene)) => m.extra.variant2 = (*gene).clamp(0, 6) as u8,
(Species::Panda, 23, Value::Byte(flags)) => panda::set_flags(m, *flags as u8),
// PolarBear.DATA_STANDING_ID: the client animates the rearing.
(Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing,
// Bat.DATA_ID_FLAGS: resting in bit 0.
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
// Fox.DATA_TYPE_ID and DATA_FLAGS_ID.
(Species::Fox, 18, Value::Int(variant)) => {
m.extra.variant = (*variant).clamp(0, 1) as u8
}
(Species::Fox, 19, Value::Byte(flags)) => m.extra.ints[0] = i32::from(*flags as u8),
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
(Species::Sheep, 18, Value::Byte(wool)) => {
m.extra.color = (*wool & 15) as u8;
@@ -86,6 +128,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.sitting = flags & 1 != 0;
m.extra.tame = flags & 4 != 0;
}
// Cat.IS_LYING and RELAX_STATE_ONE.
(Species::Cat, 21, Value::Bool(lying)) => m.extra.flag = *lying,
(Species::Cat, 22, Value::Bool(relaxing)) => m.extra.relax_one = *relaxing,
// Wolf.DATA_COLLAR_COLOR and Cat's.
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
m.extra.color = (*c & 15) as u8;
@@ -94,6 +139,55 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
(Species::Warden, 16, Value::Int(anger)) => {
if let Some(st) = m.extra.warden.as_mut() {
st.client_anger = *anger;
}
}
// Sniffer.DATA_STATE (after AgeableMob's two): the animation
// follows it in `sync::client_tick`.
(Species::Sniffer, 18, Value::Id(state)) => {
sniffer::set_state(m, (*state).clamp(0, 6) as u8);
}
// Armadillo.ARMADILLO_STATE (after AgeableMob's two): the shell and
// its animations follow it in `sync::client_tick`.
(Species::Armadillo, 18, Value::Id(state)) => {
armadillo::set_state(m, (*state).clamp(0, 3) as u8);
}
// Goat.DATA_HAS_LEFT_HORN and DATA_HAS_RIGHT_HORN: the model hides
// a horn the goat lost.
(Species::Goat, 19, Value::Bool(has)) => m.extra.lost_horns[0] = !*has,
(Species::Goat, 20, Value::Bool(has)) => m.extra.lost_horns[1] = !*has,
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID.
(Species::Shulker, 16, Value::Id(face)) => m.extra.variant3 = (*face).clamp(0, 5) as u8,
(Species::Shulker, 17, Value::Byte(peek)) => m.extra.counter = i32::from(*peek),
(Species::Shulker, 18, Value::Byte(color)) => m.extra.color = *color as u8,
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's
// flags).
(Species::Camel | Species::CamelHusk, 19, Value::Bool(dashing)) => {
camel::set_dashing(m, *dashing);
}
(Species::Camel | Species::CamelHusk, 20, Value::Long(tick)) => {
camel::set_synced_pose_tick(m, *tick);
}
// Vex.DATA_FLAGS_ID: charging in bit 0.
(Species::Vex, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
// Creaking.CAN_MOVE, IS_ACTIVE and IS_TEARING_DOWN.
(Species::Creaking, 16, Value::Bool(can_move)) => m.extra.flag2 = !*can_move,
(Species::Creaking, 17, Value::Bool(active)) => m.extra.flag = *active,
(Species::Creaking, 18, Value::Bool(true)) => creaking::set_tearing_down(m),
// Allay.DATA_DANCING and DATA_CAN_DUPLICATE (a cooldown running
// is any non-zero `egg_time`).
(Species::Allay, 16, Value::Bool(dancing)) => m.extra.flag = *dancing,
(Species::Allay, 17, Value::Bool(can)) => m.extra.egg_time = i32::from(!*can),
// Axolotl.DATA_PLAYING_DEAD (after DATA_VARIANT): the factors
// ease in `sync::client_tick`.
(Species::Axolotl, 19, Value::Bool(dead)) => m.extra.axolotl.playing_dead = *dead,
// CopperGolem.COPPER_GOLEM_STATE (after DATA_WEATHER_STATE).
(Species::CopperGolem, 17, Value::Id(state)) => {
m.extra.variant3 = (*state).clamp(0, 4) as u8;
}
// AbstractHorse's flags take 18. Horse.DATA_ID_TYPE_VARIANT: the
// colour in the low byte, the markings in the next.
(Species::Horse, 19, Value::Int(v)) => {
@@ -137,6 +231,45 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
(Species::CopperGolem, 16, Value::Id(state)) => {
m.extra.variant = (*state).clamp(0, 3) as u8;
}
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
// and raised head of an angry enderman.
(Species::Enderman, 17, Value::Bool(creepy)) => m.extra.flag = *creepy,
// AbstractHorse.DATA_ID_FLAGS: eating 16 and standing 32 (the
// graze and the rear), which `sync::client_tick` animates.
(s, 18, Value::Byte(flags)) if equine::grazes(s) => equine::set_flags(m, *flags as u8),
// Enderman.DATA_CARRY_STATE (0 for none): the block it holds.
(Species::Enderman, 16, Value::Id(state)) => {
m.extra.carried = (*state > 0).then_some(BlockStateId(*state as u32));
}
// Zombie.DATA_DROWNED_CONVERSION_ID (after DATA_BABY_ID and
// DATA_SPECIAL_TYPE_ID): the conversion's shaking.
(Species::Zombie | Species::Husk, 18, Value::Bool(converting)) => {
m.extra.counter2 = i32::from(*converting);
}
// ZombieVillager.DATA_CONVERTING_ID (before its villager data).
(Species::ZombieVillager, 19, Value::Bool(converting)) => {
zombie_villager::set_converting(m, *converting);
}
// AbstractVillager.DATA_UNHAPPY_COUNTER (after AgeableMob's two):
// the head shake of `no`, which the server counts down.
(Species::Villager | Species::WanderingTrader, 18, Value::Int(ticks)) => {
m.extra.ints[0] = *ticks;
}
// Hoglin.DATA_IMMUNE_TO_ZOMBIFICATION (after AgeableMob's two) and
// AbstractPiglin's: `sync`'s conversion shake reads it.
(Species::Hoglin, 18, Value::Bool(immune))
| (Species::Piglin | Species::PiglinBrute, 16, Value::Bool(immune)) => {
m.extra.flag2 = *immune;
}
// Blaze.DATA_FLAGS_ID: charged 1, which makes it burn.
(Species::Blaze, 16, Value::Byte(flags)) => m.extra.flag = flags & 1 != 0,
// Turtle.HAS_EGG and LAYING_EGG (after AgeableMob's two).
(Species::Turtle, 18, Value::Bool(has_egg)) => m.extra.flag = *has_egg,
(Species::Turtle, 19, Value::Bool(laying)) => m.extra.flag2 = *laying,
// WitherBoss.DATA_TARGET_B and _C (after DATA_TARGET_A): the
// entities the side heads turn toward in `wither::client_tick`.
(Species::Wither, 17, Value::Int(id)) => m.extra.ints[0] = *id,
(Species::Wither, 18, Value::Int(id)) => m.extra.ints[1] = *id,
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
@@ -208,6 +341,21 @@ fn apply_registry_data(
}
}
/// What another player's pose draws from: gliding (Entity.isFallFlying, flag
/// 0x80 of the shared flags), the pose (swimming), Riptide's spin
/// (LivingEntity.isAutoSpinAttack, flag 4) and a loaded crossbow in the
/// hand (the held stack comes with SetEquipment).
fn player_poses(m: &mut Mob, values: &[(u8, i32, Value)]) {
for (index, _, value) in values {
match (*index, value) {
(0, Value::Byte(flags)) => m.extra.fall_flying = flags & -128 != 0,
(6, Value::Id(pose)) => m.extra.synced_pose = (*pose).clamp(0, 17) as u8,
(8, Value::Byte(flags)) => m.extra.auto_spin_attack = flags & 4 != 0,
_ => {}
}
}
}
/// The options of a synched particle list: an entity effect carries its ARGB
/// colour, the omens none.
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
@@ -260,9 +408,20 @@ impl Game<'_> {
let Some(r) = &self.remote else {
return;
};
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): a villager lies
// in its bed the way the bed faces, or gets up.
let bed = values.iter().find_map(|v| match v {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
&& m.species() == Species::Villager
{
m.extra.ints[1] = code;
}
}
}
}
@@ -0,0 +1,50 @@
//! Particles a client of a server draws for a mob's entity event, which the
//! server does not send as a particle packet.
use super::Game;
use horizon_client::{
mobs::Species,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
/// `Ravager.handleEntityEvent(69)`: forty puffs of smoke from the middle
/// of its box, then `applyRoarKnockbackClient`: this client's own player
/// within 4 blocks is thrown away from it.
pub(super) fn remote_ravager_roar(&mut self, id: i32) {
let Some(m) = self
.mobs
.iter()
.find(|m| m.id == id as u32 && m.species() == Species::Ravager)
else {
return;
};
let (center, at, area) = (
m.center(),
m.position,
m.bounds().inflate(Vec3::new(4.0, 4.0, 4.0)),
);
let options = ParticleOptions::simple(ParticleType::Poof);
for _ in 0..40 {
let r = self.particles.random();
let v = [
r.next_gaussian() * 0.2,
r.next_gaussian() * 0.2,
r.next_gaussian() * 0.2,
];
self.particles.add(&options, center, v);
}
if let Some(view) = self.player_view()
&& view.alive
&& view.bounds.intersects(area)
&& let Some(p) = self.player.as_mut()
{
// strongKnockback.
let (xd, zd) = (p.position.x - at.x, p.position.z - at.z);
let dd = (xd * xd + zd * zd).max(0.001);
p.velocity = p
.velocity
.add_vector(Vec3::new(xd / dd * 4.0, 0.2, zd / dd * 4.0));
}
}
}
@@ -61,6 +61,8 @@ impl Game<'_> {
) && !baby
}
Species::Sheep => item == "minecraft:shears" || item.ends_with("_dye"),
// SnowGolem.mobInteract: shears take the pumpkin off.
Species::SnowGolem => item == "minecraft:shears" && !m.extra.sheared,
// Bucketable.bucketMobPickup.
Species::Cod
| Species::Salmon
@@ -153,6 +153,10 @@ impl Game<'_> {
}
(3, Value::Bool(visible)) => a.name_visible = *visible,
(9, Value::Float(health)) => a.health = *health,
// ArmorStand.DATA_HEAD_POSE to DATA_RIGHT_LEG_POSE.
(16..=21, Value::Floats(r)) if r.len() == 3 => {
a.pose[usize::from(index - 16)] = [r[0], r[1], r[2]];
}
// ArmorStand.DATA_CLIENT_FLAGS.
(15, Value::Byte(flags)) => {
a.small = flags & 1 != 0;
Loaded 100 of 253 files, more files were not shown because too many files have changed in this diff. Show more