Compare commits

...
Author SHA1 Message Date
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
Ludovic MarieandClaude Opus 5.5 54ab5dcf00 Merge main into fix/save-effects-teams
Main added a mob's hidden_effects (effects given with hideParticles): a saved
mob effect now writes show_particles and show_icon false for them and reads
show_particles back (MobEffectInstance.Details.CODEC), so a reload keeps them
hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:45:26 +01:00
Ludovic MarieandClaude Opus 5.5 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
Ludovic MarieandClaude Opus 5.5 6c769f38ae Merge main into fix/server-mob-animation-sync
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:58 +01:00
Ludovic MarieandClaude Opus 5.5 9c3058f756 Merge main into fix/player-pose-dimensions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:37:06 +01:00
Ludovic 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
Ludovic MarieandClaude Opus 5.5 584fa2a9b5 Merge main into fix/step-sound-discrete
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:33:09 +01:00
Ludovic MarieandClaude Opus 5.5 6c09ec2cc8 Save a player effect's icon as shown only when its particles are
MobEffectInstance's show_icon defaults to show_particles: an effect given with
hidden particles hides its icon too, and keeps doing so after a reload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:30:27 +01:00
Ludovic MarieandClaude Opus 5.5 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
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
Ludovic MarieandClaude Opus 5.5 79fca90886 Tame a pet to the player's real UUID, not an unset placeholder
acting_uuid() falls back to [0, 0, 0, 0] while level.player_uuid is unset (a new
world before its first save); the owner saved then never matches the UUID the
world gets, so the player could no longer command the pet after a reload.
acting_player_uuid() assigns the world's player UUID first, as dropped items do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:29:42 +01:00
Ludovic MarieandClaude Opus 5.5 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 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 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 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 a529be8915 Keep translucent item rendering with enchantment glint 2026-10-26 21:40:21 +01:00
Kacpherek d75ef5e4f7 Draw translucent item quads without depth writes so water shows through glass items 2026-10-26 21:31:51 +01:00
Kacpherek 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 94600b266f Format the fox module order 2026-10-26 19:44:07 +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 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +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
137 changed files with 8952 additions and 621 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 -1
View File
@@ -4,7 +4,10 @@
//! language used by every screen.
use std::{
collections::{BTreeMap, HashMap},
sync::{Arc, RwLock},
sync::{
Arc, RwLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -23,11 +26,16 @@ impl LanguageInfo {
#[derive(Default)]
pub struct Language {
pub code: String,
/// `LanguageInfo.bidirectional`: text without a strong character reads
/// right to left (`ClientLanguage.defaultRightToLeft`).
bidirectional: bool,
map: HashMap<String, String>,
}
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
/// A source of resource files: the prepared root plus packs, highest priority last.
pub trait ResourceSource {
@@ -134,8 +142,12 @@ impl Language {
{
apply_deprecated(&mut map, &bytes);
}
let bidirectional = available(sources)
.iter()
.any(|info| info.code == code && info.bidirectional);
Self {
code: code.to_owned(),
bidirectional,
map,
}
}
@@ -197,11 +209,16 @@ pub fn available(sources: &[&dyn ResourceSource]) -> Vec<LanguageInfo> {
}
pub fn set(language: Language) {
BIDIRECTIONAL.store(language.bidirectional, Ordering::Relaxed);
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
}
pub fn code() -> String {
current().map_or_else(|| DEFAULT.to_owned(), |l| l.code.clone())
}
+1
View File
@@ -62,6 +62,7 @@ gilrs = { version = "0.11", optional = true }
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
@@ -1056,6 +1056,8 @@ 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,
@@ -1081,6 +1083,7 @@ pub fn main() -> horizon_client::Result<()> {
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
+171 -26
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,8 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// `WetSpongeBlock.animateTick`.
WetSponge,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -107,17 +111,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None, false),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None, false),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None, false),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None, true),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None, true),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None, true),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D), false)
}
"spruce_leaves" => Animate::Leaves(None, None),
"spruce_leaves" => Animate::Leaves(None, None, false),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -126,16 +130,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"mycelium" => Animate::Mycelium,
"brewing_stand" => Animate::BrewingStand,
"ender_chest" => Animate::EnderChest,
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
"sand" => Animate::Falling(0xDBD3A0, true),
"red_sand" => Animate::Falling(0xA95821, true),
"gravel" => Animate::Falling(0x807C7B, false),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7, false),
"dragon_egg" => Animate::Falling(0x000000, false),
_ if name.ends_with("_concrete_powder") => Animate::Falling(
concrete_powder_color(name.trim_end_matches("_concrete_powder")),
false,
),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
@@ -167,6 +172,10 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"wet_sponge" => Animate::WetSponge,
// BrushableBlock has no dust colour of its own, so the dust is the map colour.
"suspicious_sand" => Animate::Falling(0xF7E9A3, false),
"suspicious_gravel" => Animate::Falling(0x707070, false),
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -233,6 +242,10 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `#triggers_ambient_desert_sand_block_sounds` (sand, red sand).
desert_sand_sounds: Vec<bool>,
/// `#required_for_poplar_leaf_ambience` (overworld natural logs).
poplar_leaf_logs: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
@@ -308,6 +321,8 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
desert_sand_sounds: by_state("minecraft:triggers_ambient_desert_sand_block_sounds"),
poplar_leaf_logs: by_state("minecraft:required_for_poplar_leaf_ambience"),
burnable,
ambient,
})
@@ -396,6 +411,10 @@ impl AnimateContext<'_> {
fn add(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add(&ParticleOptions::simple(ty), p, v);
}
/// `Level.addAlwaysVisibleParticle`: still shown under the Minimal option now and then.
fn add_visible(engine: &mut ParticleEngine, ty: T, p: [f64; 3], v: [f64; 3]) {
engine.add_with(&ParticleOptions::simple(ty), p, v, false, true);
}
fn sound(engine: &mut ParticleEngine, name: &'static str, p: [f64; 3], volume: f32, pitch: f32) {
engine.sounds.push((name, p, volume, pitch));
}
@@ -771,7 +790,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling, fixed_tint) => {
Animate::Leaves(falling, fixed_tint, poplar) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -783,6 +802,9 @@ fn block_animate_tick(
add(engine, T::DrippingWater, p, [0.0; 3]);
}
}
if poplar {
leaves_ambient_sound(tables, engine, ctx, state, pos);
}
// FallingParticlesLeavesBlock.makeFallingLeavesParticles.
if let Some((ty, chance)) = falling
&& engine.random().next_float() < chance
@@ -955,7 +977,7 @@ fn block_animate_tick(
fy + 1.0 + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::SulfurBubbles, p, [0.0; 3]);
add_visible(engine, T::SulfurBubbles, p, [0.0; 3]);
}
if next_int(engine.random(), 10) == 0 {
let name = "minecraft:block.potent_sulfur.noxious_gas";
@@ -963,6 +985,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 {
@@ -1045,7 +1097,7 @@ fn block_animate_tick(
add(engine, T::Portal, p, v);
}
}
Animate::Falling(color) => {
Animate::Falling(color, sand) => {
if next_int(engine.random(), 16) == 0 {
let below = ctx.facts.get(ctx.state([pos[0], pos[1] - 1, pos[2]]));
// FallingBlock.isFree: air, fire, liquids or replaceable.
@@ -1062,6 +1114,14 @@ fn block_animate_tick(
);
}
}
// AmbientDesertBlockSoundsPlayer.playAmbientSandSounds: only for the air block.
if sand
&& ctx.state([pos[0], pos[1] + 1, pos[2]]) == ctx.world.air
&& next_int(engine.random(), 2100) == 0
&& sand_sound_nearby(tables, ctx, pos)
{
sound(engine, "minecraft:block.sand.idle", [fx, fy, fz], 1.0, 1.0);
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
@@ -1179,7 +1239,7 @@ fn block_animate_tick(
}
Animate::BubbleColumn { drag } => {
if drag {
add(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
add_visible(engine, T::CurrentDown, [fx + 0.5, fy + 0.8, fz], [0.0; 3]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1192,7 +1252,7 @@ fn block_animate_tick(
);
}
} else {
add(
add_visible(
engine,
T::BubbleColumnUp,
[fx + 0.5, fy, fz + 0.5],
@@ -1204,7 +1264,7 @@ fn block_animate_tick(
fy + f64::from(r.next_float()),
fz + f64::from(r.next_float()),
];
add(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
add_visible(engine, T::BubbleColumnUp, p, [0.0, 0.04, 0.0]);
let r = engine.random();
if next_int(r, 200) == 0 {
let (volume, pitch) = (0.2 + r.next_float() * 0.2, 0.9 + r.next_float() * 0.15);
@@ -1282,6 +1342,91 @@ fn block_animate_tick(
}
}
}
/// `AmbientLeavesBlockSoundPlayer.playAmbientLeavesSounds` for the poplar leaves:
/// one log and three leaves of the same kind around the block.
fn leaves_ambient_sound(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if next_int(engine.random(), 300) != 0 {
return;
}
let Some(name) = ctx.registry.state(state).map(|s| &s.name) else {
return;
};
let (mut logs, mut leaves) = (0, 0);
for d in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let neighbor = ctx.state([pos[0] + d[0], pos[1], pos[2] + d[1]]);
if tables
.poplar_leaf_logs
.get(neighbor.0 as usize)
.copied()
.unwrap_or(false)
{
logs += 1;
}
if ctx
.registry
.state(neighbor)
.is_some_and(|s| &s.name == name)
{
leaves += 1;
}
if logs == 1 && leaves == 3 {
let p = pos.map(f64::from);
sound(engine, "minecraft:block.poplar_leaves.ambient", p, 1.0, 1.0);
return;
}
}
}
/// `AmbientDesertBlockSoundsPlayer.shouldPlayAmbientSandSound`: sand in the
/// surface column eight blocks away on all four sides.
fn sand_sound_nearby(tables: &AnimateTables, ctx: &AnimateContext, pos: [i32; 3]) -> bool {
let is_sand = |p: [i32; 3]| {
tables
.desert_sand_sounds
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let (mut found, mut checked) = (0, 0);
for d in [[0, -8], [8, 0], [0, 8], [-8, 0]] {
let (x, z) = (pos[0] + d[0], pos[2] + d[1]);
// columnContainsTriggeringBlock.
let surface = ctx.world.world_surface(x, z, |s| ctx.facts.is_air(s)) - 1;
let matches = if (surface - pos[1]).abs() > 5 {
let mut y = pos[1] + 6;
let mut above_air = ctx.is_air([x, y, z]);
y -= 1;
let mut hit = false;
for _ in 0..10 {
if above_air && is_sand([x, y, z]) {
hit = true;
break;
}
above_air = ctx.is_air([x, y, z]);
y -= 1;
}
hit
} else {
ctx.is_air([x, surface + 1, z]) && is_sand([x, surface, z])
};
if matches {
found += 1;
if found > 3 {
return true;
}
}
checked += 1;
if 4 - checked + found < 3 {
return false;
}
}
false
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
-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(),
)
}
@@ -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)]
@@ -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 {
+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);
}
}
+8 -4
View File
@@ -582,17 +582,21 @@ impl Experience {
7 + level * 2
}
}
/// Returns true when a level was gained.
pub fn give(&mut self, points: i32) -> bool {
let start = self.level;
/// Returns the first multiple of 5 the player reached, where each level
/// gained would play the level-up sound (`Player.giveExperienceLevels`).
pub fn give(&mut self, points: i32) -> Option<i32> {
let mut sound_level = None;
self.progress += points as f32 / Self::needed(self.level) as f32;
self.total = self.total.saturating_add(points).max(0);
while self.progress >= 1.0 {
self.progress = (self.progress - 1.0) * Self::needed(self.level) as f32;
self.level += 1;
if self.level % 5 == 0 {
sound_level.get_or_insert(self.level);
}
self.progress /= Self::needed(self.level) as f32;
}
self.level > start
sound_level
}
}
/// A painting (`Painting`, a `HangingEntity`): the block it hangs in, the
+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);
+127 -1
View File
@@ -187,6 +187,31 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
// LivingEntity.addAdditionalSaveData: `active_effects` only when there are
// any (MobEffectInstance.CODEC; a mob's effects are never ambient, and one
// given with `hideParticles` shows neither particles nor its icon).
if m.extra.effects.is_empty() {
map.remove(&horizon_world::nbt::JavaString::from("active_effects"));
} else {
let effects = m
.extra
.effects
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
e.push(("show_icon", Tag::Byte(i8::from(shown))));
Tag::compound(e)
});
map.insert("active_effects".into(), Tag::List(effects.collect()));
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
@@ -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,36 @@ 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))),
);
}
"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();
@@ -854,6 +920,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 +1035,11 @@ 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::Wolf {
let health = m.health;
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
@@ -985,6 +1075,7 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::Axolotl {
restore_axolotl_memories(&mut m, tag);
}
read_variant(&mut m, tag);
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
if m.species() == crate::mobs::Species::Allay {
@@ -2166,6 +2257,41 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) {
}
}
}
/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and
/// `TropicalFish` read; a missing tag is the default variant (a medium
/// salmon, otherwise index 0).
fn read_variant(m: &mut Mob, tag: &Tag) {
use crate::mobs::Species as S;
let int = |key| number(tag.get(key)).unwrap_or(0) as i32;
match m.species() {
S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8,
// Rabbit.Variant.LEGACY_CODEC: an unknown id is brown.
S::Rabbit => {
m.extra.variant = match int("RabbitType") {
v @ (0..=5 | 99) => v as u8,
_ => 0,
}
}
S::Salmon => {
m.extra.variant = text(tag.get("type"))
.and_then(|t| SALMON_SIZES.iter().position(|v| *v == t))
.unwrap_or(1) as u8;
}
S::TropicalFish => {
let packed = int("Variant");
let (size, index) = (packed & 0xFF, (packed >> 8) & 0xFF);
// TropicalFish.Pattern.byId: an unknown id is a kob.
m.extra.variant = if size <= 1 && index < 6 {
(size * 6 + index) as u8
} else {
0
};
m.extra.variant2 = ((packed >> 16) & 15) as u8;
m.extra.variant3 = ((packed >> 24) & 15) as u8;
}
_ => {}
}
}
fn restore_axolotl_memories(m: &mut Mob, tag: &Tag) {
let memories = tag.get("Brain").and_then(|b| b.get("memories"));
let ttl = memories
+8 -3
View File
@@ -889,12 +889,17 @@ impl Font {
if s.contains('§') {
return self.width_text(&Text::legacy(s));
}
s.chars().map(|c| self.glyph(c).advance).sum::<f32>().ceil()
crate::bidi::visual_str(s)
.chars()
.map(|c| self.glyph(c).advance)
.sum::<f32>()
.ceil()
}
/// Font.width of a component: `§` codes in the spans are formatting,
/// not glyphs.
/// not glyphs. Right-to-left text is measured as it is drawn, shaped.
pub fn width_text(&self, t: &Text) -> f32 {
t.resolved()
let t = t.resolved();
crate::bidi::visual(&t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
+105 -54
View File
@@ -63,9 +63,9 @@ mod frames;
mod friends;
mod functions;
mod game_mode;
mod game_rules_screen;
mod gamemode_switcher;
mod ghasts;
mod glowing;
mod golems;
mod growth;
mod hand;
@@ -128,6 +128,7 @@ mod packs;
mod pad;
mod pad_menu;
mod paintings;
mod pandas;
mod passengers;
mod persist;
mod perspective;
@@ -1341,6 +1342,8 @@ struct Game<'a> {
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
pack_ids: Option<shader_packs::PackIds>,
started: Instant,
@@ -2037,9 +2040,11 @@ impl Game<'_> {
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
/// pose.
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let sleeping = self.sleeping.is_some();
let item_use_speed = self
.using
.and_then(|(slot, _)| {
@@ -2067,6 +2072,7 @@ impl Game<'_> {
player.item_use_speed = item_use_speed;
player.respiration = respiration;
player.glider = glider;
let _ = player.set_sleeping(sleeping);
player.leather_boots = leather_boots;
player.freeze_immune = freeze_immune;
player.fluids.fast_lava = self.dim == Dim::Nether;
@@ -2454,6 +2460,15 @@ impl Game<'_> {
no_physics,
&self.res.motion().fluids,
);
// Entity.applyEffectsFromBlocks: bubble columns lift or drag the item.
horizon_client::motion::bubble_column_push(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
item.bounds(),
&mut item.velocity,
);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -2990,6 +3005,16 @@ impl Game<'_> {
data,
} => self.sculk_charge_event(pos, data),
LevelEvent::LevelEvent { id: 3007, pos, .. } => self.shriek_event(pos),
// LevelEventHandler 2019 and 2020: the crack on a face of the
// block being mined, with its hit sound for 2020.
LevelEvent::LevelEvent {
id: id @ (2019 | 2020),
pos,
data,
} => {
let face = horizon_core::geometry::Direction::ALL[data.clamp(0, 5) as usize];
self.breaking_block_effects([pos.x, pos.y, pos.z], face.normal(), id == 2020);
}
LevelEvent::LevelEvent { id: 2004, pos, .. } => {
self.spawner_burst([pos.x, pos.y, pos.z])
}
@@ -4241,6 +4266,17 @@ impl Game<'_> {
_ => {}
}
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
fn tick_mining(&mut self) -> Result<()> {
if self.destroy_delay > 0 {
self.destroy_delay -= 1;
@@ -4284,6 +4320,11 @@ impl Game<'_> {
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
// The server still counts its destroy ticks on an unbreakable block.
let m = self.mining.as_mut().ok_or("mining")?;
m.ticks += 1;
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
return Ok(());
}
// Player.getDestroySpeed / BlockBehaviour.getDestroyProgress.
@@ -4314,26 +4355,15 @@ impl Game<'_> {
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
};
let hit_sound = facts.sounds.clone();
self.swing_arm();
let m = self.mining.as_mut().ok_or("mining")?;
m.progress += progress;
m.ticks += 1;
let crack = (m.pos, hit.face);
if m.ticks % 4 == 1 {
let c = [
f64::from(m.pos[0]) + 0.5,
f64::from(m.pos[1]) + 0.5,
f64::from(m.pos[2]) + 0.5,
];
self.audio.play(
&hit_sound.hit,
Some(c),
(hit_sound.volume + 1.0) / 8.0,
hit_sound.pitch * 0.5,
);
// An instant break at START sends no destroy events.
if m.progress < 1.0 || m.ticks > 1 {
let (pos, ticks) = (m.pos, m.ticks);
self.destroy_progress_event(pos, hit.face, ticks);
}
self.crack_particle(crack.0, crack.1, state);
let m = self.mining.as_mut().ok_or("mining")?;
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
@@ -4653,7 +4683,6 @@ impl Game<'_> {
player.dimensions.eye_height
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
@@ -4689,10 +4718,12 @@ impl Game<'_> {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
} => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water.
// muffled under the swim sound in water. Whether it plays is
// Player.getMovementEmission's call (shift on the ground),
// not the pose: a forced crouch still steps.
let (volume, pitch) = if in_water {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
@@ -4704,26 +4735,21 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.swim", Some(feet), volume, pitch);
}
MotionEvent::Chime { volume, pitch } => {
self.audio.play(
"minecraft:block.amethyst_block.chime",
Some(feet),
volume,
pitch,
);
}
MotionEvent::Drown => {
// LivingEntity.makeDrownParticles: eight bubbles moving
// with the player.
use horizon_client::particles::{ParticleOptions, ParticleType};
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [
feet[0] + triangle(),
feet[1] + triangle(),
feet[2] + triangle(),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Bubble),
p,
player_velocity,
);
// The guests see the entity event 67 too.
if let Some(host) = &self.lan {
host.hub
.entity_event(host.host_entity, combat::DROWN_EVENT, feet);
}
self.drown_particles(feet, player_velocity);
}
MotionEvent::BubbleColumnParticle { bubble, position } => {
// ServerLevel.sendParticles: SPLASH (speed 1) or BUBBLE
@@ -4826,6 +4852,7 @@ impl Game<'_> {
}
}
self.powder_snow_fire()?;
self.rain_extinguish_in_frame();
self.tick_totem();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -9710,16 +9737,13 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
if self.experience.give(gained as i32)
&& self.experience.level % 5 == 0
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
let volume = if self.experience.level > 30 {
1.0
} else {
self.experience.level as f32 / 30.0
};
let volume = if level > 30 { 1.0 } else { level as f32 / 30.0 };
self.audio
.play("minecraft:entity.player.levelup", at, volume * 0.75, 1.0);
}
@@ -10089,6 +10113,14 @@ impl Game<'_> {
};
Some(t < 0.15)
}
/// `Level.isRainingAt`: it rains, the sky light is 15, nothing that stops
/// motion is above, and the biome's precipitation there is rain.
fn raining_at(&self, p: BlockPos) -> bool {
self.world.weather.levels(1.0).0 > 0.2
&& self.world.light(p.x, p.y, p.z) >> 4 == 15
&& self.rain_height(p.x, p.z) <= p.y
&& self.precipitation(p.x, p.y, p.z) == Some(false)
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)`: one above the
/// highest block in `blocks_motion_in_heightmap` or holding a fluid.
fn rain_height(&self, x: i32, z: i32) -> i32 {
@@ -10125,12 +10157,15 @@ impl Game<'_> {
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
// The Horizon Weather option off silences the rain too.
if self.settings.horizon.weather {
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
}
}
@@ -10673,6 +10708,9 @@ impl Game<'_> {
let level = &mut self.level;
level.creative = self.creative;
level.game_mode = mode;
// PrimaryLevelData.setDifficulty: /difficulty and the options button change it.
level.difficulty =
horizon_client::title::DIFFICULTIES[usize::from(self.ui.difficulty) & 3].into();
level.time = self.world.game_time;
level.weather = self.world.weather;
level.day_time = self.world.day_time;
@@ -10948,7 +10986,11 @@ impl Game<'_> {
let debug_crosshair = horizon_client::gizmos::camera_relative(&crosshair.on_top, at, view);
// LevelExtractor.extractBlockOutline: model translucency controls draw order.
let mut outline_after_terrain = false;
let outline = if self.screen == Screen::Playing && !self.spectator() {
// GameRenderer.shouldRenderBlockOutline: not with the HUD hidden (F1), and only for a
// player who may build. Adventure players only get one with a CAN_BREAK or
// CAN_PLACE_ON item and spectators with a menu block; Horizon reads neither the
// components nor opens menus for spectators, so those never show it.
let outline = if self.screen == Screen::Playing && !self.ui.hide_gui && self.may_build() {
self.pick()
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
@@ -11033,6 +11075,7 @@ impl Game<'_> {
let mut entities = std::mem::take(&mut self.entity_vertices);
entities.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
@@ -11100,6 +11143,7 @@ impl Game<'_> {
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -11144,6 +11188,7 @@ impl Game<'_> {
dark_disc: self.dark_disc(camera),
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -11165,6 +11210,7 @@ impl Game<'_> {
}),
entities: &entities,
entity_spans: &entity_spans,
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
lightning: horizon_client::gfx::lightning::Input {
bolts: &bolts,
@@ -11203,7 +11249,7 @@ impl Game<'_> {
}),
ticks: self.ticks,
darkness: self.darkness_lighting(alpha as f32),
night_vision: self.night_vision_intensity(),
night_vision: self.night_vision_intensity(alpha as f32),
block_flicker: self.fog.block_light_flicker,
boss_darken: self
.boss_overlay
@@ -11220,6 +11266,7 @@ impl Game<'_> {
// Restore storage even when rendering fails (for example, a lost surface).
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.glowing_vertices.get_mut() = glowing;
self.see_through_vertices = see_through;
result
}
@@ -12108,7 +12155,8 @@ impl ApplicationHandler for Game<'_> {
let (action, sound) = self.title.as_mut().map_or((Action::None, false), |t| {
let clicked = t.click(self.cursor.0, self.cursor.1, (b.width, b.height));
t.press_field(atlas, self.cursor, (b.width, b.height), shift);
clicked
let rules = t.press_rules(atlas, self.cursor, (b.width, b.height));
(clicked.0, clicked.1 || rules)
});
self.save_title_options();
if sound {
@@ -12877,6 +12925,7 @@ impl<'a> Game<'a> {
luminance: None,
see_through_vertices: Vec::new(),
entity_spans: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
started: now,
@@ -13851,6 +13900,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
dark_disc: false,
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -13858,6 +13908,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
nether_lighting: false,
entities: &[],
entity_spans: &[],
glowing: &[],
dynamic_lights: &[],
clouds: None,
lightning: Default::default(),
@@ -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,
+8 -35
View File
@@ -801,7 +801,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 +830,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 +880,6 @@ impl Game<'_> {
if let Err(e) = horizon_client::save::write_dat(&path, root) {
log::warn!("the world clocks were not written: {e}");
}
if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) {
log::warn!("the difficulty was not written: {e}");
}
}
/// Death message in chat (CombatTracker.getDeathMessage, as far as the
/// cause is known to the client).
@@ -988,34 +989,6 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
n += 1;
}
}
fn read_difficulty(world: &std::path::Path) -> Option<u8> {
let bytes = std::fs::read(world.join("level.dat")).ok()?;
let raw = horizon_world::compression::Compression::Gzip
.decode(&bytes, 64 << 20)
.ok()?;
let (root, _) =
horizon_world::nbt::read_named(&raw, horizon_world::nbt::Limits::default()).ok()?;
match root.tag.get("Data")?.get("Difficulty")? {
horizon_world::nbt::Tag::Byte(b) => Some((*b).clamp(0, 3) as u8),
_ => None,
}
}
/// Patch `Data.Difficulty` in level.dat (Level::write stores the default).
fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::Result<()> {
use horizon_world::{compression::Compression, nbt};
let path = world.join("level.dat");
let bytes = std::fs::read(&path)?;
let raw = Compression::Gzip.decode(&bytes, 64 << 20)?;
let (mut root, _) = nbt::read_named(&raw, nbt::Limits::default()).map_err(|e| e.to_string())?;
if let Some(data) = root.tag.get_mut("Data")
&& let Some(d) = data.get_mut("Difficulty")
{
*d = nbt::Tag::Byte(difficulty as i8);
}
let out = nbt::write_named(&root, nbt::Limits::default()).map_err(|e| e.to_string())?;
std::fs::write(&path, Compression::Gzip.encode(&out)?)?;
Ok(())
}
/// `IntegratedServer.getProfilePermissions` for the owner: `OWNER` (4) when
/// commands are allowed, else `ALL` (0).
@@ -37,6 +37,7 @@ impl Game<'_> {
}
}
self.eye_particles();
self.panda_effects();
self.sprint_dust();
self.effect_swirls();
}
+44 -3
View File
@@ -1,7 +1,16 @@
//! The player's `CombatTracker`: the damage taken lately, who dealt it and
//! where the player fell from, turned into the vanilla death message.
use super::{Game, chat::PLAYER_NAME};
use horizon_client::{health::HealthEvent, motion::MotionEvent, text::Text};
use horizon_client::{
health::HealthEvent,
motion::MotionEvent,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::random::RandomSource;
/// `EntityEvent.DROWN_PARTICLES`: broadcast with the drowning damage.
pub(super) const DROWN_EVENT: i8 = 67;
struct Entry {
damage_type: String,
@@ -45,6 +54,38 @@ pub(super) struct CombatTracker {
fall_before: f64,
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
pub(super) fn drown_particles(&mut self, at: [f64; 3], velocity: [f64; 3]) {
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for _ in 0..8 {
let mut triangle = || {
let r = self.particles.random();
r.next_double() - r.next_double()
};
let p = [at[0] + triangle(), at[1] + triangle(), at[2] + triangle()];
self.particles.add(&bubble, p, velocity);
}
}
/// Entity event 67 from a server, for this client's own player or an
/// entity it draws.
pub(super) fn remote_drown(&mut self, id: i32) {
let own = self.remote.as_ref().is_some_and(|r| r.entity_id == id);
let body = if own {
self.player.as_ref().map(|p| {
let (at, v) = (p.position, p.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
let (at, v) = (m.position, m.velocity);
([at.x, at.y, at.z], [v.x, v.y, v.z])
})
};
if let Some((at, v)) = body {
self.drown_particles(at, v);
}
}
/// The display name of a mob: its custom name or its type's name.
pub(super) fn mob_display_name(&self, id: u32) -> Option<Text> {
let m = self.mobs.iter().find(|m| m.id == id)?;
@@ -82,8 +123,8 @@ impl Game<'_> {
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
let pitch = self.audio.hurt_pitch();
self.audio.play(sound, Some(at), 1.0, pitch);
// Player.getVoicePitch: always 1.0.
self.audio.play(sound, Some(at), 1.0, 1.0);
}
/// `CombatTracker.recordDamage` for the motion events of this tick.
pub(super) fn record_combat(&mut self, events: &[MotionEvent]) {
@@ -2,7 +2,7 @@
//! tick (`execute`, `executeChain`), `BaseCommandBlock.performCommand`
//! through the command system with the block's output, and
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source};
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
@@ -12,6 +12,7 @@ use horizon_client::{
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
@@ -35,6 +36,16 @@ pub(super) struct CommandBlockScreen {
auto: bool,
}
/// What `ServerboundSetCommandBlockPacket` carries.
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum Button {
Output,
@@ -186,6 +197,18 @@ impl Game<'_> {
pub(super) fn can_use_game_master_blocks(&self) -> bool {
self.creative && self.ui.registries.permission >= 2
}
/// `CommandBlock.useWithoutItem` on a server's client, which opens the
/// screen itself (`Player.openCommandBlock`) while the server only
/// answers the click: the block at `pos`, when it is a command block the
/// player may edit.
pub(super) fn open_command_block_if_editable(&mut self, pos: Pos) {
let is_command_block = self
.block_state_at(pos)
.is_some_and(|state| Mode::of_block(&state.name).is_some());
if is_command_block && self.can_use_game_master_blocks() {
self.open_command_block(pos);
}
}
fn command_block(&self, pos: Pos) -> Option<&CommandBlock> {
match self.world.block_entities.get(&pos)? {
BlockEntity::CommandBlock(c) => Some(c),
@@ -572,12 +595,66 @@ impl Game<'_> {
s.command.insert_text(&allowed);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock`.
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
fn set_command_block(&mut self, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
auto: s.auto,
};
if self.remote.is_some() {
self.send(S::SetCommandBlock(play::SetCommandBlock {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
command: edit.command,
mode: edit.mode.ordinal(),
flags: i8::from(edit.track_output)
| (i8::from(edit.conditional) << 1)
| (i8::from(edit.auto) << 2),
}));
} else {
self.apply_command_block(&edit);
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandBlock` for a guest: the
/// block is edited only by a creative player with game master commands.
pub(super) fn lan_set_command_block(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandBlock,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
let edit = CommandBlockEdit {
pos: (p.pos[0], p.pos[1], p.pos[2]),
command: p.command,
track_output: p.flags & 1 != 0,
mode: Mode::from_ordinal(p.mode),
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -609,7 +686,7 @@ impl Game<'_> {
}
let _ = self.process_level_events();
}
let command = s.command.value.clone();
let command = s.command.clone();
let Some(c) = command_block_mut(&mut self.world, pos) else {
return;
};
@@ -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}"), &[])
}
@@ -119,6 +119,7 @@ impl Game<'_> {
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
"freeze" => DamageKind::Freeze,
"in_wall" => DamageKind::InWall,
"thorns" => DamageKind::Thorns,
_ => DamageKind::Generic,
+176 -14
View File
@@ -193,6 +193,8 @@ impl Game<'_> {
last_hurt: self.combat.last_hurt_mob.map(|(id, at)| (Who::Mob(id), at)),
velocity: p.velocity,
yaw: self.yaw,
sprinting: p.sprinting,
riding: self.riding.is_some(),
hero: self.potions.effects.get("hero_of_the_village").is_some(),
effects: self.potions.effects.list.iter().map(|e| e.id).collect(),
uuid: self.level.player_uuid.unwrap_or_default(),
@@ -402,6 +404,7 @@ impl Game<'_> {
world: &self.world,
registry: self.pack.assets.registry,
facts: self.res.facts(),
fluid_tables: &self.motion.fluids,
movement: &self.motion.blocks,
collision: self.collision,
data: self.res.mob_data(),
@@ -679,6 +682,7 @@ impl Game<'_> {
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
self.guardian_bubbles();
self.villager_reputation_tick();
self.merchant_tick();
self.villager_pickups();
@@ -690,6 +694,7 @@ impl Game<'_> {
self.tick_projectiles()?;
self.tick_fangs();
self.tick_allays();
self.tick_pandas();
self.tick_breeze_effects();
self.tick_sniffer_effects();
self.piglin_pickups();
@@ -933,6 +938,18 @@ impl Game<'_> {
match e {
MobEvent::SquidCycle { mob, at } => self.lan_entity_event(mob as i32, 19, at),
MobEvent::RabbitHop { mob, at } => self.lan_entity_event(mob as i32, 1, at),
MobEvent::Drown { mob, at } => {
self.lan_entity_event(mob as i32, super::combat::DROWN_EVENT, at);
let v = self
.mobs
.iter()
.find(|m| m.id == mob)
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -980,6 +997,19 @@ impl Game<'_> {
}
}
}
MobEvent::Knockback {
target,
power,
x,
z,
} => match target {
Who::Player => self.knock_back_player(power, x, z),
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.knockback(power, x, z);
}
}
},
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -1372,9 +1402,14 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::PandaSneezed { mob } => self.panda_sneezed(mob),
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
// Turtle.ageBoundaryReached and Panda.afterSneeze: only
// with mob loot on.
if matches!(
table,
"minecraft:gameplay/turtle_grow" | "minecraft:gameplay/panda_sneeze"
) && !self.ui.rules.bool("mob_drops")
{
continue;
}
@@ -1611,6 +1646,13 @@ impl Game<'_> {
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Panda.getBreedOffspring: genes from both parents.
if p.species() == Species::Panda {
horizon_client::mobs::panda::inherit(child, &p, q.as_ref().unwrap_or(&p));
let xp = 1 + self.world.random.next_int_bound(7).unwrap_or(0) as u32;
self.spawn_breeding_orb([p.position.x, p.position.y, p.position.z], xp);
return;
}
// Nautilus.getBreedOffspring: a tame parent's baby is tame too.
if p.species() == Species::Nautilus && p.extra.tame {
child.extra.tame = true;
@@ -1619,6 +1661,10 @@ impl Game<'_> {
}
// Offspring inherit a parent's variant (pigs/cows/chickens) or mixed wool.
child.extra.variant = p.extra.variant;
// Fox.getBreedOffspring: either parent's variant.
if p.species() == Species::Fox && !coin {
child.extra.variant = q.as_ref().map_or(p.extra.variant, |q| q.extra.variant);
}
if p.species() == Species::Sheep {
child.extra.color = p.extra.color;
}
@@ -1920,6 +1966,7 @@ impl Game<'_> {
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut arrow_events = Vec::new();
let mut trails = Vec::new();
{
let mut targets: Vec<(Who, Aabb)> = self
.mobs
@@ -2011,6 +2058,25 @@ impl Game<'_> {
if shooter.is_some() {
p.owner = Some(Who::Player);
}
// AbstractHurtingProjectile.tick ends with createParticleTrail.
if super::remote_projectiles::has_smoke_trail(&p.kind) {
trails.push(p.position);
}
// ThrownEnderpearl.onAboveBubbleColumn / onInsideBubbleColumn.
if keep && hit == Hit::None && matches!(p.kind, ProjectileKind::EnderPearl) {
let (at, h) = (p.position, 0.125);
horizon_client::motion::bubble_column_push(
w.world,
w.registry,
w.facts,
w.collision,
Aabb::new(
Vec3::new(at.x - h, at.y, at.z - h),
Vec3::new(at.x + h, at.y + 2.0 * h, at.z + h),
),
&mut p.velocity,
);
}
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
@@ -2053,6 +2119,7 @@ impl Game<'_> {
keep
});
}
self.smoke_trails(trails);
for at in deflections {
self.audio
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
@@ -2217,11 +2284,9 @@ impl Game<'_> {
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
}
}
/// `Level.isRainingAt` for arrows: raining, open to the sky (the biome's
/// precipitation is not checked here).
/// `Level.isRainingAt` for arrows.
fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
let raining = self.world.weather.levels(1.0).0 > 0.2;
move |p: BlockPos| raining && self.world.light(p.x, p.y, p.z) >> 4 == 15
move |p: BlockPos| self.raining_at(p)
}
/// The level as arrows see it.
fn arrow_scene<'s>(
@@ -2834,6 +2899,14 @@ impl Game<'_> {
let creative = self.creative;
// The hand's slot (Player.interactOn passes the hand).
let selected = self.held_slot();
// Panda.mobInteract: bamboo, or a click on a panda lying on its back.
if self.mobs[index].species() == Species::Panda && self.mobs[index].alive() {
let taken = self.interact_panda(index, &item);
if taken {
self.swing_arm();
}
return Ok(taken);
}
if self.interact_zombie_villager(index, &item) {
return Ok(true);
}
@@ -2886,6 +2959,7 @@ impl Game<'_> {
.items()
.get(&item)
.is_some_and(|i| i.max_damage > 0);
let acting = self.acting_player_uuid();
let m = &mut self.mobs[index];
if !m.alive() {
return Ok(false);
@@ -2980,6 +3054,7 @@ impl Game<'_> {
}
Species::Wolf
if m.extra.tame
&& m.extra.owner == Some(acting)
&& item.ends_with("_dye")
&& horizon_client::mobs::render::DYE_NAMES
.iter()
@@ -3002,7 +3077,9 @@ impl Game<'_> {
consume = true;
}
Species::Wolf
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
if m.extra.tame
&& m.extra.owner == Some(acting)
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
{
m.extra.sitting = !m.extra.sitting;
m.ai.jumping = false;
@@ -3022,7 +3099,7 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
if tamed {
horizon_client::mobs::tame::set_tame(m, true);
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
m.persistent = true;
m.nav_stop();
m.ai.target = None;
@@ -3049,6 +3126,7 @@ impl Game<'_> {
// Cat.mobInteract: dye, heal or sit when tame; fish may tame a wild one.
Species::Cat
if m.extra.tame
&& m.extra.owner == Some(acting)
&& item.ends_with("_dye")
&& horizon_client::mobs::render::DYE_NAMES
.iter()
@@ -3071,7 +3149,12 @@ impl Game<'_> {
m.persistent = true;
consume = true;
}
Species::Cat if m.extra.tame && is_food && m.health < m.attrs.max_health => {
Species::Cat
if m.extra.tame
&& m.extra.owner == Some(acting)
&& is_food
&& m.health < m.attrs.max_health =>
{
let heal = self
.res
.items()
@@ -3084,7 +3167,9 @@ impl Game<'_> {
eat = eating_sound(m, &mut self.world.random);
}
Species::Cat
if m.extra.tame && !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
if m.extra.tame
&& m.extra.owner == Some(acting)
&& !(is_food && m.extra.age == 0 && m.extra.in_love <= 0) =>
{
m.extra.sitting = !m.extra.sitting;
self.world.modified.insert((
@@ -3098,7 +3183,7 @@ impl Game<'_> {
let tamed = m.random.next_int_bound(3).unwrap_or(1) == 0;
if tamed {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
m.extra.owner = Some(acting);
m.extra.sitting = true;
}
eat = eating_sound(m, &mut self.world.random);
@@ -3402,6 +3487,60 @@ impl Game<'_> {
}
}
}
/// `Guardian.aiStep` on a client: two bubbles a tick trailing a guardian
/// that swims along, and bubbles up the beam to its target. The server
/// never draws them, so the mob's own random stays untouched.
pub(super) fn guardian_bubbles(&mut self) {
use horizon_client::mobs::guardian;
use horizon_client::particles::{ParticleOptions, ParticleType};
let mut swimming = Vec::new();
let mut beams = Vec::new();
for m in &self.mobs {
if !m.alive() || !matches!(m.species(), Species::Guardian | Species::ElderGuardian) {
continue;
}
let at = [m.position.x, m.position.y, m.position.z];
if m.extra.flag2 && m.fluids.in_water {
let (width, height, _) = m.dims();
swimming.push((at, width, height, m.view_vector()));
}
if let Some((_, scale)) = guardian::beam(m, 0.0)
&& let Some(t) = self.beam_target(m.ai.target, 1.0)
{
beams.push(([at[0], m.eye_y(), at[2]], [t.x, t.y, t.z], f64::from(scale)));
}
}
let bubble = ParticleOptions::simple(ParticleType::Bubble);
for (at, width, height, view) in swimming {
for _ in 0..2 {
let (x, y, z) = {
let r = self.particles.random();
(r.next_double(), r.next_double(), r.next_double())
};
let p = [
at[0] + width * (2.0 * x - 1.0) * 0.5 - view[0] * 1.5,
at[1] + height * y - view[1] * 1.5,
at[2] + width * (2.0 * z - 1.0) * 0.5 - view[2] * 1.5,
];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
for (eye, target, scale) in beams {
let d = [target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]];
let length = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if length <= 0.0 {
continue;
}
let mut dist = self.particles.random().next_double();
while dist < length {
let r = self.particles.random();
dist += 1.8 - scale + r.next_double() * (1.7 - scale);
let f = dist / length;
let p = [eye[0] + d[0] * f, eye[1] + d[1] * f, eye[2] + d[2] * f];
self.particles.add(&bubble, p, [0.0; 3]);
}
}
}
/// Mob models and projectiles for the entity pass.
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
@@ -3409,8 +3548,10 @@ impl Game<'_> {
let scale = f64::from(self.settings.entity_distance);
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
if mob.invisible {
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
if mob.invisible && outline.is_none() {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
@@ -3472,9 +3613,18 @@ impl Game<'_> {
out,
);
self.mob_block_geometry(mob, camera, alpha, packed, out);
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if !mob.glowing && horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none() {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
self.cull_entity_vertices(out, entity_start);
}
// GuardianRenderer: the laser to the locked target.
@@ -3522,7 +3672,15 @@ impl Game<'_> {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0 && self.entity_glowing(a.id) {
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
let light = self.world.light(
@@ -3654,6 +3812,9 @@ impl Game<'_> {
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
@@ -3703,6 +3864,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
K::SonicBoom => "minecraft:sonic_boom",
K::Freeze => "minecraft:freeze",
K::OutOfWorld => "minecraft:out_of_world",
}
}
+165 -2
View File
@@ -13,7 +13,7 @@ use horizon_client::{
save::VERSION_NAME,
text::Text,
};
use horizon_core::geometry::Axis;
use horizon_core::geometry::{Axis, Vec3};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -766,7 +766,8 @@ impl Game<'_> {
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
let lerpf = |a: f32, b: f32| a + alpha * (b - a);
for m in &self.mobs {
let guests = self.lan.iter().flat_map(|h| h.models.values());
for m in self.mobs.iter().chain(guests) {
if m.removed || m.invisible || m.has_effect("minecraft:invisibility") {
continue;
}
@@ -796,6 +797,159 @@ impl Game<'_> {
// EntityType.ITEM: 0.25 by 0.25, eyes at 0.2125.
g.hitbox(pos, (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
// The other entities (EntityTypes sizes; eyes at 0.85 of the height
// unless the type sets one). `at` is the interpolated position.
let at = |previous: Vec3, now: Vec3| {
[
lerp(previous.x, now.x),
lerp(previous.y, now.y),
lerp(previous.z, now.z),
]
};
for p in &self.paintings {
let (min, max) = p.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.425, false, step_view(p.step()));
}
for f in self.item_frames.iter().filter(|f| !f.invisible) {
let (min, max) = f.bounds();
let centre = std::array::from_fn(|i| (min[i] + max[i]) / 2.0);
g.entity_box(min, max, centre, 0.0, false, step_view(f.step()));
}
for c in &self.cushions {
let (min, max) = c.bounds();
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
let (size, eye) = match (a.marker, a.small) {
(true, _) => ((0.0, 0.0), 0.0),
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
g.hitbox(
at(b.previous, b.position),
(super::boats::WIDTH as f32, h),
h,
false,
(0.0, b.yaw),
);
}
for c in &self.carts {
let (w, h) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
g.hitbox(
at(c.previous, c.position),
(w, h),
h * 0.85,
false,
(0.0, c.yaw),
);
}
for o in &self.orbs {
g.hitbox(
at(o.previous, o.position),
(0.5, 0.5),
0.425,
false,
(0.0, 0.0),
);
}
for f in &self.falling {
g.hitbox(
at(f.previous, f.position),
(0.98, 0.98),
0.833,
false,
(0.0, 0.0),
);
}
for t in &self.tnts {
g.hitbox(
at(t.previous, t.position),
(0.98, 0.98),
0.15,
false,
(0.0, 0.0),
);
}
for c in &self.crystals {
let p = c.position;
g.hitbox([p.x, p.y, p.z], (2.0, 2.0), 1.7, false, (0.0, 0.0));
}
for p in &self.creatures.projectiles {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
K::WindCharge | K::BreezeWindCharge => (0.3125, 0.0),
K::LargeFireball { .. } => (1.0, 0.85),
};
g.hitbox(
at(p.previous, p.position),
(size, size),
eye,
false,
(p.pitch, p.yaw),
);
}
for t in &self.tridents {
g.hitbox(
at(t.previous, t.position),
(0.5, 0.5),
0.13,
false,
(t.pitch, t.yaw),
);
}
for r in &self.rockets {
// Projectile.updateRotation: the look follows the motion.
let v = r.velocity;
let yaw = v.x.atan2(v.z).to_degrees() as f32;
let pitch = v.y.atan2(v.x.hypot(v.z)).to_degrees() as f32;
g.hitbox(
at(r.previous, r.position),
(0.25, 0.25),
0.2125,
false,
(pitch, yaw),
);
}
for h in self
.hook
.iter()
.map(|h| (h.previous, h.position))
.chain(self.remote_hooks.iter().map(|h| (h.previous, h.position)))
{
g.hitbox(at(h.0, h.1), (0.25, 0.25), 0.2125, false, (0.0, 0.0));
}
for b in &self.bolts {
g.hitbox(b.at, (0.0, 0.0), 0.0, false, (0.0, 0.0));
}
// The dragon: 16 by 8, a living entity; its parts follow.
if let Some(d) = &self.dragon {
g.hitbox(
at(d.previous, d.position),
(16.0, 8.0),
6.8,
true,
(0.0, d.yaw),
);
}
// The dragon's parts.
for (id, b) in self.special_targets() {
if (super::dragon::SPECIAL..super::dragon::SPECIAL + 8).contains(&id) {
@@ -828,3 +982,12 @@ impl Game<'_> {
g
}
}
/// The (x, y) rotations whose view vector is the step of a direction.
fn step_view(step: [i32; 3]) -> (f32, f32) {
let [x, y, z] = step.map(f64::from);
(
(-y).asin().to_degrees() as f32,
(-x.atan2(z)).to_degrees() as f32,
)
}
+3 -1
View File
@@ -2127,10 +2127,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.
+50 -12
View File
@@ -253,10 +253,17 @@ impl Game<'_> {
out
}
/// LightmapRenderStateExtractor: End flashes add to the sky light factor.
/// LightmapRenderStateExtractor: End flashes add to the sky light factor
/// unless Hide Lightning Flashes is on (the sky sprite is not gated), a
/// third of it under a boss bar's world fog.
pub(super) fn end_flash_sky(&self, sky: &mut horizon_client::environment::Sky, partial: f64) {
if self.dim == Dim::End {
sky.sky_light_factor += self.end.flash.intensity(partial as f32);
if self.dim == Dim::End && !self.settings.file.bool("hideLightningFlashes") {
let intensity = self.end.flash.intensity(partial as f32);
sky.sky_light_factor += if self.boss_overlay.should_create_world_fog() {
intensity / 3.0
} else {
intensity
};
}
}
@@ -599,7 +606,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 +618,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 +656,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,
+4 -4
View File
@@ -30,8 +30,8 @@ enum State {
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
life: i32,
@@ -770,8 +770,8 @@ impl Game<'_> {
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
pub(super) position: Vec3,
pub(super) previous: Vec3,
}
impl Hook {
+1 -1
View File
@@ -456,7 +456,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
}));
}
}
+6 -1
View File
@@ -405,7 +405,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);
}
}
}
}
+212 -6
View File
@@ -288,8 +288,17 @@ 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 0x01, glowing 0x40
// (LivingEntity.updateGlowingStatus).
let shared = i8::from(m.fire_ticks > 0) | i8::from(m.is_currently_glowing()) << 6;
if shared != 0 {
meta.push((0, serializer::BYTE, Value::Byte(shared)));
}
// Entity.DATA_POSE: the pose the species keeps in its own fields (a
// pose back at standing goes out by the entity's own default).
let pose = horizon_client::mobs::sync::pose_id(m);
if pose != 0 {
meta.push((6, serializer::POSE, Value::Id(i32::from(pose))));
}
if let Some(name) = &m.custom_name {
meta.push((
@@ -400,6 +409,18 @@ 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:cat" | "minecraft:wolf" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
let flags = i8::from(e.sitting) | if e.tame { 4 } else { 0 };
@@ -514,6 +535,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,6 +573,121 @@ fn mob_meta(
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
}
// Warden.CLIENT_ANGER_LEVEL: the heartbeat's pace.
"minecraft:warden" => {
let anger = e.warden.as_ref().map_or(0, |w| w.client_anger);
if anger != 0 {
meta.push((16, serializer::INT, Value::Int(anger)));
}
defaults.push((16, serializer::INT, Value::Int(0)));
}
// Sniffer.DATA_STATE (after AgeableMob's two).
"minecraft:sniffer" => {
if e.variant != 0 {
meta.push((
18,
serializer::SNIFFER_STATE,
Value::Id(i32::from(e.variant)),
));
}
defaults.push((18, serializer::SNIFFER_STATE, Value::Id(0)));
}
// Shulker.DATA_ATTACH_FACE_ID, DATA_PEEK_ID and DATA_COLOR_ID (16
// for none).
"minecraft:shulker" => {
if e.variant3 != 0 {
meta.push((16, serializer::DIRECTION, Value::Id(i32::from(e.variant3))));
}
defaults.push((16, serializer::DIRECTION, Value::Id(0)));
if e.counter != 0 {
meta.push((17, serializer::BYTE, Value::Byte(e.counter as i8)));
}
defaults.push((17, serializer::BYTE, Value::Byte(0)));
if e.color != 16 {
meta.push((18, serializer::BYTE, Value::Byte(e.color as i8)));
}
defaults.push((18, serializer::BYTE, Value::Byte(16)));
}
// 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.
"minecraft:enderman" => {
if let Some(state) = e.carried {
@@ -541,6 +700,38 @@ fn mob_meta(
}
_ => {}
}
// Camel.DASH and LAST_POSE_CHANGE_TICK (after AbstractHorse's flags).
if horizon_client::mobs::camel::is_camel(m.species()) {
if e.flag2 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
let tick = horizon_client::mobs::camel::synced_pose_tick(m, game_time);
if tick != 0 {
meta.push((20, serializer::LONG, Value::Long(tick)));
}
defaults.push((20, serializer::LONG, Value::Long(0)));
}
}
/// What a client shakes a mob by: Entity.DATA_TICKS_FROZEN (7), whose full
/// count is `isFullyFrozen`, and Strider.DATA_SUFFOCATING (19, after
/// AgeableMob's two and DATA_BOOST_TIME).
fn mob_freeze_meta(
m: &Mob,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
if m.ticks_frozen > 0 {
meta.push((7, serializer::INT, Value::Int(m.ticks_frozen)));
}
defaults.push((7, serializer::INT, Value::Int(0)));
if m.kind.id == "minecraft:strider" {
if m.extra.flag {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
}
/// The equipment a mob shows (ClientboundSetEquipmentPacket slots: main hand,
@@ -551,13 +742,16 @@ fn mob_equipment(
out: &mut Vec<(u8, Option<horizon_protocol::item::Stack>)>,
) {
let e = &m.extra;
// A horse's armour, a llama's carpet, a harness: Horizon keeps the body
// slot's item (6) in `head`.
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slots = [
(0u8, &e.main_hand),
(1, &e.off_hand),
(2, &e.feet),
(3, &e.legs),
(4, &e.chest),
(5, &e.head),
(if body { 6 } else { 5 }, &e.head),
];
for (slot, item) in slots {
if let Some(item) = item {
@@ -1067,6 +1261,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 +1272,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 {
@@ -1406,8 +1605,9 @@ impl Game<'_> {
horizon_world::level::LevelEvent::LevelEvent { id, pos, data } => {
// The acting guest does not predict the egg's teleport
// particles, bone meal's growth, or anvil result sounds
// (these levelEvent calls name no excluded player).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030) {
// (these levelEvent calls name no excluded player), nor
// the digger's own crack and hit sound (2019, 2020).
let except = if matches!(*id, 2015 | 1505 | 1029 | 1030 | 2019 | 2020) {
None
} else {
self.lan_acting
@@ -1630,6 +1830,7 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
@@ -1698,7 +1899,12 @@ impl Game<'_> {
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
if !m.glowing {
// LivingEntity.isCurrentlyGlowing: the guest's Glowing effect.
let guest = host.hub.guest(*conn);
if m.glowing || guest.is_some_and(|g| g.effects.get("glowing").is_some()) {
let team = guest.and_then(|g| self.score.board.players_team(&g.name));
self.mark_outline(&out[entity_start..], super::glowing::team_color(team));
} else {
self.cull_entity_vertices(out, entity_start);
}
}
+15 -3
View File
@@ -1200,6 +1200,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);
@@ -2188,7 +2189,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
@@ -2257,6 +2258,7 @@ impl Game<'_> {
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.player.set_sleeping(sleeping);
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
@@ -2273,6 +2275,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) {
@@ -2333,6 +2337,15 @@ impl Game<'_> {
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
MotionEvent::Drown => {
if let Some(g) = host.hub.guest(conn) {
host.hub
.entity_event(g.entity_id, super::combat::DROWN_EVENT, at);
}
self.drown_particles(at, [0.0; 3]);
}
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
@@ -2668,8 +2681,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();
+29 -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};
@@ -28,6 +28,22 @@ pub(super) fn player_for(
) -> Option<u32> {
let targeting = m.ai.target == Some(Who::Player)
|| m.ai.last_hurt_by == Some(Who::Player) && m.age as i32 - m.ai.last_hurt_by_time <= 100;
// A tame wolf or cat sees its owner (`TamableAnimal.getOwner`), while they
// are in play, whoever is nearer. Not while it targets or was just hurt by
// a player: then `Who::Player` is that attacker, whoever it is, and the
// owner goals ignore it until the wolf lets go.
if !targeting
&& matches!(m.species(), Species::Wolf | Species::Cat)
&& m.extra.tame
&& let Some(owner) = m.extra.owner
{
if host.is_some_and(|v| v.alive && v.uuid == owner) {
return None;
}
if let Some((conn, _)) = guests.iter().find(|(_, v)| v.alive && v.uuid == owner) {
return Some(*conn);
}
}
choose(m.position, targeting, host, guests, bound)
}
@@ -70,6 +86,10 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
target: Who::Player,
..
}
| MobEvent::Knockback {
target: Who::Player,
..
}
| MobEvent::Ignite {
target: Who::Player,
..
@@ -175,9 +195,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 +339,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 +356,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)]
@@ -369,6 +384,8 @@ mod tests {
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
sprinting: false,
riding: false,
hero: false,
effects: Vec::new(),
uuid: [0; 4],
@@ -247,14 +247,10 @@ impl Game<'_> {
return;
};
let (d, s) = (p.dist.map(f64::from), p.max_speed.map(f64::from));
// ClientLevel.addParticle: the camera's 32 blocks unless the packet
// (`force`) or the type overrides the limiter.
// ClientLevel.addParticle: the camera's 32 blocks and the Particles
// option, unless the packet or the type overrides the limiter.
let add = |particles: &mut horizon_client::particles::ParticleEngine, at, v| {
if p.override_limiter {
particles.create(&options, at, v);
} else {
particles.add(&options, at, v);
}
particles.add_with(&options, at, v, p.override_limiter, p.always_show);
};
if p.count == 0 {
add(
+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},
@@ -10,13 +10,12 @@ 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]);
}
/// `LevelEventHandler.levelEvent` 3002 to 3005, drawing from the client
/// level's random.
@@ -135,6 +134,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.
+18 -3
View File
@@ -324,6 +324,13 @@ const DECORATION_TYPES: [(&str, &str); 24] = [
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// `MapDecorationType.showOnItemFrame` is false for the player arrows and the two markers.
fn shown_on_frame(sprite: &str) -> bool {
!matches!(
decoration_type(sprite),
Some("player" | "red_marker" | "blue_marker" | "player_off_map" | "player_off_limits")
)
}
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
@@ -1114,9 +1121,12 @@ impl Game<'_> {
}
/// `MapRenderer.render`: the map texture and its decorations under `m`
/// (map pixels 0..128, y down), drawn at depth offsets toward the viewer.
/// `show_only_frame` (a map in an item frame) leaves out the decorations that
/// do not show on frames.
pub(super) fn map_vertices(
&mut self,
id: i32,
show_only_frame: bool,
m: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
@@ -1158,14 +1168,18 @@ impl Game<'_> {
.get(&id)
.map(|m| m.decorations.clone())
.unwrap_or_default();
for (i, d) in decorations.iter().enumerate() {
let mut count = 0;
for d in &decorations {
if show_only_frame && !shown_on_frame(d.sprite) {
continue;
}
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
continue;
};
let dm = glam::Mat4::from_translation(glam::Vec3::new(
f32::from(d.x) / 2.0 + 64.0,
f32::from(d.y) / 2.0 + 64.0,
-0.02 - i as f32 * 0.001,
-0.02 - count as f32 * 0.001,
)) * glam::Mat4::from_rotation_z(
(f32::from(d.rot) * 360.0 / 16.0).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(4.0))
@@ -1187,6 +1201,7 @@ impl Game<'_> {
[p(-1.0, 1.0), p(1.0, 1.0), p(1.0, -1.0), p(-1.0, -1.0)],
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
);
count += 1;
}
}
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
@@ -1255,7 +1270,7 @@ impl Game<'_> {
}
}
if has_data {
self.map_vertices(id, m, light, out);
self.map_vertices(id, false, m, light, out);
}
}
}
+1 -6
View File
@@ -713,12 +713,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 {
+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);
+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| {
+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);
}
}
}
@@ -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,
@@ -456,6 +457,15 @@ impl Game<'_> {
self.player_elytra_geometry(m, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
// in its team by its profile name.
if self.potions.effects.get("glowing").is_some() {
let team = match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
};
self.mark_outline(&out[start..], super::glowing::team_color(team));
}
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
+28 -2
View File
@@ -53,7 +53,11 @@ impl Game<'_> {
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
// Entity.playEntityOnFireExtinguishedSound
self.extinguish_sound(at);
Ok(())
}
/// Entity.playEntityOnFireExtinguishedSound
fn extinguish_sound(&mut self, at: [f64; 3]) {
let r = self.particles.random();
let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4;
self.audio.play(
@@ -62,7 +66,29 @@ impl Game<'_> {
0.7,
pitch,
);
Ok(())
}
/// `Entity.applyEffectsFromBlocks`: a burning player in the rain
/// (`isInRain`) has the fire put out (`clearFire`), with the extinguish
/// sound that everyone near hears.
pub(super) fn rain_extinguish_player(&mut self) {
let Some(player) = self.player.as_ref() else {
return;
};
let (at, top) = (player.position, player.bounds().max_position().y);
if player.fire_ticks <= 0 || !self.in_rain(at, top) {
return;
}
if let Some(player) = self.player.as_mut() {
player.fire_ticks = 0;
}
self.extinguish_sound([at.x, at.y, at.z]);
}
/// The in-window player's rain extinguish (a server's player has its
/// fire put out by the server).
pub(super) fn rain_extinguish_in_frame(&mut self) {
if self.remote.is_none() {
self.rain_extinguish_player();
}
}
/// Whether the block at `pos` is powder snow.
pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool {
+21
View File
@@ -1036,6 +1036,7 @@ impl Game<'_> {
/// read it).
fn sync_villager_raids(&mut self, infos: &HashMap<i32, Vec<RaiderInfo>>) {
let raids = &self.raiding.raids;
let mut ongoing = Vec::new();
for m in &mut self.mobs {
if m.species() != Species::Villager {
continue;
@@ -1061,7 +1062,27 @@ impl Game<'_> {
let ext = mobs_villager::ext_mut(m);
ext.raid = status;
ext.raid_wave = wave;
if !m.no_ai && matches!(status, mobs_villager::PRE_RAID | mobs_villager::RAID) {
ongoing.push(m.id);
}
}
// Villager.customServerAiStep: a 1 in 100 chance each tick, in an
// ongoing raid, of entity event 42 (`handleEntityEvent` splashes).
for id in ongoing {
if self.particles.random().next_int_bound(100) == Ok(0) {
self.mob_raid_splash(id);
}
}
}
/// Entity event 42 for villager `id`: the guests get the byte, and this
/// game draws `AbstractVillager.addParticlesAroundSelf(SPLASH)` itself.
fn mob_raid_splash(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
self.lan_entity_event(id as i32, 42, at);
self.particles_around_mob(id, "splash", horizon_client::particles::Burst::VILLAGER);
}
/// The raid bar a living player at `at` is in (`Raid.getPlayers`): red,
+152 -25
View File
@@ -182,6 +182,9 @@ pub(super) struct Remote {
online_mode: bool,
/// Player entities in the level: entity id to profile UUID.
level_players: HashMap<i32, u128>,
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
@@ -468,6 +471,7 @@ impl Game<'_> {
listed: Default::default(),
online_mode: false,
level_players: HashMap::new(),
glowing: Default::default(),
profile: 0,
profile_name: String::new(),
destroying: None,
@@ -1466,7 +1470,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);
@@ -1777,6 +1783,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 +1906,10 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => m.invisible = flags & 0x20 != 0,
(0, Value::Byte(flags)) => {
m.invisible = flags & 0x20 != 0;
m.glowing = flags & 0x40 != 0;
}
(2, Value::OptText(name)) => {
let text = name.as_ref().map(horizon_client::entity_nbt::name_text);
m.custom_name =
@@ -1912,6 +1926,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 +1949,22 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
(7, Value::Int(ticks)) => m.ticks_frozen = *ticks,
// Strider.DATA_SUFFOCATING (after AgeableMob's two
// and DATA_BOOST_TIME): a cold strider shakes.
(19, Value::Bool(cold))
if m.species() == horizon_client::mobs::Species::Strider =>
{
m.extra.flag = *cold;
}
_ => {}
}
}
}
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: for another player
// too, remote_mob_data's apply reads index 8 above.
let mannequin = self
@@ -2024,13 +2063,22 @@ impl Game<'_> {
// player holds and wears (EquipmentSlot ordinals).
C::SetEquipment(p) => {
self.remote_object_equipment(p.id, &p.slots);
let items: Vec<(u8, Option<Arc<str>>)> = p
let items: Vec<(u8, Option<Arc<str>>, Option<ItemStack>)> = p
.slots
.iter()
.map(|(slot, stack)| (*slot, stack.as_ref().map(|s| self.item_name(s.item))))
.map(|(slot, stack)| {
(
*slot,
stack.as_ref().map(|s| self.item_name(s.item)),
self.to_stack(stack),
)
})
.collect();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
for (slot, item) in items {
// The body slot (6: horse armour, llama carpet, harness)
// is kept in `head`, as Horizon's own mobs do.
let body = horizon_client::mobs::drops::head_is_body(m.species());
for (slot, item, stack) in items {
let e = &mut m.extra;
match slot {
0 => e.main_hand = item,
@@ -2038,7 +2086,9 @@ impl Game<'_> {
2 => e.feet = item,
3 => e.legs = item,
4 => e.chest = item,
5 => e.head = item,
5 if !body => e.head = item,
6 if body => e.head = item,
7 => m.set_saddle(stack),
_ => {}
}
}
@@ -2147,6 +2197,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 +2211,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 {
@@ -3216,6 +3273,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 +3406,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 +3425,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 +3575,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 +3620,25 @@ 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;
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -3542,11 +3647,15 @@ impl Game<'_> {
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
let mut wither_heads = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
continue;
}
if m.species() == horizon_client::mobs::Species::Wither {
wither_heads.extend((0..3).map(|i| horizon_client::mobs::wither::head_pos(m, i)));
}
m.previous = m.position;
m.previous_yaw = m.yaw;
m.previous_body_yaw = m.body_yaw;
@@ -3604,6 +3713,10 @@ 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);
}
// 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);
@@ -3619,7 +3732,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,
@@ -3653,6 +3766,11 @@ 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);
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
@@ -3677,10 +3795,24 @@ impl Game<'_> {
d.tick(world, facts, collision, false, &self.res.motion().fluids);
}
head_lerps.retain(|_, (_, steps)| *steps > 0);
// WitherBoss.aiStep runs on a client too: smoke around each head.
let smoke = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Smoke,
);
for h in wither_heads {
let r = self.particles.random();
let at = [
h[0] + r.next_gaussian() * 0.3,
h[1] + r.next_gaussian() * 0.3,
h[2] + r.next_gaussian() * 0.3,
];
self.particles.add(&smoke, at, [0.0; 3]);
}
for (item, ticks, at, eye, pitch, yaw) in using {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
self.guardian_bubbles();
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
@@ -3935,6 +4067,9 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.destroying = Some((hit.pos, face, 0.0));
}
// startDestroyBlock plays the first hit and crack itself; the
// server's level events 2019 and 2020 do the rest.
self.breaking_block_effects(hit.pos, hit.face, true);
self.mining = Some(Mining {
pos: hit.pos,
progress: 0.0,
@@ -3947,20 +4082,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 +4249,7 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
} else if let Some(stack) = held.as_ref()
&& self.predict_item_on_block(stack, &hit)
{
@@ -5234,6 +5356,11 @@ impl Game<'_> {
batch.text(atlas, message, (w - tw) / 2.0, 85.0, u32::MAX, true);
}
}
/// Shared flag 6 of an entity on a server's client (`None` without a server).
pub(super) fn remote_glowing(&self, id: u32) -> Option<bool> {
let r = self.remote.as_ref()?;
Some(r.glowing.contains(&(id as i32)))
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (from the
/// player list).
@@ -4,7 +4,9 @@
//! 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, finalize, panda, sniffer, sync, tame,
};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::{Builtin, Dynamic};
@@ -17,10 +19,18 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_POSE: a player's or mannequin's box follows it.
(6, Value::Id(pose))
if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") =>
{
m.extra.avatar_pose = horizon_client::pose::Pose::from_id(*pose);
}
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
// off hand 2. The flag starts and stops the use; the client ticks
// the count itself (a bow's pull follows it).
@@ -64,10 +74,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;
@@ -94,6 +117,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)) => {
@@ -78,7 +78,27 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.velocity.y -= 0.05;
}
/// `AbstractHurtingProjectile.getTrailParticle`: smoke for every one of them
/// but the wind charges (no trail) and the dragon's fireball (its own).
pub(super) fn has_smoke_trail(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. }
)
}
impl Game<'_> {
/// `AbstractHurtingProjectile.createParticleTrail`: one smoke puff half a
/// block above each position.
pub(super) fn smoke_trails(&mut self, at: impl IntoIterator<Item = Vec3>) {
let smoke = ParticleOptions::simple(ParticleType::Smoke);
for p in at {
self.particles.add(&smoke, [p.x, p.y + 0.5, p.z], [0.0; 3]);
}
}
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
&mut self,
@@ -356,6 +376,14 @@ impl Game<'_> {
}
}
self.creatures.projectiles = projectiles;
let trails: Vec<Vec3> = self
.creatures
.projectiles
.iter()
.filter(|p| has_smoke_trail(&p.kind))
.map(|p| p.position)
.collect();
self.smoke_trails(trails);
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
@@ -458,7 +486,7 @@ impl Game<'_> {
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
self.particles.add_with(&options, at, velocity, false, true);
}
}
}
+11 -4
View File
@@ -245,6 +245,7 @@ impl Game<'_> {
1.0,
);
}
self.particles.particle_level = self.settings.particles;
if let Some(e) = eye {
self.particles.camera = e;
}
@@ -288,12 +289,14 @@ impl Game<'_> {
e[2].floor() as i32,
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
// The Horizon Weather option off means no rain visuals at all.
if rain > 0.0 && self.settings.horizon.weather {
// ClientLevel.tickWeatherEffects: rain splashes within the weather radius.
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = 10;
let radius = self.settings.weather_radius;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32
/ if self.settings.particles == 1 { 2 } else { 1 };
for _ in 0..count {
let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius;
let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius;
@@ -305,6 +308,10 @@ impl Game<'_> {
{
continue;
}
// The Minimal option stops at the first spot that rains.
if self.settings.particles >= 2 {
break;
}
let below = top - 1;
let (bx, bz) = (random.next_double(), random.next_double());
let state = world.block(x, below, z);
+38 -2
View File
@@ -1194,14 +1194,14 @@ impl Game<'_> {
/// `LightmapRenderStateExtractor`: the night vision effect's intensity
/// (`GameRenderer.nightVisionScale`), or conduit power's water vision.
pub(super) fn night_vision_intensity(&self) -> f32 {
pub(super) fn night_vision_intensity(&self, partial: f32) -> f32 {
use horizon_client::fog::FogType;
if let Some(e) = self.potions.effects.get("night_vision") {
// MobEffectInstance.endsWithin(200); -1 is infinite.
return if e.duration < 0 || e.duration > 200 {
1.0
} else {
0.7 + (e.duration as f32 * std::f32::consts::PI * 0.2).sin() * 0.3
0.7 + ((e.duration as f32 - partial) * std::f32::consts::PI * 0.2).sin() * 0.3
};
}
if self.potions.effects.get("conduit_power").is_some()
@@ -1515,6 +1515,16 @@ impl Game<'_> {
fn tick_entity_events(&mut self) {
self.tick_player_sculk_events();
self.tick_mob_sculk_events();
// Dropped items are entities too: Entity.applyEffectsFromBlocks.
let items: Vec<[f64; 3]> = self
.dropped
.iter()
.filter(|i| i.on_ground)
.map(|i| [i.position.x, i.position.y, i.position.z])
.collect();
for at in items {
self.step_on_redstone_ore(at, false);
}
}
/// Entity.move emits on the authoritative server too, for each ServerPlayer.
@@ -1530,6 +1540,9 @@ impl Game<'_> {
});
if let Some((at, on_ground, health, flying)) = player {
let crouch = self.keys.contains(&KeyCode::ShiftLeft);
if on_ground && !self.spectator() {
self.step_on_redstone_ore(at, crouch);
}
let in_water = self.in_water_at(at);
self.entity_motion_events(
Who::Player,
@@ -1548,6 +1561,26 @@ impl Game<'_> {
}
}
/// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the
/// entity is stepping carefully (`attack_block` only lights an unlit ore).
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) {
if carefully {
return;
}
// getOnPosLegacy: 0.2 below the feet.
let pos = [
at[0].floor() as i32,
(at[1] - 0.2).floor() as i32,
at[2].floor() as i32,
];
if matches!(
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
) {
let _ = self.attack_block(pos);
}
}
fn tick_mob_sculk_events(&mut self) {
let snapshot: Vec<(u32, [f64; 3], bool, bool, Species, Option<i64>)> = self
.mobs
@@ -1565,6 +1598,9 @@ impl Game<'_> {
})
.collect();
for (id, at, on_ground, in_water, species, damaged) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false);
}
let emits = !matches!(species, Species::Shulker);
if emits {
self.entity_motion_events(Who::Mob(id), at, on_ground, in_water, false, false);
@@ -546,6 +546,11 @@ impl Loading {
(batch.width, batch.height),
self.menu.modifiers.shift_key(),
);
title.press_rules(
&gui.atlas,
self.menu.cursor,
(batch.width, batch.height),
);
self.menu_action(event_loop, action);
}
}
+4 -4
View File
@@ -21,7 +21,7 @@ pub(super) struct ThrownTrident {
pub id: u32,
pub(super) stack: ItemStack,
pub(super) position: Vec3,
previous: Vec3,
pub(super) previous: Vec3,
pub(super) velocity: Vec3,
pub(super) yaw: f32,
pub(super) pitch: f32,
@@ -112,7 +112,7 @@ impl Game<'_> {
}
true
}
fn in_water_or_rain(&self) -> bool {
fn in_water_or_rain(&mut self) -> bool {
let Some(p) = &self.player else { return false };
let (x, y, z) = (
p.position.x.floor() as i32,
@@ -121,8 +121,8 @@ impl Game<'_> {
);
let water = self.res.facts().get(self.world.block(x, y, z)).fluid
== horizon_client::blocks::Fluid::Water;
let sky = self.world.light(x, y + 1, z) >> 4 == 15;
water || (self.world.weather.raining && sky)
let (at, top) = (p.position, p.bounds().max_position().y);
water || self.in_rain(at, top)
}
/// The living entities a spinning player can touch this tick, for
/// `LivingEntity.checkAutoSpinAttack` (none when not spinning).
@@ -94,6 +94,8 @@ impl WorldRequest {
level.difficulty = horizon_client::title::DIFFICULTIES[o.difficulty()].into();
level.allow_commands = Some(o.commands(creative));
horizon_client::save::write_world_gen_settings(&save_root, level.seed, o.world_gen())?;
// WorldCreationUiState.getGameRules, written with the level.
level.game_rules = o.rules.by_name();
}
// Worlds made without the Create New World screen (--world, the
// control socket) take its default: commands on for Creative only.
@@ -1,8 +1,9 @@
//! InWorldGameRulesScreen (AbstractGameRulesScreen), opened from World
//! Options: the rules by category under a search box, a switch or a number
//! box each, and Done sending the changed ones (ServerboundSetGameRulePacket).
//! Leaving with changes asks first (ConfirmScreen).
use horizon_client::{
//! AbstractGameRulesScreen: the rules by category under a search box, a
//! switch or a number box each. InWorldGameRulesScreen, opened from World
//! Options, sends the changed ones on Done (ServerboundSetGameRulePacket) and
//! asks before leaving with changes (ConfirmScreen); WorldCreationGameRulesScreen,
//! opened from Create New World, hands them back and leaves without asking.
use crate::{
confirm_screen::Confirm,
font::Font,
gamerules::{self, GameRules, RULES, Rule, Value},
@@ -32,14 +33,14 @@ enum Row {
/// What a click or Escape asks of World Options.
#[derive(Debug, PartialEq)]
pub(super) enum Exit {
pub enum Exit {
/// Done: the rules that changed.
Apply(Vec<(&'static str, Value)>),
/// Cancel, or discarding the changes.
Discard,
}
pub(super) struct RulesScreen {
pub struct RulesScreen {
search: EditBox,
values: GameRules,
initial: GameRules,
@@ -49,6 +50,8 @@ pub(super) struct RulesScreen {
scroll: ui::Scroll,
/// ConfirmScreen "Game Rule Changes" in its place.
confirm: bool,
/// InWorldGameRulesScreen rather than WorldCreationGameRulesScreen.
in_world: bool,
}
fn name_key(r: &Rule) -> String {
@@ -102,7 +105,15 @@ fn confirm(font: &Font, size: (f32, f32)) -> Confirm {
}
impl RulesScreen {
pub(super) fn new(rules: &GameRules) -> Self {
/// InWorldGameRulesScreen over the world's rules.
pub fn new(rules: &GameRules) -> Self {
Self::build(rules, true)
}
/// WorldCreationGameRulesScreen over the new world's rules.
pub fn for_creation(rules: &GameRules) -> Self {
Self::build(rules, false)
}
fn build(rules: &GameRules, in_world: bool) -> Self {
// setInitialFocus: the search box.
let search = EditBox::new(0.0, 0.0, SEARCH_WIDTH, 15.0);
let numbers = RULES
@@ -123,6 +134,7 @@ impl RulesScreen {
numbers,
scroll: ui::Scroll::default(),
confirm: false,
in_world,
}
}
@@ -197,9 +209,10 @@ impl RulesScreen {
})
.collect()
}
/// InWorldGameRulesScreen.onClose: pending changes ask first.
/// InWorldGameRulesScreen.onClose: pending changes ask first;
/// WorldCreationGameRulesScreen.onClose discards them.
fn close(&mut self) -> Option<Exit> {
if self.changes().is_empty() {
if !self.in_world || self.changes().is_empty() {
Some(Exit::Discard)
} else {
self.confirm = true;
@@ -207,7 +220,7 @@ impl RulesScreen {
}
}
/// Escape: ConfirmScreen answers no; the rules close.
pub(super) fn escape(&mut self) -> Option<Exit> {
pub fn escape(&mut self) -> Option<Exit> {
if self.confirm {
self.confirm = false;
None
@@ -216,7 +229,7 @@ impl RulesScreen {
}
}
/// The mouse wheel (AbstractSelectionList: half an entry a step).
pub(super) fn wheel(&mut self, delta: f32, h: f32) {
pub fn wheel(&mut self, delta: f32, h: f32) {
if !self.confirm {
let content = self.content();
self.scroll
@@ -224,20 +237,20 @@ impl RulesScreen {
}
}
/// A press on the scrollbar holds it (AbstractSelectionList.updateScrolling).
pub(super) fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
pub fn press_scrollbar(&mut self, cursor: (f32, f32), (w, h): (f32, f32)) -> bool {
let content = self.content();
self.scroll
.press(cursor, Self::bar(w), HEADER, h - FOOTER, content)
}
/// The pointer moved while the scrollbar is held.
pub(super) fn drag(&mut self, y: f32, h: f32) {
pub fn drag(&mut self, y: f32, h: f32) {
if self.scroll.held() {
let content = self.content();
self.scroll.drag(y, HEADER, h - FOOTER, content);
}
}
/// The button came up: the scrollbar lets go.
pub(super) fn release(&mut self) {
pub fn release(&mut self) {
self.scroll.release();
}
fn focused(&mut self) -> Option<(Option<&'static str>, &mut EditBox)> {
@@ -272,7 +285,7 @@ impl RulesScreen {
}
}
/// Run an edit on the focused box, then its responder.
pub(super) fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
pub fn edit(&mut self, f: impl FnOnce(&mut EditBox)) {
let Some((rule, b)) = self.focused() else {
return;
};
@@ -282,12 +295,12 @@ impl RulesScreen {
self.edited(rule);
}
}
pub(super) fn typing(&self) -> bool {
pub fn typing(&self) -> bool {
!self.confirm && (self.search.focused || self.numbers.iter().any(|(_, b)| b.focused))
}
/// A left click; Some when the screen closes.
pub(super) fn click(
pub fn click(
&mut self,
atlas: &HudAtlas,
cursor: (f32, f32),
@@ -355,12 +368,7 @@ impl RulesScreen {
}
}
/// Whether a click lands on a button (for its sound).
pub(super) fn clicks_button(
&self,
atlas: &HudAtlas,
cursor: (f32, f32),
size: (f32, f32),
) -> bool {
pub fn clicks_button(&self, atlas: &HudAtlas, cursor: (f32, f32), size: (f32, f32)) -> bool {
let (w, h) = size;
let on = |(x, y): (f32, f32)| ui::hovered(cursor, x, y, 150.0, 20.0);
if self.confirm {
@@ -377,9 +385,9 @@ impl RulesScreen {
})
}
pub(super) fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
pub fn draw(&mut self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
if self.confirm {
let labels = [
Text::translate("gui.yes", &[]),
@@ -393,7 +401,7 @@ impl RulesScreen {
self.scroll.clamp(HEADER, h - FOOTER, content);
// RuleList: the in-world list background, its entries clipped to it,
// then the separators and scrollbar.
ui::list_background(batch, atlas, HEADER, h - FOOTER, true);
ui::list_background(batch, atlas, HEADER, h - FOOTER, self.in_world);
let first = batch.vertices.len();
let hovered = self.hovered(rows.len(), cursor, (w, h));
let (left, x) = (row_left(w), control_x(w));
@@ -450,7 +458,7 @@ impl RulesScreen {
}
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::separators(batch, atlas, HEADER, h - FOOTER, true);
ui::separators(batch, atlas, HEADER, h - FOOTER, self.in_world);
ui::scrollbar(
batch,
atlas,
@@ -526,8 +534,8 @@ mod tests {
#[test]
fn box_colours_are_vanillas() {
assert_eq!(VALID, horizon_client::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, horizon_client::hud::rgba(255, 0, 0, 255));
assert_eq!(VALID, crate::hud::rgba(224, 224, 224, 255));
assert_eq!(INVALID, crate::hud::rgba(255, 0, 0, 255));
}
#[test]
+162 -21
View File
@@ -35,9 +35,13 @@ fn entity_shader() -> String {
#[cfg(test)]
mod crumbling_tests;
mod entity_batch;
mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{SHADOW_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY};
pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY,
};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -247,6 +251,8 @@ pub struct Frame<'a> {
pub outline: Vec<[f64; 6]>,
/// `BlockOutlineRenderState.isTranslucent`: draw after translucent terrain.
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -260,6 +266,9 @@ pub struct Frame<'a> {
pub nether_lighting: bool,
/// Camera-relative entity geometry textured from the shared atlas.
pub entities: &'a [Vertex],
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
/// outlined through walls after the hand.
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
pub weather: &'a [crate::weather::Vertex],
@@ -377,6 +386,7 @@ pub struct Renderer {
sky: wgpu::RenderPipeline,
dark_disc: wgpu::RenderPipeline,
lines: wgpu::RenderPipeline,
lines_secondary: wgpu::RenderPipeline,
/// Gizmo lines (`RenderTypes.linesTranslucentNoDepthWrite`) and the
/// always-on-top ones.
debug_lines: wgpu::RenderPipeline,
@@ -391,6 +401,7 @@ pub struct Renderer {
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
translucent_item_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
@@ -400,6 +411,8 @@ pub struct Renderer {
cloud_uniform: wgpu::Buffer,
cloud_group: wgpu::BindGroup,
additive_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.ENERGY_SWIRL`: additive, unlit, the sprite repeated within itself.
energy_swirl_pipeline: wgpu::RenderPipeline,
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
@@ -407,6 +420,8 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
entity_outline: entity_outline::EntityOutline,
panorama: panorama::Panorama,
/// `RenderPipelines.WORLD_BORDER` and its buffers.
border_pipeline: wgpu::RenderPipeline,
@@ -1213,12 +1228,6 @@ impl Renderer {
attributes: &instance_attributes,
}),
];
let line_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 24,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let alpha_blend = wgpu::BlendState::ALPHA_BLENDING;
let debug_line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
@@ -1351,13 +1360,25 @@ impl Renderer {
"Block outline",
"vs_line",
"fs_line",
&line_buffers,
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
true,
true,
Some(alpha_blend),
);
// RenderTypes.secondaryBlockOutline: the black stroke under a high contrast outline.
let lines_secondary = pipeline(
"Block outline secondary",
"vs_line",
"fs_line",
&debug_line_buffers,
wgpu::PrimitiveTopology::TriangleStrip,
None,
false,
true,
Some(alpha_blend),
);
let debug_lines = pipeline(
"Debug lines",
"vs_debug_line",
@@ -1531,7 +1552,7 @@ impl Renderer {
});
let outline = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Outline"),
size: 24 * MAX_OUTLINE_EDGES,
size: 32 * 2 * MAX_OUTLINE_EDGES,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -1837,6 +1858,19 @@ impl Renderer {
alpha_blend,
false,
);
// RenderPipelines.ITEM_TRANSLUCENT: blended, depth tested. Drawn before
// translucent terrain, it cannot write depth: the water behind a
// glass item would fail the test and vanish (vanilla sorts these in
// its order independent transparency pass instead).
let translucent_item_pipeline = entity_pipeline(
"Translucent items",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
alpha_blend,
false,
);
let cloud_pipeline = entity_pipeline(
"Clouds",
Some(wgpu::Face::Back),
@@ -1865,6 +1899,27 @@ impl Renderer {
alpha_blend,
false,
);
// RenderPipelines.ENERGY_SWIRL: BlendFunction.ADDITIVE (one, one), no culling; no depth
// writes either: vanilla sorts the quads far to near, so every face passes the test.
let one_one = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
};
let energy_swirl_pipeline = entity_pipeline_with(
"Energy swirls",
None,
false,
wgpu::CompareFunction::GreaterEqual,
Default::default(),
wgpu::BlendState {
color: one_one,
alpha: one_one,
},
false,
"fs_energy_swirl",
wgpu::ColorWrites::ALL,
);
let lightning_attributes = wgpu::vertex_attr_array![6=>Float32x3];
let lightning_vertex = [
entity_vertex[0].clone(),
@@ -2316,6 +2371,11 @@ impl Renderer {
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, config.format.remove_srgb_suffix());
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
config.format.remove_srgb_suffix(),
);
let panorama = panorama::Panorama::new(
&device,
&queue,
@@ -2361,6 +2421,7 @@ impl Renderer {
sky,
dark_disc,
lines,
lines_secondary,
debug_lines,
debug_lines_on_top,
debug_line_buffer,
@@ -2371,6 +2432,7 @@ impl Renderer {
weather,
crumbling_pipeline,
shadow_blob_pipeline,
translucent_item_pipeline,
water_mask_pipeline,
cloud_pipeline,
cloud_mesh: Default::default(),
@@ -2379,6 +2441,7 @@ impl Renderer {
cloud_uniform,
cloud_group,
additive_pipeline,
energy_swirl_pipeline,
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
@@ -2386,6 +2449,7 @@ impl Renderer {
celestial_buffer,
end_sky: end_sky::EndSky::default(),
stars,
entity_outline,
panorama,
border_pipeline,
border_group,
@@ -2482,6 +2546,18 @@ impl Renderer {
present_modes,
})
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
pass.set_vertex_buffer(0, self.outline.slice(..));
if high_contrast {
pass.set_pipeline(&self.lines_secondary);
pass.draw(0..4, 0..edges);
}
pass.set_pipeline(&self.lines);
let first = if high_contrast { edges } else { 0 };
pass.draw(0..4, first..first + edges);
}
fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
let indices: Vec<u32> = (0..quads)
.flat_map(|q| {
@@ -3954,7 +4030,26 @@ impl Renderer {
);
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
let lines = outline_vertices(&frame.outline[..outline_edges], camera);
// The same per-line colour and pixel width layout as the gizmo lines. A high
// contrast outline is two strokes: the black one first, the bright one after it.
let edges = outline_vertices(&frame.outline[..outline_edges], camera);
let width = outline_width(self.config.width);
let stroke = |color: u32, width: f32| {
edges.iter().map(move |e| {
let mut line = [0.0; 8];
line[..6].copy_from_slice(e);
line[6] = f32::from_bits(color);
line[7] = width;
line
})
};
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
.collect()
} else {
stroke(0x6600_0000, width).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
}
@@ -3979,8 +4074,10 @@ impl Renderer {
crumbling_quads,
translucent_crumbling_quads,
water_mask_quads,
swirl_quads,
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
@@ -3989,8 +4086,10 @@ impl Renderer {
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads
+ swirl_quads
+ shadow_quads
+ translucent_particle_quads;
+ translucent_particle_quads
+ translucent_item_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4051,6 +4150,13 @@ impl Renderer {
bytemuck::cast_slice(all_entities),
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
&mut encoder,
frame.glowing,
[self.config.width, self.config.height],
);
if let Some((rotation, brightness)) = frame.stars {
self.stars.prepare(
&mut self.uploads,
@@ -4078,7 +4184,8 @@ impl Renderer {
} else {
lightning::QUADS
})
.max(weather_quads);
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -4513,6 +4620,17 @@ impl Renderer {
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
self.weather.draw(&mut pass, &self.entity_group);
}
@@ -4530,7 +4648,11 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - translucent_particle_quads - shadow_quads) * 4) as i32,
((world_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
* 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4554,6 +4676,21 @@ impl Renderer {
self.lightning.draw(&mut pass, &frame.lightning);
pass.set_bind_group(0, &self.group, &[]);
}
if swirl_quads > 0 {
pass.set_pipeline(&self.energy_swirl_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..swirl_quads * 6,
((entity_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads)
* 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 {
pass.set_pipeline(&self.additive_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -4562,9 +4699,7 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
if outline_edges > 0 && !frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
if debug_depth > 0 {
pass.set_pipeline(&self.debug_lines);
@@ -4611,7 +4746,8 @@ impl Renderer {
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_particle_quads) * 4) as i32,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -4619,9 +4755,7 @@ impl Renderer {
// LevelRenderer.executeClassicTransparency: translucent targets'
// outlines follow terrain, so its blending cannot wash them out.
if outline_edges > 0 && frame.outline_after_terrain {
pass.set_pipeline(&self.lines);
pass.set_vertex_buffer(0, self.outline.slice(..));
pass.draw(0..4, 0..outline_edges as u32);
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
@@ -4808,6 +4942,13 @@ impl Renderer {
0..1,
);
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
&mut encoder,
&view_target,
&self.entity_group,
&self.indices,
);
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5240,7 +5381,7 @@ mod tests {
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0, 1, 1]);
assert_eq!(counts, [4, 1, 1, 0, 0, 1, 1, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
+21 -7
View File
@@ -3,16 +3,27 @@ use super::Vertex;
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`) and translucent-layer particles (15,
/// drawn after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`).
const MATERIALS: usize = 6;
/// the boat water mask (13, `RenderPipelines.WATER_MASK`), energy swirls
/// (34, `RenderPipelines.ENERGY_SWIRL`), entity shadows (14,
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 8;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
/// powered wither's armour). The light bytes carry the sprite's origin and the colour bytes its
/// size (`breaking::stage_light`), the UV the scrolled position in the sprite.
pub const ENERGY_SWIRL_OVERLAY: u32 = 34;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
/// The overlay of translucent-layer particles (`SingleQuadParticle.Layer.translucent`).
pub const TRANSLUCENT_PARTICLE_OVERLAY: u32 = 15;
/// The overlay of item quads with translucent pixels (`Sheets.translucentItemSheet`,
/// `RenderPipelines.ITEM_TRANSLUCENT`); past the creeper swell range (16 to 31).
pub const TRANSLUCENT_ITEM_OVERLAY: u32 = 32;
/// A translucent item with an enchantment glint.
pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -43,8 +54,10 @@ impl EntityBatch {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
TRANSLUCENT_PARTICLE_OVERLAY => 5,
ENERGY_SWIRL_OVERLAY => 4,
SHADOW_OVERLAY => 5,
TRANSLUCENT_PARTICLE_OVERLAY => 6,
TRANSLUCENT_ITEM_OVERLAY | TRANSLUCENT_GLINT_ITEM_OVERLAY => 7,
_ => 0,
};
self.buckets[material][counts[material]] = index;
@@ -96,12 +109,13 @@ mod tests {
) -> (Vec<Vertex>, [u32; MATERIALS]) {
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
let mut counts = [0; MATERIALS];
for (i, layer) in [0, 11, 12, 13, 14, 15].into_iter().enumerate() {
for (i, layer) in [0, 11, 12, 13, 34, 14, 15].into_iter().enumerate() {
for quad in entities.as_chunks::<4>().0 {
let material = match quad[0].layer {
11 => 11,
12 => 12,
13 => 13,
34 => 34,
14 => 14,
15 => 15,
_ => 0,
@@ -0,0 +1,311 @@
//! The entity outline (`LevelRenderer.entityOutlineTarget`): the glowing entities'
//! silhouettes in their team colours, drawn without depth so walls do not hide them
//! (`RenderTypes.outline`), the `entity_outline` post chain over them (`entity_sobel`, then
//! `entity_outline_box_blur` across and up), and `LevelRenderer.blitEntityOutline` onto
//! the frame after the hand.
use super::Vertex;
/// `entityOutlineTarget`'s `GpuFormat.RGBA8_UNORM`.
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
struct Targets {
size: [u32; 2],
/// `entityOutlineTarget`.
outline: wgpu::TextureView,
/// The chain's `swap` target.
swap: wgpu::TextureView,
/// What each pass reads: the outline (nearest), swap (bilinear), the outline
/// (bilinear), swap (nearest).
groups: [wgpu::BindGroup; 4],
}
pub(super) struct EntityOutline {
silhouette: wgpu::RenderPipeline,
/// Sobel, blur across, blur up (into `FORMAT`) and the blended blit (onto the frame).
passes: [wgpu::RenderPipeline; 4],
layout: wgpu::BindGroupLayout,
nearest: wgpu::Sampler,
bilinear: wgpu::Sampler,
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
targets: Option<Targets>,
}
impl EntityOutline {
/// `entity` draws the silhouettes with the entity shader's vertex stage and
/// `fs_outline`; `format` is the frame's.
pub(super) fn new(
device: &wgpu::Device,
entity: (
&wgpu::ShaderModule,
&wgpu::PipelineLayout,
&[Option<wgpu::VertexBufferLayout>],
),
format: wgpu::TextureFormat,
) -> Self {
let (entity_shader, entity_layout, entity_vertex) = entity;
// RenderPipelines.OUTLINE_NO_CULL: no depth test, the colour replaced.
let silhouette = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Entity outline silhouettes"),
layout: Some(entity_layout),
vertex: wgpu::VertexState {
module: entity_shader,
entry_point: Some("vs_entity"),
compilation_options: Default::default(),
buffers: entity_vertex,
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: entity_shader,
entry_point: Some("fs_outline"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity outline"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_outline.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Entity outline bindings"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Entity outline"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pass = |entry: &str, target: wgpu::ColorTargetState| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(entry),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_quad"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
compilation_options: Default::default(),
targets: &[Some(target)],
}),
multiview_mask: None,
cache: None,
})
};
// BlendFunction.ENTITY_OUTLINE_BLIT over the colour only (write mask 7).
let blit = wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::COLOR,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
Self {
silhouette,
passes: [
pass("fs_sobel", FORMAT.into()),
pass("fs_blur_x", FORMAT.into()),
pass("fs_blur_y", FORMAT.into()),
pass("fs_blit", blit),
],
layout,
nearest: sampler("Entity outline nearest", wgpu::FilterMode::Nearest),
bilinear: sampler("Entity outline bilinear", wgpu::FilterMode::Linear),
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
targets: None,
}
}
fn vertex_buffer(device: &wgpu::Device, capacity: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity outline vertices"),
size: capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
return;
}
let vertices = silhouettes.len() as u64;
if vertices > self.capacity {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
let texture = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
};
let outline = texture("Entity outline");
let swap = texture("Entity outline swap");
let group = |view: &wgpu::TextureView, sampler: &wgpu::Sampler| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity outline pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
};
let groups = [
group(&outline, &self.nearest),
group(&swap, &self.bilinear),
group(&outline, &self.bilinear),
group(&swap, &self.nearest),
];
self.targets = Some(Targets {
size,
outline,
swap,
groups,
});
}
/// `LevelRenderer.executeOutline`, the post chain and `blitEntityOutline` onto `frame`.
pub(super) fn draw(
&self,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
{
let mut pass = begin(encoder, "Entity outline", &t.outline, clear);
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
let steps = [
("Entity outline sobel", &t.swap, clear),
("Entity outline blur across", &t.outline, clear),
("Entity outline blur up", &t.swap, clear),
("Entity outline blit", frame, wgpu::LoadOp::Load),
];
for (i, (label, view, load)) in steps.into_iter().enumerate() {
let mut pass = begin(encoder, label, view, load);
pass.set_pipeline(&self.passes[i]);
pass.set_bind_group(0, &t.groups[i], &[]);
pass.draw(0..3, 0..1);
}
}
}
fn begin<'e>(
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
load: wgpu::LoadOp<wgpu::Color>,
) -> wgpu::RenderPass<'e> {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})];
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &attachments,
..Default::default()
})
}
@@ -0,0 +1,60 @@
// The entity_outline post chain (post_effect/entity_outline.json) over the silhouettes of
// the glowing entities, then RenderTarget.blitAndBlendToTexture onto the frame.
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var source_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
// screenquad.vsh, with v running down the rows as wgpu's textures do.
@vertex fn vs_quad(@builtin(vertex_index) i: u32) -> PostOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: PostOut;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
fn tap(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(source, source_sampler, uv, 0.0);
}
// entity_sobel.fsh: alpha marks where coverage changes, the colour is the mean of the
// covered texels around.
@fragment fn fs_sobel(in: PostOut) -> @location(0) vec4<f32> {
let texel = 1.0 / vec2<f32>(textureDimensions(source));
let center = tap(in.uv);
let left = tap(in.uv - vec2(texel.x, 0.0));
let right = tap(in.uv + vec2(texel.x, 0.0));
let up = tap(in.uv - vec2(0.0, texel.y));
let down = tap(in.uv + vec2(0.0, texel.y));
let total = clamp(abs(center.a - left.a) + abs(center.a - right.a)
+ abs(center.a - up.a) + abs(center.a - down.a), 0.0, 1.0);
let color = center.rgb * center.a + left.rgb * left.a + right.rgb * right.a
+ up.rgb * up.a + down.rgb * down.a;
return vec4(color * 0.2, total);
}
// entity_outline_box_blur.fsh at Radius 2: bilinear taps at -1.5 and 0.5 texels and half
// a tap at 2; the colour averaged, the alpha summed.
fn blur(uv: vec2<f32>, step: vec2<f32>) -> vec4<f32> {
let blurred = tap(uv + step * -1.5) + tap(uv + step * 0.5) + tap(uv + step * 2.0) / 2.0;
return vec4(blurred.rgb / 2.5, blurred.a);
}
// BlurDir (1, 0).
@fragment fn fs_blur_x(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(1.0 / f32(textureDimensions(source).x), 0.0));
}
// BlurDir (0, 1): up the screen in GL's bottom-up textures, towards row 0 here.
@fragment fn fs_blur_y(in: PostOut) -> @location(0) vec4<f32> {
return blur(in.uv, vec2(0.0, -1.0 / f32(textureDimensions(source).y)));
}
// blit_screen.fsh, blended by RenderPipelines.ENTITY_OUTLINE_BLIT.
@fragment fn fs_blit(in: PostOut) -> @location(0) vec4<f32> {
return tap(in.uv);
}
@@ -405,12 +405,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
}
/// Whether a run holds only geometry a gbuffers program can draw from the shared atlas: no
/// celestial body, cloud, beam, End portal or crack (overlay codes Horizon's entity shader
/// draws in its own way; entity.wgsl).
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12))
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
}
/// The programs of entity runs, in the order they draw.
+13
View File
@@ -204,6 +204,19 @@ impl Gizmos {
position[1] + f64::from(size.1),
position[2] + half,
];
self.entity_box(min, max, position, eye_height, living, view);
}
/// The same for an entity whose box is not centred on its position
/// (a painting or an item frame): `min` and `max` are the box.
pub fn entity_box(
&mut self,
min: [f64; 3],
max: [f64; 3],
position: [f64; 3],
eye_height: f32,
living: bool,
view: (f32, f32),
) {
self.cuboid(min, max, WHITE, STROKE_WIDTH);
if living {
let eye = min[1] + f64::from(eye_height);
+2
View File
@@ -912,6 +912,8 @@ impl HudBatch {
// StringDecomposer.iterateFormatted inside Font.drawInternal:
// `§` codes inside a span restyle what follows and are not drawn.
let text = text.resolved();
// Language.getVisualOrder: shaped and reordered for right-to-left text.
let text = crate::bidi::visual(&text);
let font = &atlas.font;
let alpha = (color >> 24) as u8;
let mut glyphs = Vec::new();
+39 -2
View File
@@ -46,6 +46,9 @@ pub struct ItemQuad {
pub foil: Foil,
/// `MaterialInfo.lightEmission` (block light floor).
pub light_emission: u8,
/// The quad's sprite has translucent pixels (`MaterialInfo.itemRenderType`
/// is a translucent sheet): drawn with the translucent entities.
pub translucent: bool,
}
/// Which glint render type a quad uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -159,7 +162,7 @@ impl ItemRenderState {
let m = pose * layer.matrix;
match &layer.geometry {
LayerGeometry::Quads(baked) => {
submit_quads(baked, layer, self.display, m, sprites, sink);
submit_quads(renderer, baked, layer, self.display, m, sprites, sink);
}
LayerGeometry::Special { model, argument } => special::submit(
renderer,
@@ -203,6 +206,7 @@ pub fn argb(color: i32) -> u32 {
/// `ItemFeatureRenderer.prepareMainSubmit` for one cuboid layer.
fn submit_quads(
renderer: &ItemRenderer,
baked: &BakedItemModel,
layer: &LayerRender,
display: DisplayContext,
@@ -216,6 +220,8 @@ fn submit_quads(
FoilType::Special => Foil::Special,
};
let _ = display;
// Quads of one layer mostly share a sprite: look its translucency up once.
let mut last: Option<(&str, bool)> = None;
for q in &baked.quads {
let Some(r) = sprites
.get(sprite_name(&q.material.sprite))
@@ -239,6 +245,16 @@ fn submit_quads(
let pos = q.positions.map(|p| m.transform_point3(Vec3::from(p)));
let uv =
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v]);
let translucent = match last {
Some((sprite, translucent)) if sprite == q.material.sprite => translucent,
_ => {
let translucent = renderer
.sprites
.translucent(sprite_name(&q.material.sprite));
last = Some((&q.material.sprite, translucent));
translucent
}
};
sink.quad(ItemQuad {
pos,
uv,
@@ -246,6 +262,7 @@ fn submit_quads(
color: argb(color),
foil,
light_emission: q.light_emission,
translucent: translucent || q.material.force_translucent,
});
}
}
@@ -260,6 +277,9 @@ struct FileSprites {
root: PathBuf,
permutations: Vec<PalettedPermutations>,
cache: Mutex<HashMap<String, Option<SpriteMask>>>,
/// Sprites with a pixel that is neither fully transparent nor opaque
/// (`Transparency.hasTranslucent`), filled in as sprites load.
translucent: Mutex<HashMap<String, bool>>,
}
impl SpriteLookup for FileSprites {
fn mask(&self, sprite: &str) -> Option<SpriteMask> {
@@ -283,6 +303,13 @@ fn argb_pixels(rgba: &[u8]) -> Vec<u32> {
.collect()
}
impl FileSprites {
fn translucent(&self, sprite: &str) -> bool {
let known = |this: &Self| this.translucent.lock().ok()?.get(sprite).copied();
known(self).unwrap_or_else(|| {
self.mask(sprite);
known(self).unwrap_or(false)
})
}
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
crate::atlas_sources::texture_path(&self.root, sprite, directory)
}
@@ -303,6 +330,10 @@ impl FileSprites {
(w, h, pixels)
}
};
let translucent = argb.iter().any(|p| matches!(p >> 24, 1..=254));
if let Ok(mut cache) = self.translucent.lock() {
cache.insert(sprite.to_owned(), translucent);
}
let alpha: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let animation = fs::read_to_string(png.with_extension("png.mcmeta"))
.ok()
@@ -452,6 +483,7 @@ impl ItemRenderer {
root: root.to_path_buf(),
permutations,
cache: Mutex::default(),
translucent: Mutex::default(),
},
baked: Mutex::default(),
extents: Mutex::default(),
@@ -694,7 +726,12 @@ impl ItemSink for WorldSink<'_> {
} else {
self.light
};
let overlay = if q.foil == Foil::None { 0 } else { 7 };
let overlay = match (q.foil, q.translucent) {
(Foil::None, true) => crate::gfx::TRANSLUCENT_ITEM_OVERLAY,
(Foil::None, false) => 0,
(_, true) => crate::gfx::TRANSLUCENT_GLINT_ITEM_OVERLAY,
(_, false) => 7,
};
for (p, uv) in q.pos.iter().zip(q.uv) {
self.out.push(Vertex {
normal: [0; 4],
@@ -358,6 +358,7 @@ mod tests {
color,
foil: Foil::None,
light_emission: 0,
translucent: false,
}
}
@@ -119,6 +119,7 @@ fn draw(
color,
foil,
light_emission: 0,
translucent: false,
});
}
for (child_name, child) in &part.children {
+40 -2
View File
@@ -2,7 +2,12 @@
//! player state (inventory, health, game mode) that the host's gameplay code
//! operates on when it acts for them.
use super::{Joined, PlayerSender, entity::Tracker};
use crate::{items::ItemStack, motion::PlayerMotion};
use crate::{
items::ItemStack,
motion::PlayerMotion,
pose::{Dimensions, Pose},
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
item::Stack,
packets::{configuration::ClientInformation, play},
@@ -407,8 +412,41 @@ impl Guest {
}
f
}
/// `Player.getDesiredPose`: sleeping, gliding, then the body's own pose
/// (swimming, crawling, crouching) once its server tick has run.
pub fn pose(&self) -> Pose {
if self.sleeping {
Pose::Sleeping
} else if self.fall_flying {
Pose::FallFlying
} else if let Some(m) = &self.motion {
m.pose
} else if self.sneaking && !self.flying {
Pose::Crouching
} else {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
}
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
let d = self.dimensions();
let half = f64::from(d.width) / 2.0;
Aabb::new(
Vec3::new(x - half, y, z - half),
Vec3::new(x + half, y + f64::from(d.height), z + half),
)
}
pub fn eye_height(&self) -> f64 {
if self.sneaking { 1.27 } else { 1.62 }
f64::from(self.dimensions().eye_height)
}
/// `Player.isWithinBlockInteractionRange(pos, buffer)` for the guest:
/// its block interaction range is 4.5, 5 in creative.
+2 -12
View File
@@ -2329,18 +2329,8 @@ impl Hub {
}
// getBoundingBox: the box of the body's pose (swimming and crawling
// are 0.6 tall), so a swimmer under a ceiling isn't sent back.
let (half, height) = if g.fall_flying {
(0.3, 0.6)
} else if let Some(m) = &g.motion {
(
f64::from(m.dimensions.width) / 2.0,
f64::from(m.dimensions.height),
)
} else if g.sneaking {
(0.3, 1.5)
} else {
(0.3, 1.8)
};
let d = g.dimensions();
let (half, height) = (f64::from(d.width) / 2.0, f64::from(d.height));
let at = |p: [f64; 3]| {
Aabb::new(
Vec3::new(p[0] - half, p[1], p[2] - half),
+2
View File
@@ -15,6 +15,7 @@ pub mod auto_jump;
pub mod banner;
pub mod beehive;
pub mod benchmark;
pub mod bidi;
pub mod biome;
pub mod block_entities;
pub mod block_entity_anim;
@@ -74,6 +75,7 @@ pub mod fog;
pub mod font;
pub mod friends_screen;
pub mod game_mode_switcher;
pub mod game_rules_screen;
pub mod gamepad;
pub mod gfx;
pub mod gizmos;
+86 -1
View File
@@ -11,6 +11,7 @@ pub mod ambient;
pub mod anim;
pub mod animal_brain;
pub mod aquatic;
pub mod armadillo;
pub mod axolotl;
pub mod bat;
pub mod bee;
@@ -31,8 +32,11 @@ pub mod enderman;
pub mod equine;
pub mod farm;
pub mod finalize;
pub mod fox;
pub mod frog;
pub mod gear;
pub mod goals;
pub mod goat;
pub mod golem;
pub mod guardian;
pub mod happy_ghast;
@@ -47,6 +51,7 @@ pub mod monster_brain;
pub mod nautilus;
pub mod nav;
pub mod nether;
pub mod panda;
pub mod path;
pub mod phantom;
pub mod piglin;
@@ -66,6 +71,7 @@ pub mod spawning;
pub mod species;
pub mod strider;
pub mod sulfur_cube;
pub mod sync;
pub mod tame;
pub mod trader;
pub mod transport_items;
@@ -527,6 +533,8 @@ pub enum DamageKind {
InWall,
Thorns,
SonicBoom,
/// `freeze`: a fully frozen mob in powder snow.
Freeze,
/// `fell_out_of_world`: below the world's floor.
OutOfWorld,
}
@@ -596,6 +604,7 @@ impl Damage {
| DamageKind::InWall
| DamageKind::Generic
| DamageKind::SonicBoom
| DamageKind::Freeze
| DamageKind::OutOfWorld
)
}
@@ -620,6 +629,7 @@ impl Damage {
| DamageKind::Cactus
| DamageKind::Generic
| DamageKind::InWall
| DamageKind::Freeze
| DamageKind::OutOfWorld
)
}
@@ -646,6 +656,9 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// A player's or mannequin's synced pose (`Entity.DATA_POSE`), which
/// sets its box (`Avatar.POSES`).
pub avatar_pose: crate::pose::Pose,
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
pub sprinting: bool,
pub passenger: bool,
@@ -788,12 +801,16 @@ pub struct Extra {
/// A goat's `IsScreamingGoat` and the horns it lost (left, right).
pub screaming: bool,
pub lost_horns: [bool; 2],
/// A goat's lowered head and ram behaviours.
pub goat: goat::State,
/// Attribute bases and permanent modifiers set by `finalizeSpawn`.
pub attributes: Option<Box<finalize::Attributes>>,
/// Brain memories `finalizeSpawn` sets: (memory, value, ticks to expiry).
pub memories: Vec<(&'static str, i64, i64)>,
/// A camel's `LAST_POSE_CHANGE_TICK`.
pub pose_change_tick: i64,
/// `Entity.DATA_POSE` as a mob of a server has it (`sync::pose_id`).
pub pose: u8,
/// The school leader a fish follows, and a tropical fish that swims alone.
pub leader: Option<u32>,
pub lone: bool,
@@ -801,6 +818,12 @@ pub struct Extra {
pub brain: Option<Box<monster_brain::Brain>>,
/// A bee's nectar, anger, flower and hive state.
pub bee: Option<Box<bee::Bee>>,
/// An armadillo's state and scare brain.
pub armadillo: armadillo::State,
/// A frog's brain memories and tongue target.
pub frog: frog::State,
/// A panda's flags, counters and amounts.
pub panda: Option<Box<panda::State>>,
}
#[derive(Clone, Debug)]
pub struct Mob {
@@ -903,6 +926,18 @@ pub enum MobEvent {
mob: u32,
at: [f64; 3],
},
/// `LivingEntity.baseTick`'s drowning damage: entity event 67, the
/// clients' `makeDrownParticles`.
Drown {
mob: u32,
at: [f64; 3],
},
/// `Level.broadcastEntityEvent` for a mob: guests run `sync::entity_event`.
EntityEvent {
mob: u32,
event: i8,
at: [f64; 3],
},
Sound(&'static str, [f64; 3], f32, f32),
/// `ChestBlockEntity.startOpen` / `stopOpen` by a copper golem: the
/// chest's opener count goes up or down (interaction range 3).
@@ -1010,6 +1045,13 @@ pub enum MobEvent {
amount: f32,
source: Damage,
},
/// `LivingEntity.knockback(power, x, z)`: pushed away from the direction.
Knockback {
target: Who,
power: f64,
x: f64,
z: f64,
},
/// Set another mob's attack target (HurtByTargetGoal alerts).
SetTarget {
mob: u32,
@@ -1244,6 +1286,10 @@ pub enum MobEvent {
center: [f64; 3],
spread: [f64; 3],
},
/// `Panda.afterSneeze`: the grown pandas within 10 blocks start.
PandaSneezed {
mob: u32,
},
}
/// How a spawned-by-kind mob should be set up.
#[derive(Clone, Debug, Default)]
@@ -1281,6 +1327,8 @@ pub struct Seen {
pub target: Option<Who>,
pub health: f32,
pub variant: u8,
/// A slime's or magma cube's size.
pub size: i32,
/// Dye colour (sheep).
pub color: u8,
/// A villager's claimed job site and bed.
@@ -1348,6 +1396,10 @@ impl Mob {
.max((f64::from(ticks) * self.burning_time()).ceil() as i32);
self.ticks_frozen = 0;
}
/// `Entity.isFullyFrozen`.
pub fn is_fully_frozen(&self) -> bool {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
}
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by).
@@ -1376,6 +1428,7 @@ impl Mob {
target: m.ai.target,
health: m.health,
variant: m.extra.variant,
size: m.extra.size,
color: m.extra.color,
job: villager.then_some(m.extra.home).flatten(),
bed: villager.then_some(m.extra.bed).flatten(),
@@ -1554,6 +1607,9 @@ pub struct PlayerView {
pub last_hurt: Option<(Who, i64)>,
pub velocity: Vec3,
pub yaw: f32,
/// `Player.isSprinting` and `isPassenger` (what scares an armadillo).
pub sprinting: bool,
pub riding: bool,
/// The Hero of the Village effect.
pub hero: bool,
/// The active effects, without namespace (`slowness`, `poison`...).
@@ -1578,6 +1634,8 @@ pub struct MobWorld<'a> {
pub world: &'a World,
pub registry: &'a horizon_world::registry::BlockRegistry,
pub facts: &'a BlockFacts,
/// Fluid states and flow facts, for the currents that push mobs.
pub fluid_tables: &'a crate::motion::FluidResources,
pub movement: &'a horizon_world::movement_properties::BoundMovementProperties,
pub collision: &'a BlockCollisionRegistry<'a>,
pub data: &'a MobData,
@@ -1820,12 +1878,24 @@ impl Mob {
return false;
}
let mut amount = if reflected { 1000.0 } else { amount };
if self.species() == Species::Armadillo {
amount = armadillo::reduce_damage(self, amount);
}
if source.kind == DamageKind::FallingBlock
&& !species::body_slot(self.species())
&& self.extra.head.is_some()
{
amount *= 0.75;
}
// LivingEntity.hurtServer: `#freeze_hurts_extra_types` take five times.
if source.kind == DamageKind::Freeze
&& matches!(
self.species(),
Species::Strider | Species::Blaze | Species::MagmaCube
)
{
amount *= 5.0;
}
let (worn, worn_toughness) = self.worn_armor();
if !source.bypasses_armor() && self.kind.armor + worn > 0.0 {
// CombatRules.getDamageAfterAbsorb
@@ -1968,6 +2038,11 @@ impl Mob {
pub fn has_effect(&self, effect: &str) -> bool {
self.extra.effects.iter().any(|e| e.0 == effect)
}
/// `LivingEntity.isCurrentlyGlowing`: the Glowing effect or the `Glowing` tag (on a
/// server's client, `glowing` holds shared flag 6 instead).
pub fn is_currently_glowing(&self) -> bool {
self.glowing || self.has_effect("minecraft:glowing")
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
// MobEffectInstance.update: a stronger effect replaces the old one
@@ -1999,8 +2074,18 @@ impl Mob {
}
/// LivingEntity.getVoicePitch.
pub fn voice_pitch(&mut self) -> f32 {
// HappyGhast.getVoicePitch: always 1.0, babies and all.
if self.species() == Species::HappyGhast {
return 1.0;
}
// ZombieVillager.getVoicePitch: babies are 2.0, not 1.5.
let baby = if self.species() == Species::ZombieVillager {
2.0
} else {
1.5
};
let pitch = (self.random.next_float() - self.random.next_float()) * 0.2
+ if self.is_baby() { 1.5 } else { 1.0 };
+ if self.is_baby() { baby } else { 1.0 };
// Bat.getVoicePitch: a little lower.
if self.species() == Species::Bat {
pitch * 0.95
+149 -20
View File
@@ -106,6 +106,7 @@ pub enum Family {
Strider,
Bat,
Bee,
Panda,
PolarBear,
}
pub fn family(s: Species) -> Family {
@@ -168,6 +169,7 @@ pub fn family(s: Species) -> Family {
S::Strider => Family::Strider,
S::Bat => Family::Bat,
S::Bee => Family::Bee,
S::Panda => Family::Panda,
S::PolarBear => Family::PolarBear,
_ => Family::Generic,
}
@@ -180,6 +182,9 @@ pub fn register(m: &mut Mob) {
Family::Undead => undead::register(m),
Family::Hostile => hostile::register(m),
Family::Aquatic => aquatic::register(m),
Family::Generic if m.species() == Species::Armadillo => super::armadillo::register(m),
Family::Generic if m.species() == Species::Frog => super::frog::register(m),
Family::Generic if m.species() == Species::Fox => super::fox::register(m),
Family::Generic => {
generic(m);
if m.species() == Species::Rabbit {
@@ -188,6 +193,9 @@ pub fn register(m: &mut Mob) {
if m.species() == Species::Turtle {
super::turtle::register(m);
}
if m.species() == Species::Goat {
super::goat::register(m);
}
}
Family::Inert => {}
Family::Witch => super::witch::register(m),
@@ -229,6 +237,7 @@ pub fn register(m: &mut Mob) {
Family::Strider => super::strider::register(m),
Family::Bat => super::bat::register(m),
Family::Bee => super::bee::register(m),
Family::Panda => super::panda::register(m),
Family::PolarBear => super::polar_bear::register(m),
}
// FloatGoal's constructor lets the navigation float, as do the
@@ -411,7 +420,10 @@ pub fn tick(
}
// Mob.tickHeadTurn -> BodyRotationControl.clientTick (a frozen
// creaking's waits).
if m.alive() && (!super::creaking::is_creaking(m) || super::creaking::can_move(m)) {
if m.alive()
&& (!super::creaking::is_creaking(m) || super::creaking::can_move(m))
&& !super::armadillo::scared(m)
{
m.body_rotation_tick();
}
events.append(&mut m.outbox);
@@ -433,6 +445,9 @@ pub fn tick(
if m.species() == Species::Bee {
super::bee::tick(m, level_random);
}
if m.species() == Species::Panda {
super::panda::tick(m, w, events);
}
true
}
/// LivingEntity.updateSwingTime (6-tick swing duration).
@@ -448,6 +463,16 @@ pub fn tick_swing(m: &mut Mob) {
}
m.anim.attack_anim = m.ai.swing_time as f32 / 6.0;
}
/// `Entity.isInRain`: rain at the block position, or at the top of the box.
pub(super) fn in_rain(m: &Mob, w: &MobWorld) -> bool {
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
w.raining_at(x, y, z) || w.raining_at(x, top, z)
}
/// `LivingEntity.baseTick` refills a breather's air by 4, then
/// `Axolotl.handleAirSupply` (unless the mob has no AI): water or rain fills
/// it, anywhere else it runs down from the pre-tick value, and at -20 the
@@ -458,15 +483,7 @@ fn axolotl_air_supply(m: &mut Mob, w: &MobWorld) {
if m.no_ai {
return;
}
// Entity.isInRain: rain at the block, or at the top of the box.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let top = (m.position.y + f64::from(m.height())).floor() as i32;
let in_rain = w.raining_at(x, y, z) || w.raining_at(x, top, z);
if m.fluids.in_water || in_rain {
if m.fluids.in_water || in_rain(m, w) {
m.air = super::axolotl::MAX_AIR;
} else {
m.air = before - 1;
@@ -494,8 +511,9 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
);
m.damage(fire, Damage::of(DamageKind::InFire));
}
// Fire: water extinguishes, lava ignites, burning hurts every second.
if m.fluids.in_water && m.fire_ticks > 0 {
// Fire: water or rain (Entity.isInRain) extinguishes, lava ignites,
// burning hurts every second.
if m.fire_ticks > 0 && (m.fluids.in_water || in_rain(m, w)) {
m.fire_ticks = 0;
events.push(MobEvent::Sound(
"entity.generic.extinguish_fire",
@@ -524,14 +542,19 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
m.first_tick = false;
if m.alive() {
// LivingEntity.baseTick: suffocation inside full blocks.
// LivingEntity.baseTick: suffocation inside full blocks. Entity.isInWall
// is false under noPhysics, which Vex.tick holds while it ticks.
let (ex, ey, ez) = (
m.position.x.floor() as i32,
m.eye_y().floor() as i32,
m.position.z.floor() as i32,
);
let at_eye = w.facts.get(w.world.block(ex, ey, ez));
if at_eye.full_collision && at_eye.solid_render && m.width() < 1.0 {
if at_eye.full_collision
&& at_eye.solid_render
&& m.width() < 1.0
&& m.species() != Species::Vex
{
m.damage(1.0, Damage::of(DamageKind::InWall));
}
// Air supply and drowning (WaterAnimal.handleAirSupply for aquatic mobs).
@@ -551,17 +574,20 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.air -= 1;
if m.air == -20 {
m.air = 0;
events.push(MobEvent::Drown {
mob: m.id,
at: [m.position.x, m.position.y, m.position.z],
});
m.damage(2.0, Damage::of(DamageKind::Drown));
}
} else if m.air < 300 {
m.air = (m.air + 4).min(300);
}
if m.kind.flags.water_sensitive
&& (m.fluids.in_water || w.raining && w.can_see_sky(ex, ey, ez))
{
if m.kind.flags.water_sensitive && (m.fluids.in_water || in_rain(m, w)) {
m.damage(1.0, Damage::of(DamageKind::Drown));
}
freeze_tick(m, w);
}
tick_effects(m);
if m.species() == Species::Illusioner {
@@ -591,6 +617,57 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
// Entity.baseTick ends with Leashable.tickLeash.
super::leash::tick(m, w, events);
}
/// `LivingEntity.aiStep`'s "freezing" with `InsideBlockEffectType.FREEZE`:
/// powder snow builds `ticks_frozen` up, anywhere else it melts twice as
/// fast, and a fully frozen mob takes one freeze damage every 40 ticks.
fn freeze_tick(m: &mut Mob, w: &MobWorld) {
let in_snow = in_powder_snow(m, w);
if !in_snow && m.ticks_frozen == 0 {
return;
}
let can_freeze = can_freeze(m, w);
if in_snow && can_freeze {
m.ticks_frozen =
(m.ticks_frozen + 1).min(crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE);
} else {
m.ticks_frozen = (m.ticks_frozen - 2).max(0);
}
if m.age.is_multiple_of(40) && m.is_fully_frozen() && can_freeze {
m.damage(1.0, Damage::of(DamageKind::Freeze));
}
}
/// Whether the mob's box (shrunk by `1.0E-5`, as in `Entity.checkInsideBlocks`)
/// touches powder snow.
fn in_powder_snow(m: &Mob, w: &MobWorld) -> bool {
let b = m.bounds().deflate(Vec3::new(1e-5, 1e-5, 1e-5));
let (min, max) = (b.min_position(), b.max_position());
for x in min.x.floor() as i32..=max.x.floor() as i32 {
for y in min.y.floor() as i32..=max.y.floor() as i32 {
for z in min.z.floor() as i32..=max.z.floor() as i32 {
if &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:powder_snow" {
return true;
}
}
}
}
false
}
/// `LivingEntity.canFreeze`: not a `#freeze_immune_entity_types` mob and no
/// worn piece in `#freeze_immune_wearables`.
fn can_freeze(m: &Mob, w: &MobWorld) -> bool {
if w.data
.entity_has("minecraft:freeze_immune_entity_types", &m.kind.id)
{
return false;
}
let immune = w
.items
.and_then(|i| i.item_tags.get("minecraft:freeze_immune_wearables"));
![&m.extra.head, &m.extra.chest, &m.extra.legs, &m.extra.feet]
.into_iter()
.flatten()
.any(|item| immune.is_some_and(|t| t.contains(&**item)))
}
/// `Mob.playAmbientSound` on demand: a spawn egg or a mob bucket just placed `m`.
pub fn ambient_sound(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
play_ambient(m, w, events);
@@ -676,6 +753,9 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
.is_some_and(|t| t.position.distance_squared(m.position) > 121.0);
super::witch::ai_step(m, events, far);
}
if m.species() == Species::Fox {
super::fox::pre_ai_step(m);
}
// AbstractHorse.aiStep: the tail swishes now and then (moveTail).
if super::equine::is_equine(m.species()) {
super::equine::tail_tick(m);
@@ -895,7 +975,7 @@ fn sun_burn_tick(m: &mut Mob, w: &MobWorld) -> bool {
m.position.z.floor() as i32,
);
let br = w.brightness(x, y, z);
let wet = m.fluids.in_water || w.raining && w.can_see_sky(x, y, z);
let wet = m.fluids.in_water || in_rain(m, w);
br > 0.5 && m.random.next_float() * 30.0 < (br - 0.4) * 2.0 && !wet && w.can_see_sky(x, y, z)
}
/// Mob.serverAiStep
@@ -1001,6 +1081,8 @@ fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) {
Species::Evoker if m.extra.counter > 0 => m.extra.counter -= 1,
Species::Bat => super::bat::server_ai_step(m, cx.w, cx.events),
Species::Bee => super::bee::server_ai_step(m, cx),
Species::Armadillo => super::armadillo::server_ai_step(m, cx),
Species::Frog => super::frog::server_ai_step(m, cx.w),
_ => {}
}
}
@@ -1054,6 +1136,10 @@ fn species_ai_step(
}
Species::Nautilus | Species::ZombieNautilus => super::nautilus::ai_step(m, w, events),
Species::Chicken => farm::chicken_ai_step(m, w, events),
Species::Armadillo => super::armadillo::tick(m),
Species::Frog => super::frog::tick(m),
Species::Goat => super::goat::tick(m),
Species::Fox => super::fox::ai_step(m, w, events),
Species::Turtle => super::turtle::ai_step(m, w, events),
Species::Sheep if m.extra.anim_ticks > 0 => {
m.extra.anim_ticks -= 1;
@@ -1164,6 +1250,18 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] {
match m.species() {
Species::Nautilus | Species::ZombieNautilus => super::nautilus::sounds(m),
Species::SulfurCube => super::sulfur_cube::sounds(m),
Species::Armadillo => super::armadillo::sounds(m),
// Panda.getAmbientSound.
Species::Panda => {
let ambient = if super::panda::is_aggressive(m) {
"entity.panda.aggressive_ambient"
} else if super::panda::is_worried(m) {
"entity.panda.worried_ambient"
} else {
m.kind.sounds[0]
};
[ambient, m.kind.sounds[1], m.kind.sounds[2]]
}
// The sound variant's baby_sounds (classic sets, like the rest here).
Species::Pig if m.is_baby() => [
"entity.baby_pig.ambient",
@@ -1186,6 +1284,10 @@ pub fn feed_animal(
m: &mut Mob,
level: &mut impl RandomSource,
) -> Option<Option<(&'static str, f32, f32)>> {
// Armadillo.mobInteract: a ball takes no food.
if super::armadillo::scared(m) {
return None;
}
if m.extra.age == 0 && m.extra.in_love <= 0 {
m.extra.in_love = 600;
m.extra.love_player = true;
@@ -1362,6 +1464,11 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, _amount: f32) -> bool {
super::bee::hurt(m);
true
}
// Panda.hurtServer: up first, whatever the damage.
Species::Panda => {
super::panda::hurt(m);
true
}
Species::Enderman => super::enderman::on_damage(m, source),
Species::WitherSkeleton | Species::Blaze | Species::MagmaCube | Species::Strider
if source.is_fire() =>
@@ -1376,6 +1483,7 @@ pub fn after_hurt(m: &mut Mob, source: &Damage) {
m.ai.no_action_time = 0;
match m.species() {
Species::Enderman => super::enderman::after_hurt(m, source),
Species::Armadillo => super::armadillo::after_hurt(m, source),
Species::Shulker => super::shulker::after_hurt(m),
Species::Warden => super::warden::hurt(m, source),
Species::Wolf => super::tame::after_hurt(m, source),
@@ -1413,6 +1521,9 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
if m.species() == Species::Ravager {
super::ravager::on_attack(m, cx.events);
}
if m.species() == Species::Panda {
super::panda::bit(m);
}
if super::creaking::is_creaking(m) {
super::creaking::on_attack(m, cx.events);
}
@@ -1491,6 +1602,12 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
if super::creaking::is_creaking(m) {
m.voice("entity.creaking.attack", cx.events);
}
// Panda.playAttackSound: playSound(PANDA_BITE, 1.0, 1.0).
if m.species() == Species::Panda && !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.panda.bite", at, 1.0, 1.0));
}
// Axolotl.playAttackSound: playSound(AXOLOTL_ATTACK, 1.0, 1.0).
if m.species() == Species::Axolotl {
let at = [m.position.x, m.position.y, m.position.z];
@@ -1682,8 +1799,20 @@ pub fn step_sound(
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
S::CamelHusk => Some(("entity.camel_husk.step", 0.4, 1.0)),
S::Camel => Some(("entity.camel.step", 1.0, 1.0)),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
+5
View File
@@ -133,6 +133,11 @@ pub fn ai_step(m: &mut Mob) {
if m.extra.egg_time > 0 {
m.extra.egg_time -= 1;
}
animate(m);
}
/// `Allay.tick`'s client half: the hold and spin clocks (the server's and a
/// client's).
pub fn animate(m: &mut Mob) {
let holding = m.extra.main_hand.is_some();
let v = &mut m.anim.v;
v[HOLD] = (v[HOLD] + if holding { 1.0 } else { -1.0 }).clamp(0.0, 5.0);
+1 -1
View File
@@ -259,7 +259,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
S::Dolphin => {
// Dolphin.tick: moisture out of water, hop on land.
if m.fluids.in_water || w.raining {
if m.fluids.in_water || super::ai::in_rain(m, w) {
m.extra.counter = 2400;
} else {
m.extra.counter -= 1;
+407
View File
@@ -0,0 +1,407 @@
//! Armadillos (`Armadillo`, `ArmadilloAi`): a player who sprints or rides
//! nearby, an undead mob or the last attacker scares one into a ball (IDLE,
//! ROLLING, SCARED, UNROLLING); it peeks out now and then, and a ball takes
//! less damage. The brain's `ARMADILLO_SCARE_DETECTED` sensor, its
//! `DANGER_DETECTED_RECENTLY` memory and the `ArmadilloBallUp` behaviour are
//! kept in `State`; the walking behaviours are the goals of `register`.
//! AnimState states: roll out, roll up, peek.
use super::{
Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who,
goals::{
Cx, Float, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone,
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
pub const IDLE: u8 = 0;
pub const ROLLING: u8 = 1;
pub const SCARED: u8 = 2;
pub const UNROLLING: u8 = 3;
const ROLL_OUT: usize = 0;
const ROLL_UP: usize = 1;
const PEEK: usize = 2;
/// `ArmadilloState.animationDuration`.
fn animation_duration(state: u8) -> i32 {
match state {
ROLLING => 10,
SCARED => 50,
UNROLLING => 30,
_ => 0,
}
}
/// `Armadillo.ARMADILLO_STATE` and the brain's memory and behaviour.
#[derive(Clone, Debug, Default)]
pub struct State {
pub state: u8,
/// `inStateTicks`.
pub in_state_ticks: i32,
/// `DANGER_DETECTED_RECENTLY`: ticks to live, 0 when absent.
danger: i32,
/// Ticks to the sensor's next scan.
sensor: i32,
/// `ArmadilloBallUp`: running, its peek timer and whether it saw the
/// danger around last tick.
ball_up: bool,
next_peek: i32,
danger_was_around: bool,
/// Whether the PANIC activity was the active one after the last tick.
panic_activity: bool,
/// `peekReceivedClient`.
peek_received: bool,
}
pub fn register(m: &mut Mob) {
let mut panic = Panic::new(2.0);
panic.environmental = true;
let g = &mut m.ai.goals;
g.add(0, Float);
g.add(1, ArmadilloPanic(panic));
g.add(2, Rolled);
g.add(5, Stroll::avoiding_water(1.0));
g.add(6, LookAtPlayer::new(6.0));
g.add(7, RandomLookAround::default());
}
/// `Armadillo.isScared`.
pub fn scared(m: &Mob) -> bool {
m.extra.armadillo.state != IDLE
}
/// `ArmadilloState.shouldHideInShell`: the model shows its shell.
pub fn hiding_in_shell(m: &Mob) -> bool {
let a = &m.extra.armadillo;
match a.state {
ROLLING => a.in_state_ticks > 5,
SCARED => true,
UNROLLING => a.in_state_ticks < 26,
_ => false,
}
}
/// `Armadillo.getState`, as it is synched.
pub fn state(m: &Mob) -> u8 {
m.extra.armadillo.state
}
/// `Armadillo.switchToState` and `onSyncedDataUpdated(ARMADILLO_STATE)`.
pub fn set_state(m: &mut Mob, state: u8) {
let a = &mut m.extra.armadillo;
if a.state != state {
a.state = state;
a.in_state_ticks = 0;
}
}
/// `Armadillo.canStayRolledUp`.
fn can_stay_rolled_up(m: &Mob) -> bool {
!m.ai.panicking
&& !m.fluids.in_water
&& !m.fluids.in_lava
&& m.extra.leash.is_none()
&& m.extra.mount.is_none()
&& m.extra.rider.is_none()
}
/// `Entity.playSound(sound)`.
fn play(m: &Mob, events: &mut Vec<MobEvent>, sound: &'static str) {
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound(sound, at, 1.0, 1.0));
}
}
/// `Armadillo.rollUp`.
fn roll_up(m: &mut Mob, events: &mut Vec<MobEvent>) {
if !scared(m) {
super::creaking::stop_in_place(m);
m.extra.in_love = 0;
if !m.silent {
m.voice("entity.armadillo.roll", events);
}
set_state(m, ROLLING);
}
}
/// `Armadillo.rollOut`.
fn roll_out(m: &mut Mob, events: &mut Vec<MobEvent>) {
if scared(m) {
if !m.silent {
m.voice("entity.armadillo.unroll_finish", events);
}
set_state(m, IDLE);
}
}
/// `Armadillo.hurtServer`: a ball takes half of the damage past the first
/// point.
pub fn reduce_damage(m: &Mob, amount: f32) -> f32 {
if scared(m) {
((amount - 1.0) / 2.0).max(0.0)
} else {
amount
}
}
/// `Armadillo.actuallyHurt`: a hit by a living thing curls it up (and keeps
/// the danger in mind for 80 ticks), a hit by fire and the like unrolls it.
pub fn after_hurt(m: &mut Mob, source: &Damage) {
if m.no_ai || m.health <= 0.0 {
return;
}
let mut events = Vec::new();
if source.attacker.is_some() {
m.extra.armadillo.danger = 80;
if can_stay_rolled_up(m) {
roll_up(m, &mut events);
}
} else if environmental(source.kind) {
roll_out(m, &mut events);
}
m.outbox.append(&mut events);
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES`.
pub fn environmental(kind: DamageKind) -> bool {
matches!(
kind,
DamageKind::Cactus | DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava
)
}
/// The hurt sound: a ball's is muffled, and it has no ambient sound
/// (`getAmbientSound`, `getHurtSound`); empty is none.
pub fn sounds(m: &Mob) -> [&'static str; 3] {
let [ambient, hurt, death] = m.kind.sounds;
if scared(m) {
["", "entity.armadillo.hurt_reduced", death]
} else {
[ambient, hurt, death]
}
}
/// `EntityTypeTags.UNDEAD`.
fn undead(s: Species) -> bool {
matches!(
s,
Species::Skeleton
| Species::Stray
| Species::WitherSkeleton
| Species::SkeletonHorse
| Species::Bogged
| Species::Parched
| Species::ZombieHorse
| Species::CamelHusk
| Species::Zombie
| Species::ZombieVillager
| Species::ZombifiedPiglin
| Species::Zoglin
| Species::Drowned
| Species::Husk
| Species::ZombieNautilus
| Species::Wither
| Species::Phantom
)
}
/// `Armadillo.isScaredBy` for the nearby living entities: within 7 blocks
/// sideways and 2 up and down, an undead mob, the last attacker, or a
/// player who sprints or rides.
fn scared_by_someone(m: &Mob, w: &MobWorld) -> bool {
let area = m.bounds().inflate(Vec3::new(7.0, 2.0, 7.0));
let attacker =
m.ai.last_hurt_by
.filter(|_| m.age as i32 - m.ai.last_hurt_by_time <= 100);
if let Some(p) = &w.player
&& p.alive
&& !p.spectator
&& p.bounds.intersects(area)
&& (attacker == Some(Who::Player) || p.sprinting || p.riding)
{
return true;
}
w.others_near(area).any(|s| {
s.id != m.id
&& s.alive
&& s.bounds.intersects(area)
&& (undead(s.species) || attacker == Some(Who::Mob(s.id)))
})
}
/// `Armadillo.customServerAiStep`'s brain: memories expire, the scare
/// sensor scans every fifth tick, the core's rolling out, then the
/// `ArmadilloBallUp` behaviour of the PANIC activity.
pub fn server_ai_step(m: &mut Mob, cx: &mut Cx) {
let events = &mut *cx.events;
let a = &mut m.extra.armadillo;
if a.danger > 0 {
a.danger -= 1;
}
a.sensor -= 1;
if a.sensor <= 0 {
a.sensor = 5;
if !can_stay_rolled_up(m) {
m.extra.armadillo.danger = 0;
} else if scared_by_someone(m, cx.w) {
m.extra.armadillo.danger = 80;
}
}
// ARMADILLO_ROLLING_OUT: no danger left in mind.
if m.extra.armadillo.danger == 0 && scared(m) {
roll_out(m, events);
}
if m.extra.armadillo.panic_activity && !m.extra.armadillo.ball_up && m.on_ground && !liquid(m) {
// ArmadilloBallUp.start.
m.extra.armadillo.ball_up = true;
roll_up(m, events);
}
if m.extra.armadillo.ball_up {
ball_up_tick(m, events);
}
let a = &mut m.extra.armadillo;
a.panic_activity = a.danger > 0 && !m.ai.panicking;
}
fn liquid(m: &Mob) -> bool {
m.fluids.in_water || m.fluids.in_lava
}
/// `ArmadilloBallUp.tickOrStop`.
fn ball_up_tick(m: &mut Mob, events: &mut Vec<MobEvent>) {
if !scared(m) {
// canStillUse is false: stop.
m.extra.armadillo.ball_up = false;
if !can_stay_rolled_up(m) {
roll_out(m, events);
}
return;
}
let a = &mut m.extra.armadillo;
if a.next_peek > 0 {
a.next_peek -= 1;
}
if a.state == ROLLING && a.in_state_ticks > animation_duration(ROLLING) {
set_state(m, SCARED);
if m.on_ground {
play(m, events, "entity.armadillo.land");
}
return;
}
let danger = m.extra.armadillo.danger;
let around = danger > 75;
if around != m.extra.armadillo.danger_was_around {
m.extra.armadillo.next_peek = next_peek_timer(m);
}
m.extra.armadillo.danger_was_around = around;
let state = m.extra.armadillo.state;
if state == SCARED {
if m.extra.armadillo.next_peek == 0 && m.on_ground && around {
peek(m, events);
m.extra.armadillo.next_peek = next_peek_timer(m);
}
if danger < animation_duration(UNROLLING) {
play(m, events, "entity.armadillo.unroll_start");
set_state(m, UNROLLING);
}
} else if state == UNROLLING && danger > animation_duration(UNROLLING) {
set_state(m, SCARED);
}
}
/// `ArmadilloBallUp.pickNextPeekTimer`.
fn next_peek_timer(m: &mut Mob) -> i32 {
animation_duration(SCARED) + m.random.next_int_between_inclusive(100, 400).unwrap_or(100)
}
/// `level.broadcastEntityEvent(body, 64)`; the sound is the client's
/// `playLocalSound`, which the server plays for everyone here.
fn peek(m: &mut Mob, events: &mut Vec<MobEvent>) {
entity_event(m, 64);
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::EntityEvent {
mob: m.id,
event: 64,
at,
});
events.push(MobEvent::Sound("entity.armadillo.peek", at, 1.0, 1.0));
}
/// `Armadillo.handleEntityEvent`: 64 is a peek.
pub fn entity_event(m: &mut Mob, event: u8) {
if event == 64 {
m.extra.armadillo.peek_received = true;
}
}
/// `Armadillo.tick`: the animation states on a client, the ball's head held
/// to its body, and `inStateTicks`. Server and client both run it.
pub fn tick(m: &mut Mob) {
animate(m);
if scared(m) {
// clampHeadRotationToBody with getMaxHeadYRot 0.
m.head_yaw += super::living::wrap(m.body_yaw - m.head_yaw);
}
m.extra.armadillo.in_state_ticks += 1;
}
/// `Armadillo.setupAnimationStates`.
fn animate(m: &mut Mob) {
let age = m.age as f32;
let a = &mut m.extra.armadillo;
let s = &mut m.anim.states;
match a.state {
ROLLING => {
s[ROLL_OUT].stop();
s[ROLL_UP].start_if_stopped(age);
s[PEEK].stop();
}
SCARED => {
s[ROLL_OUT].stop();
s[ROLL_UP].stop();
if a.peek_received {
s[PEEK].stop();
a.peek_received = false;
}
if a.in_state_ticks == 0 {
s[PEEK].start(age);
s[PEEK].fast_forward(animation_duration(SCARED));
} else {
s[PEEK].start_if_stopped(age);
}
}
UNROLLING => {
s[ROLL_OUT].start_if_stopped(age);
s[ROLL_UP].stop();
s[PEEK].stop();
}
_ => {
s[ROLL_OUT].stop();
s[ROLL_UP].stop();
s[PEEK].stop();
}
}
}
/// `ArmadilloAi.ArmadilloPanic`: it unrolls, then runs.
#[derive(Clone, Debug)]
struct ArmadilloPanic(Panic);
impl Goal for ArmadilloPanic {
goal_clone!();
fn flags(&self) -> u8 {
self.0.flags()
}
fn name(&self) -> &'static str {
"Panic"
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_continue(m, cx)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
roll_out(m, cx.events);
self.0.start(m, cx);
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.stop(m, cx);
}
}
/// The behaviours that do not run while the armadillo is a ball: its
/// `MoveToTargetSink` and the idle activity's walking and looking.
#[derive(Clone, Debug)]
struct Rolled;
impl Goal for Rolled {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
scared(m)
}
}
+9 -8
View File
@@ -59,11 +59,11 @@ pub struct Goat {
pub ramming_x_head_rot: f32,
}
impl Goat {
pub fn from_mob(_m: &Mob) -> Self {
pub fn from_mob(m: &Mob) -> Self {
Self {
left_horn: true,
right_horn: true,
ramming_x_head_rot: 0.0,
left_horn: !m.extra.lost_horns[0],
right_horn: !m.extra.lost_horns[1],
ramming_x_head_rot: super::goat::ramming_x_head_rot(m),
}
}
}
@@ -960,7 +960,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
match m.species() {
Species::Goat => goat(p, s, &Goat::from_mob(m)),
Species::PolarBear => polar_bear(p, s, super::polar_bear::standing_scale(m, s.partial)),
Species::Panda => panda(p, s, &Panda::default()),
Species::Panda => panda(p, s, &super::panda::pose(m, s.partial)),
Species::Llama | Species::TraderLlama => {
llama(p, s, m.extra.chested && !s.baby);
}
@@ -980,9 +980,10 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
Species::Parrot => {
let flap = m.anim.flap_o + (m.anim.flap - m.anim.flap_o) * s.partial;
let speed = m.anim.flap_speed_o + (m.anim.flap_speed - m.anim.flap_speed_o) * s.partial;
// `Parrot.isFlying` is `!onGround`, in water too.
let pose = if m.extra.sitting {
ParrotPose::Sitting
} else if !m.on_ground && !m.fluids.in_water {
} else if !m.on_ground {
ParrotPose::Flying
} else {
ParrotPose::Standing
@@ -1007,7 +1008,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
p,
s,
&Armadillo {
hiding_in_shell: false,
hiding_in_shell: super::armadillo::hiding_in_shell(m),
roll_out: st[0],
roll_up: st[1],
peek: st[2],
@@ -1028,7 +1029,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
a,
),
Species::Rabbit => rabbit(p, s, &st[0], &st[1], a),
Species::Fox => fox(p, s, &Fox::default(), a),
Species::Fox => fox(p, s, &super::fox::pose(m, s.partial), a),
_ => return false,
}
true
+22
View File
@@ -31,6 +31,28 @@ pub fn pose(m: &Mob) -> u8 {
pub(super) fn set_pose(m: &mut Mob, pose: u8) {
m.extra.variant3 = pose;
}
/// `Pose.id` of the pose (`sync::pose_id`), and its inverse.
pub fn pose_id(m: &Mob) -> u8 {
match pose(m) {
SHOOTING => 16,
INHALING => 17,
LONG_JUMPING => 6,
SLIDING => 15,
_ => 0,
}
}
pub(super) fn set_pose_id(m: &mut Mob, id: u8) {
set_pose(
m,
match id {
16 => SHOOTING,
17 => INHALING,
6 => LONG_JUMPING,
15 => SLIDING,
_ => STANDING,
},
);
}
/// `Breeze.getFiringYPosition`.
pub fn firing_y(m: &Mob) -> f64 {
m.position.y + f64::from(m.height()) / 2.0 + f64::from(0.3_f32)
+45 -4
View File
@@ -45,6 +45,25 @@ pub fn sitting(m: &Mob) -> bool {
fn in_transition(m: &Mob) -> bool {
pose_time(m) < if sitting(m) { SIT_DOWN } else { STAND_UP }
}
/// `Camel.getPoseTime` of a client mob from the `LAST_POSE_CHANGE_TICK` it
/// was sent and the client's game time, and whether that value says it sits.
fn client_pose(m: &Mob, game_time: i64) -> (i64, bool) {
let tick = m.extra.pose_change_tick;
(game_time - tick.abs(), tick < 0)
}
/// The camel's `LAST_POSE_CHANGE_TICK` as the server sends it: in game time
/// (a mob counts its own age), negative while sitting.
pub fn synced_pose_tick(m: &Mob, game_time: i64) -> i64 {
let tick = (game_time - pose_time(m)).max(0);
if sitting(m) { -tick.max(1) } else { tick }
}
/// `Entity.onSyncedDataUpdated` for the pose tick of a client mob: its sign
/// is whether it sits. `client_tick` keeps `egg_time` (in the mob's age, for
/// `sitting` and `refuse_to_move`) with it.
pub fn set_synced_pose_tick(m: &mut Mob, tick: i64) {
m.extra.pose_change_tick = tick;
m.extra.egg_time = if tick < 0 { -1 } else { 0 };
}
/// `Camel.refuseToMove`: sitting or getting up or down.
pub fn refuse_to_move(m: &Mob) -> bool {
sitting(m) || in_transition(m)
@@ -166,7 +185,29 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.ai.zza = 0.0;
m.ai.nav.stop();
}
// setupAnimationStates.
animation_states(m, pose_time(m), sitting(m));
}
/// `Camel.tick` on a client that is not the server: the dash cooldown, which
/// the dash flag starts, and the keyframe states from the synced pose tick.
pub fn client_tick(m: &mut Mob, game_time: i64) {
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
}
let (pose_time, sitting) = client_pose(m, game_time);
let since = (i64::from(m.age) - pose_time).max(0) as i32;
m.extra.egg_time = if sitting { -since.max(1) } else { since };
animation_states(m, pose_time, sitting);
}
/// `Camel.onSyncedDataUpdated(DASH)`: a dash starts the cooldown unless it
/// runs. Not on the first tick.
pub fn set_dashing(m: &mut Mob, dashing: bool) {
if m.age > 0 && m.extra.anim_ticks == 0 {
m.extra.anim_ticks = DASH_COOLDOWN;
}
m.extra.flag2 = dashing;
}
/// `Camel.setupAnimationStates` for a pose time and whether it sits.
fn animation_states(m: &mut Mob, pose_time: i64, sitting: bool) {
let age = m.age as f32;
if m.extra.ints[0] <= 0 {
m.extra.ints[0] = m.random.next_int_bound(40).unwrap_or(0) + 80;
@@ -175,9 +216,9 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.extra.ints[0] -= 1;
}
// isCamelVisuallySitting: (poseTime < 0) != sitting.
let visually_sitting = (pose_time(m) < 0) != sitting(m);
let sitting_down = sitting(m) && pose_time(m) < SIT_DOWN && pose_time(m) >= 0;
let transition = in_transition(m) && pose_time(m) >= 0;
let visually_sitting = (pose_time < 0) != sitting;
let sitting_down = sitting && (0..SIT_DOWN).contains(&pose_time);
let transition = (0..if sitting { SIT_DOWN } else { STAND_UP }).contains(&pose_time);
let dashing = m.extra.flag2;
let st = &mut m.anim.states;
if visually_sitting {
+27 -3
View File
@@ -1,6 +1,6 @@
//! Mob controls: `MoveControl` (with the flying and smooth-swimming
//! variants), `LookControl`, `JumpControl` and `BodyRotationControl`.
use super::{Mob, MobWorld, living::wrap};
use super::{Mob, MobWorld, Species, living::wrap};
use horizon_core::geometry::trig;
/// `2.5000003E-7F` widened: the squared distance controls treat as arrived.
@@ -113,8 +113,16 @@ pub struct BodyRotation {
last_stable_head: f32,
}
impl Mob {
/// `Mob.getMaxHeadXRot`, with `Wolf`'s 20 degrees while sitting.
pub fn max_head_x_rot(&self) -> f32 {
if self.species() == Species::Wolf && self.extra.sitting {
20.0
} else {
self.kind.max_head_x_rot
}
}
pub fn look_at(&mut self, at: [f64; 3]) {
let (y, x) = (self.kind.head_rot_speed, self.kind.max_head_x_rot);
let (y, x) = (self.kind.head_rot_speed, self.max_head_x_rot());
self.ai.look.set_look_at(at, y, x);
}
pub fn look_at_speed(&mut self, at: [f64; 3], y_speed: f32, x_max: f32) {
@@ -135,6 +143,16 @@ impl Mob {
if self.species() == super::Species::Rabbit {
return super::rabbit::move_control(self, w);
}
// Fox.FoxMoveControl.tick: still while asleep, sitting or faceplanted.
if self.species() == super::Species::Fox && !super::fox::can_move(self) {
return;
}
// Panda.PandaMoveControl: only while it can act.
if self.species() == super::Species::Panda
&& !super::panda::can_perform_action(self, w.thundering)
{
return;
}
let attr_speed = self.movement_speed();
match self.ai.mov.kind {
MoveKind::Custom => super::ai::custom_move_control(self, w, events),
@@ -314,6 +332,11 @@ impl Mob {
/// LookControl.tick (and SmoothSwimmingLookControl).
pub fn look_control_tick(&mut self) {
let look = self.ai.look.clone();
let fox = self.species() == super::Species::Fox;
// Fox.FoxLookControl: no turning in sleep.
if fox && super::fox::flag(self, super::fox::SLEEPING) {
return;
}
if look.kind == LookKind::HappyGhast {
return super::happy_ghast::look_control(self);
}
@@ -346,7 +369,8 @@ impl Mob {
}
}
LookKind::Default | LookKind::Fixed => {
if look.kind == LookKind::Default {
// FoxLookControl.resetXRotOnTick keeps a stalking or pouncing pitch.
if look.kind == LookKind::Default && (!fox || super::fox::resets_x_rot(self)) {
self.head_pitch = 0.0;
}
if look.cooldown > 0 {
+12 -2
View File
@@ -70,6 +70,16 @@ pub fn sound(m: &Mob, what: &str) -> &'static str {
}
/// `CopperGolem.setupAnimationStates` (client) and the sound of the head spin.
pub fn ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec<MobEvent>) {
if animate(m) {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::LocalSound(sound(m, "spin"), at, 1.0, 1.0));
}
}
/// `CopperGolem.setupAnimationStates`, for the server and a client alike;
/// returns whether the head spin sound plays now (a client's sound is the
/// server's `LocalSound`).
pub fn animate(m: &mut Mob) -> bool {
let mut spin = false;
let age = m.age as f32;
let state = m.extra.variant3;
let st = &mut m.anim.states;
@@ -93,8 +103,7 @@ pub fn ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.ints[0] = tick + 200 + r.next_int_bound(40).unwrap_or(0);
}
if tick == m.extra.ints[0] + 10 {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::LocalSound(sound(m, "spin"), at, 1.0, 1.0));
spin = true;
m.extra.ints[0] = 0;
}
}
@@ -106,6 +115,7 @@ pub fn ai_step(m: &mut Mob, _w: &MobWorld, events: &mut Vec<MobEvent>) {
}
_ => {}
}
spin
}
/// `CopperGolem.updateWeathering` on the level random: the first tick sets
/// the next weathering tick, which advances the weather state; once
+49 -21
View File
@@ -152,8 +152,26 @@ pub fn sway(m: &mut Mob, events: &mut Vec<MobEvent>) {
}
/// `Creaking.doHurtTarget`: the attack animation (the sound is the
/// `playAttackSound` that ends `Mob.doHurtTarget`).
pub fn on_attack(m: &mut Mob, _events: &mut Vec<MobEvent>) {
pub fn on_attack(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.extra.ints[ATTACKING] = ATTACK_TICKS;
broadcast(m, 4, events);
}
/// `Level.broadcastEntityEvent` for a creaking (guests run `entity_event`).
fn broadcast(m: &Mob, event: i8, events: &mut Vec<MobEvent>) {
events.push(MobEvent::EntityEvent {
mob: m.id,
event,
at: [m.position.x, m.position.y, m.position.z],
});
}
/// `Creaking.handleEntityEvent` on a client: 66 the invulnerability flash,
/// 4 the attack swing (the sounds are the server's).
pub fn entity_event(m: &mut Mob, event: u8) {
match event {
66 => m.extra.ints[INVULNERABLE] = INVULNERABILITY_TICKS,
4 => m.extra.ints[ATTACKING] = ATTACK_TICKS,
_ => {}
}
}
/// The heart this creaking is bound to protects it (`isProtector`); a link
/// read from a save is trusted until the heart resolves it.
@@ -176,15 +194,12 @@ pub fn update(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if heart_bound(m) && m.alive() && !protected(m, w) {
m.health = 0.0;
}
for slot in [INVULNERABLE, ATTACKING] {
if m.extra.ints[slot] > 0 {
m.extra.ints[slot] -= 1;
}
// hurtServer set the invulnerability clock since the last tick: entity
// event 66.
if m.extra.ints[INVULNERABLE] == INVULNERABILITY_TICKS {
broadcast(m, 66, events);
}
let age = m.age as f32;
m.anim.states[0].animate_when(m.extra.ints[ATTACKING] > 0, age);
m.anim.states[1].animate_when(m.extra.ints[INVULNERABLE] > 0, age);
m.anim.states[2].animate_when(tearing_down(m), age);
animate(m);
if m.extra.counter & HURT != 0 {
// hurtServer: sway, and the heart reacts to a player's hit.
if protected(m, w) {
@@ -200,18 +215,6 @@ pub fn update(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
m.extra.counter &= !(HURT | HURT_BY_PLAYER);
}
// checkEyeBlink: the eyes of a creaking falling apart flicker.
if m.death_time > m.extra.ints[NEXT_FLICKER] {
let glowing = m.extra.ints[EYES] != 0;
let (lo, hi) = if glowing {
(2, 8)
} else {
(m.death_time / 4, m.death_time / 2)
};
let span = (hi - lo + 1).max(1);
m.extra.ints[NEXT_FLICKER] = m.death_time + lo + m.random.next_int_bound(span).unwrap_or(0);
m.extra.ints[EYES] = i32::from(!glowing);
}
if !m.alive() {
return;
}
@@ -226,6 +229,31 @@ pub fn update(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.flag2 = !now;
}
}
/// The clocks and keyframe states `Creaking.aiStep`, `setupAnimationStates`
/// and `checkEyeBlink` run (the server's and a client's).
pub fn animate(m: &mut Mob) {
for slot in [INVULNERABLE, ATTACKING] {
if m.extra.ints[slot] > 0 {
m.extra.ints[slot] -= 1;
}
}
let age = m.age as f32;
m.anim.states[0].animate_when(m.extra.ints[ATTACKING] > 0, age);
m.anim.states[1].animate_when(m.extra.ints[INVULNERABLE] > 0, age);
m.anim.states[2].animate_when(tearing_down(m), age);
// checkEyeBlink: the eyes of a creaking falling apart flicker.
if m.death_time > m.extra.ints[NEXT_FLICKER] {
let glowing = m.extra.ints[EYES] != 0;
let (lo, hi) = if glowing {
(2, 8)
} else {
(m.death_time / 4, m.death_time / 2)
};
let span = (hi - lo + 1).max(1);
m.extra.ints[NEXT_FLICKER] = m.death_time + lo + m.random.next_int_bound(span).unwrap_or(0);
m.extra.ints[EYES] = i32::from(!glowing);
}
}
/// `Mob.stopInPlace`.
pub fn stop_in_place(m: &mut Mob) {
m.nav_stop();
+7 -1
View File
@@ -244,7 +244,13 @@ pub fn slot_item(m: &Mob, slot: &str) -> Option<LootStack> {
}
_ => (None, None),
};
let mut stack = loot_stack(item?, 1);
// A panda keeps the whole stack it picked up in its mouth.
let count = if slot == "mainhand" && m.species() == Species::Panda {
super::panda::held_count(m)
} else {
1
};
let mut stack = loot_stack(item?, count as i32);
if let (Some(index), Some(g)) = (index, gear::gear(m)) {
for (e, l) in &g.slots[index] {
stack.enchantments.push((e.to_string(), *l as i32));
+1 -9
View File
@@ -184,15 +184,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
if (m.fluids.in_water
|| w.raining
&& w.can_see_sky(
m.position.x.floor() as i32,
m.eye_y().floor() as i32,
m.position.z.floor() as i32,
))
&& m.alive()
{
if (m.fluids.in_water || super::ai::in_rain(m, w)) && m.alive() {
// Hurt by water: teleport away.
m.extra.counter2 = m.extra.counter2.max(1);
}
+3 -9
View File
@@ -1677,7 +1677,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
let jump = self.uniform(600, 1200);
set_memory(m, "long_jump_cooldown", i64::from(jump), i64::MAX);
let ram = self.uniform(600, 6000);
set_memory(m, "ram_cooldown", i64::from(ram), i64::MAX);
m.extra.goat.ram_cooldown = ram;
m.extra.screaming = self.random.next_double() < 0.02;
age_boundary_reached(m);
if !m.is_baby() && self.float() < 0.1 {
@@ -1691,13 +1691,7 @@ impl<L: FinalizeLevel + ?Sized, R: RandomSource> Ctx<'_, L, R> {
let hidden = panda_gene(self.int(16));
m.extra.variant = main;
m.extra.variant2 = hidden;
let shown = panda_variant(main, hidden);
if shown == 5 {
set_base(m, Attr::MaxHealth, 10.0);
}
if shown == 1 {
set_base(m, Attr::MovementSpeed, f64::from(0.07f32));
}
super::panda::set_attributes(m);
self.ageable(m, g.or(Some(Group::Ageable(Ageable::chance(0.2)))))
}
S::PolarBear | S::Ocelot => {
@@ -1950,7 +1944,7 @@ fn set_memory(m: &mut Mob, name: &'static str, value: i64, ttl: i64) {
}
/// `Panda.Gene.getRandom` (enum order: normal, lazy, worried, playful,
/// brown, weak, aggressive).
fn panda_gene(roll: i32) -> u8 {
pub fn panda_gene(roll: i32) -> u8 {
match roll {
0 => 1,
1 => 2,
+845
View File
@@ -0,0 +1,845 @@
//! Foxes (`net.minecraft.world.entity.animal.fox.Fox`): the goal list, the
//! flags byte (sleeping, sitting, crouching, interested, pouncing,
//! faceplanted), stalking and pouncing, napping in the shade by day, seeking
//! shelter, perching, eating berries and the food held in the mouth.
//! Picking up items, strolling through villages and trusting players (with
//! defending them) are not ported yet.
use super::{
Mob, MobEvent, MobWorld, Seen, Species,
goals::{
Avoid, AvoidEntity, Breed, Cx, Float, FollowParent, Goal, JUMP, LOOK, LeapAtTarget,
LookAtPlayer, MOVE, MeleeAttack, MoveToBlock, NearestTarget, Panic, Prey, Stroll,
goal_clone, hide_pos, reduced,
},
living::wrap,
};
use horizon_core::{
geometry::{Axis, Vec3},
jmath,
random::RandomSource,
};
use horizon_world::registry::BlockStateId;
// Fox.DATA_FLAGS_ID, kept in `extra.ints[0]` (and sent to clients as is).
pub const SITTING: i32 = 1;
pub const CROUCHING: i32 = 4;
pub const INTERESTED: i32 = 8;
pub const POUNCING: i32 = 16;
pub const SLEEPING: i32 = 32;
pub const FACEPLANTED: i32 = 64;
pub const DEFENDING: i32 = 128;
/// `Fox.ticksSinceEaten`.
const TICKS_SINCE_EATEN: usize = 1;
/// `Fox.interestedAngle` and `crouchAmount` in `anim.v`, the previous tick's
/// values in `anim.vo`.
const INTERESTED_ANGLE: usize = 0;
const CROUCH_AMOUNT: usize = 1;
/// `Fox.MAX_CROUCH_AMOUNT`.
const MAX_CROUCH_AMOUNT: f32 = 5.0;
pub fn flag(m: &Mob, f: i32) -> bool {
m.extra.ints[0] & f != 0
}
fn set_flag(m: &mut Mob, f: i32, on: bool) {
if on {
m.extra.ints[0] |= f;
} else {
m.extra.ints[0] &= !f;
}
}
/// `Fox.clearStates`.
fn clear_states(m: &mut Mob) {
m.extra.ints[0] = 0;
}
/// `Fox.canMove`, which `FoxMoveControl` waits for.
pub fn can_move(m: &Mob) -> bool {
!flag(m, SLEEPING | SITTING | FACEPLANTED)
}
/// `FoxLookControl.resetXRotOnTick`: stalking and pouncing keep their pitch.
pub fn resets_x_rot(m: &Mob) -> bool {
!flag(m, POUNCING | CROUCHING | INTERESTED | FACEPLANTED)
}
/// `Fox.registerGoals` and `setTargetGoals` (the targets wait for the variant:
/// red foxes prefer chickens and rabbits, snow foxes fish).
pub fn register(m: &mut Mob) {
let countdown = m.random.next_int_bound(reduced(140)).unwrap_or(0);
let g = &mut m.ai.goals;
g.add(0, Wrap::new(Float).on_start(clear_states));
g.add(1, Faceplant { countdown: 0 });
g.add(2, Panic::new(2.2));
g.add(3, Wrap::new(Breed::new(1.0)).on_start(clear_states));
// AVOID_PLAYERS: not a sneaking player.
g.add(
4,
AvoidEntity::new(Avoid::Player, 16.0, 1.6, 1.4)
.player_when(|p| !p.sneaking && !p.spectator),
);
g.add(
4,
AvoidEntity::new(Avoid::Species(vec![Species::Wolf]), 8.0, 1.6, 1.4).seen_when(|s| !s.tame),
);
g.add(
4,
AvoidEntity::new(Avoid::Species(vec![Species::PolarBear]), 8.0, 1.6, 1.4),
);
g.add(5, StalkPrey);
g.add(6, Pounce);
g.add(6, SeekShelter::new(1.25));
g.add(
7,
Wrap::new(MeleeAttack::new(1.2, true))
.when(
|m| !flag(m, SITTING | SLEEPING | CROUCHING | FACEPLANTED),
false,
)
.on_start(|m| set_flag(m, INTERESTED, false)),
);
g.add(7, Sleep { countdown });
g.add(8, Wrap::new(FollowParent::new(1.25)).on_start(clear_states));
g.add(10, EatBerries::new());
g.add(10, LeapAtTarget::new(0.4));
g.add(11, Stroll::avoiding_water(1.0));
// FoxLookAtPlayerGoal: not with the nose in the snow or while stalking.
g.add(
12,
Wrap::new(LookAtPlayer::new(24.0)).when(|m| !flag(m, FACEPLANTED | INTERESTED), true),
);
g.add(13, Perch::default());
let red = |m: &Mob, _: &MobWorld| m.extra.variant == 0;
let snow = |m: &Mob, _: &MobWorld| m.extra.variant != 0;
let land = || NearestTarget::new(Prey::Filter(chicken_or_rabbit), false).interval(10);
let turtle = || NearestTarget::new(Prey::Filter(baby_turtle_on_land), false).interval(10);
let fish = || NearestTarget::new(Prey::Filter(schooling_fish), false).interval(20);
let t = &mut m.ai.targets;
t.add(4, land().gate(red));
t.add(4, turtle().gate(red));
t.add(6, fish().gate(red));
t.add(4, fish().gate(snow));
t.add(6, land().gate(snow));
t.add(6, turtle().gate(snow));
}
fn chicken_or_rabbit(s: &Seen) -> bool {
matches!(s.species, Species::Chicken | Species::Rabbit)
}
fn baby_turtle_on_land(s: &Seen) -> bool {
s.species == Species::Turtle && s.baby && !s.in_water
}
fn schooling_fish(s: &Seen) -> bool {
matches!(
s.species,
Species::Cod | Species::Salmon | Species::TropicalFish
)
}
/// The part of `Fox.tick` a client runs too: the head tilt eases toward the
/// interested flag and the crouch builds up to 5 while crouching.
pub fn client_tick(m: &mut Mob) {
let (interested, crouching) = (flag(m, INTERESTED), flag(m, CROUCHING));
let v = &mut m.anim.v;
m.anim.vo[INTERESTED_ANGLE] = v[INTERESTED_ANGLE];
v[INTERESTED_ANGLE] += (if interested { 1.0 } else { 0.0 } - v[INTERESTED_ANGLE]) * 0.4;
m.anim.vo[CROUCH_AMOUNT] = v[CROUCH_AMOUNT];
v[CROUCH_AMOUNT] = if crouching {
(v[CROUCH_AMOUNT] + 0.2).min(MAX_CROUCH_AMOUNT)
} else {
0.0
};
}
/// `Fox.isFullyCrouched`.
fn fully_crouched(m: &Mob) -> bool {
m.anim.v[CROUCH_AMOUNT] == MAX_CROUCH_AMOUNT
}
/// `Fox.tick` and the eating part of `Fox.aiStep`.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
client_tick(m);
if !m.alive() {
return;
}
m.extra.ints[TICKS_SINCE_EATEN] += 1;
if let Some(item) = m.extra.main_hand.clone()
&& eats(w, &item)
&& m.ai.target.is_none()
&& m.on_ground
&& !flag(m, SLEEPING)
{
let eaten = m.extra.ints[TICKS_SINCE_EATEN];
if eaten > 600 {
// ItemStack.finishUsingItem: the food is gone, a use remainder stays.
m.extra.main_hand = w
.items
.and_then(|i| i.items.get(&item))
.and_then(|i| i.use_remainder.as_deref())
.map(Into::into);
m.extra.ints[TICKS_SINCE_EATEN] = 0;
} else if eaten > 560 && m.random.next_float() < 0.1 {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.fox.eat", at, 1.0, 1.0));
}
}
if !m.target_info(w).is_some_and(|t| t.alive) {
set_flag(m, CROUCHING | INTERESTED, false);
}
let in_water = m.fluids.in_water;
if in_water || m.ai.target.is_some() || w.thundering {
set_flag(m, SLEEPING, false);
}
if in_water || flag(m, SLEEPING) {
set_flag(m, SITTING, false);
}
// Faceplanted: crumbs of the snow now and then (level event 2001).
if flag(m, FACEPLANTED) && m.random.next_float() < 0.2 {
let at = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
events.push(MobEvent::LevelEvent {
id: 2001,
pos: at,
data: w.world.block(at[0], at[1], at[2]).0 as i32,
});
}
}
/// `Fox.aiStep` before the living step: a sleeping fox takes no input.
pub fn pre_ai_step(m: &mut Mob) {
if flag(m, SLEEPING) {
m.ai.jumping = false;
m.ai.xxa = 0.0;
m.ai.zza = 0.0;
}
}
/// `Fox.isConsumableFood`: the item has food and consumable components.
fn eats(w: &MobWorld, item: &str) -> bool {
w.items
.and_then(|i| i.items.get(item))
.is_some_and(|i| i.food.is_some() && i.consumable.is_some())
}
/// `Fox.FoxBehaviorGoal.hasShelter`: out of the sky's sight and not a bad spot.
fn has_shelter(m: &Mob, w: &MobWorld) -> bool {
let at = [
m.position.x.floor() as i32,
m.bounds().max(Axis::Y).floor() as i32,
m.position.z.floor() as i32,
];
!w.can_see_sky(at[0], at[1], at[2]) && m.walk_target_value(w, at) >= 0.0
}
/// `Fox.FoxBehaviorGoal.alertable`: someone within 12 blocks the fox would
/// keep watch over (`FoxAlertableEntitiesSelector`).
fn alertable(m: &Mob, w: &MobWorld) -> bool {
let (from, id, y) = (m.position, m.id, m.position.y);
let mob = w
.nearby(from, 12.0, |s: &Seen| {
s.id != id
&& (s.position.y - y).abs() <= 6.0
&& m.distance_sqr(s.position) <= 144.0
&& match s.species {
Species::Fox => false,
Species::Chicken | Species::Rabbit => true,
Species::Wolf | Species::Cat | Species::Parrot | Species::Nautilus => !s.tame,
_ => true,
}
})
.next()
.is_some();
mob || w.player.as_ref().is_some_and(|p| {
p.alive
&& !p.creative
&& !p.spectator
&& !p.sneaking
&& (p.position.y - y).abs() <= 6.0
&& m.distance_sqr(p.position) <= 144.0
})
}
/// `Fox.SleepGoal`: nap in the shade by day when nothing is around.
#[derive(Clone, Debug)]
struct Sleep {
countdown: i32,
}
impl Sleep {
fn can_sleep(&mut self, m: &Mob, w: &MobWorld) -> bool {
if self.countdown > 0 {
self.countdown -= 1;
return false;
}
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
w.daylight
&& has_shelter(m, w)
&& !alertable(m, w)
&& w.block_name(x, y, z) != "minecraft:powder_snow"
}
}
impl Goal for Sleep {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK | JUMP
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.ai.nav.is_done() && (self.can_sleep(m, cx.w) || flag(m, SLEEPING))
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.can_sleep(m, cx.w)
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
set_flag(m, SITTING | CROUCHING | INTERESTED, false);
m.ai.jumping = false;
set_flag(m, SLEEPING, true);
m.nav_stop();
let p = m.position;
m.ai.mov.set_wanted(p.x, p.y, p.z, 0.0);
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
self.countdown = m.random.next_int_bound(reduced(140)).unwrap_or(0);
clear_states(m);
}
}
/// A shared goal under the fox's own conditions and start-up: the extra
/// `canUse` of `FoxMeleeAttackGoal` and `FoxLookAtPlayerGoal` and the
/// `clearStates` or `setIsInterested(false)` its subclasses do in `start`.
#[derive(Clone, Debug)]
struct Wrap<G> {
goal: G,
when: Option<fn(&Mob) -> bool>,
/// The condition also applies to `canContinueToUse`.
both: bool,
on_start: Option<fn(&mut Mob)>,
}
impl<G: Goal + Clone + 'static> Wrap<G> {
fn new(goal: G) -> Self {
Self {
goal,
when: None,
both: false,
on_start: None,
}
}
fn when(mut self, f: fn(&Mob) -> bool, both: bool) -> Self {
(self.when, self.both) = (Some(f), both);
self
}
fn on_start(mut self, f: fn(&mut Mob)) -> Self {
self.on_start = Some(f);
self
}
}
impl<G: Goal + Clone + 'static> Goal for Wrap<G> {
goal_clone!();
fn flags(&self) -> u8 {
self.goal.flags()
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.when.is_none_or(|f| f(m)) && self.goal.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
(!self.both || self.when.is_none_or(|f| f(m))) && self.goal.can_continue(m, cx)
}
fn interruptable(&self) -> bool {
self.goal.interruptable()
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
if let Some(f) = self.on_start {
f(m);
}
self.goal.start(m, cx);
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.goal.stop(m, cx);
}
fn every_tick(&self) -> bool {
self.goal.every_tick()
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
self.goal.tick(m, cx);
}
fn name(&self) -> &'static str {
self.goal.name()
}
}
/// `Fox.isPathClear`: six points along the way to the target, each with
/// nothing but replaceable blocks in the three above it.
fn path_clear(m: &Mob, w: &MobWorld, to: Vec3) -> bool {
let (zdiff, xdiff) = (to.z - m.position.z, to.x - m.position.x);
let slope = zdiff / xdiff;
for i in 0..6 {
let step = f64::from(i as f32 / 6.0);
let z = if slope == 0.0 { 0.0 } else { zdiff * step };
let x = if slope == 0.0 {
xdiff * step
} else {
z / slope
};
for j in 1..4 {
let at = [
(m.position.x + x).floor() as i32,
(m.position.y + f64::from(j)).floor() as i32,
(m.position.z + z).floor() as i32,
];
if !w.facts.get(w.world.block(at[0], at[1], at[2])).replaceable {
return false;
}
}
}
true
}
fn signum(x: f64) -> f64 {
if x > 0.0 {
1.0
} else if x < 0.0 {
-1.0
} else {
0.0
}
}
/// `Fox.FaceplantGoal`: stay put for 40 ticks with the nose in the snow.
#[derive(Clone, Debug)]
struct Faceplant {
countdown: i32,
}
impl Goal for Faceplant {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK | JUMP
}
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
flag(m, FACEPLANTED)
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
flag(m, FACEPLANTED) && self.countdown > 0
}
fn start(&mut self, _: &mut Mob, _: &mut Cx) {
self.countdown = reduced(40);
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
set_flag(m, FACEPLANTED, false);
}
fn tick(&mut self, _: &mut Mob, _: &mut Cx) {
self.countdown -= 1;
}
}
/// `Fox.StalkPreyGoal`: creep up on a chicken or rabbit more than 6 blocks
/// away, then crouch and keep still once it is within 6.
#[derive(Clone, Debug)]
struct StalkPrey;
impl StalkPrey {
fn look(m: &mut Mob, eye: [f64; 3]) {
let (y, x) = (m.kind.max_head_y_rot, m.kind.max_head_x_rot);
m.look_at_speed(eye, y, x);
}
}
impl Goal for StalkPrey {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if flag(m, SLEEPING) {
return false;
}
let Some(t) = m.target_info(cx.w) else {
return false;
};
let stalkable = match m.ai.target {
Some(super::Who::Mob(id)) => {
cx.w.seen(id)
.is_some_and(|s| matches!(s.species, Species::Chicken | Species::Rabbit))
}
_ => false,
};
t.alive
&& stalkable
&& m.distance_sqr(t.position) > 36.0
&& !flag(m, CROUCHING | INTERESTED)
&& !m.ai.jumping
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
set_flag(m, SITTING | FACEPLANTED, false);
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
match m.target_info(cx.w) {
Some(t) if path_clear(m, cx.w, t.position) => {
set_flag(m, INTERESTED | CROUCHING, true);
m.nav_stop();
Self::look(m, [t.position.x, t.eye_y, t.position.z]);
}
_ => set_flag(m, INTERESTED | CROUCHING, false),
}
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let Some(t) = m.target_info(cx.w) else { return };
Self::look(m, [t.position.x, t.eye_y, t.position.z]);
if m.distance_sqr(t.position) <= 36.0 {
set_flag(m, INTERESTED | CROUCHING, true);
m.nav_stop();
} else {
let at = [t.position.x, t.position.y, t.position.z];
m.nav_move_to_entity(cx.w, at, 0, 1.5);
}
}
}
/// `Fox.FoxPounceGoal`: once fully crouched with a clear path, leap at the
/// target, tilt with the flight and bite when within 2 blocks, or end up with
/// the nose in snow.
#[derive(Clone, Debug)]
struct Pounce;
impl Goal for Pounce {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | JUMP
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !fully_crouched(m) {
return false;
}
let Some(t) = m.target_info(cx.w).filter(|t| t.alive) else {
return false;
};
let clear = path_clear(m, cx.w, t.position);
if !clear {
set_flag(m, CROUCHING | INTERESTED, false);
}
clear
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !m.target_info(cx.w).is_some_and(|t| t.alive) {
return false;
}
let yd = m.velocity.y;
(yd * yd >= f64::from(0.05f32) || m.head_pitch.abs() >= 15.0 || !m.on_ground)
&& !flag(m, FACEPLANTED)
}
fn interruptable(&self) -> bool {
false
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
m.ai.jumping = true;
set_flag(m, POUNCING, true);
set_flag(m, INTERESTED, false);
if let Some(t) = m.target_info(cx.w) {
m.look_at_speed([t.position.x, t.eye_y, t.position.z], 60.0, 30.0);
let u = m.position.vector_to(t.position).normalize();
m.velocity = m.velocity.add_vector(Vec3::new(u.x * 0.8, 0.9, u.z * 0.8));
}
m.nav_stop();
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
set_flag(m, CROUCHING | INTERESTED | POUNCING, false);
m.anim.v[CROUCH_AMOUNT] = 0.0;
m.anim.vo[CROUCH_AMOUNT] = 0.0;
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let target = m.target_info(cx.w);
if let Some(t) = &target {
m.look_at_speed([t.position.x, t.eye_y, t.position.z], 60.0, 30.0);
}
if !flag(m, FACEPLANTED) {
let v = m.velocity;
if v.y * v.y < f64::from(0.03f32) && m.head_pitch != 0.0 {
m.head_pitch += 0.2 * wrap(-m.head_pitch);
} else {
let horizontal = v.horizontal_distance();
let bias = if m.ai.jumping && v.y > 0.0 { 6.5 } else { 1.0 };
let y = v.y * bias;
let len = (horizontal * horizontal + y * y).sqrt();
if len > f64::from(1.0e-5f32) {
let pitch = signum(-y) * jmath::acos(horizontal / len) * 180.0
/ f64::from(std::f32::consts::PI);
m.head_pitch = (pitch as f32 % 360.0).clamp(-90.0, 90.0);
}
}
}
if let (Some(t), Some(who)) = (&target, m.ai.target)
&& m.distance_sqr(t.position) <= 4.0
{
super::ai::do_hurt_target(m, who, cx);
} else if m.head_pitch > 0.0
&& m.on_ground
&& m.velocity.y as f32 != 0.0
&& cx.w.block_name(
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
) == "minecraft:snow"
{
m.head_pitch = 60.0;
m.ai.target = None;
set_flag(m, FACEPLANTED, true);
}
}
}
/// `Fox.SeekShelterGoal`: by day, or in a thunderstorm, leave open sky for
/// somewhere the sky does not see. Villages are not told apart yet (vanilla
/// stays put in one).
#[derive(Clone, Debug)]
struct SeekShelter {
speed: f64,
interval: i32,
wanted: [f64; 3],
}
impl SeekShelter {
fn new(speed: f64) -> Self {
Self {
speed,
interval: reduced(100),
wanted: [0.0; 3],
}
}
fn set_wanted_pos(&mut self, m: &mut Mob, w: &MobWorld) -> bool {
hide_pos(m, w).is_some_and(|at| {
self.wanted = at;
true
})
}
}
impl Goal for SeekShelter {
goal_clone!();
fn flags(&self) -> u8 {
MOVE
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if flag(m, SLEEPING) || m.ai.target.is_some() {
return false;
}
let at = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let sees_sky = cx.w.can_see_sky(at[0], at[1], at[2]);
if cx.w.thundering && sees_sky {
self.set_wanted_pos(m, cx.w)
} else if self.interval > 0 {
self.interval -= 1;
false
} else {
self.interval = 100;
cx.w.daylight && sees_sky && self.set_wanted_pos(m, cx.w)
}
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
!m.ai.nav.is_done()
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
clear_states(m);
m.nav_move_to(cx.w, self.wanted, self.speed);
}
}
/// `Fox.PerchAndSearchGoal`: now and then sit down and look around two to
/// four times when nothing is going on.
#[derive(Clone, Debug, Default)]
struct Perch {
rel: [f64; 2],
look_time: i32,
looks_remaining: i32,
}
impl Perch {
fn reset_look(&mut self, m: &mut Mob) {
let angle = std::f64::consts::TAU * m.random.next_double();
self.rel = [angle.cos(), angle.sin()];
self.look_time = reduced(80 + m.random.next_int_bound(20).unwrap_or(0));
}
}
impl Goal for Perch {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.ai.last_hurt_by.is_none()
&& m.random.next_float() < 0.02
&& !flag(m, SLEEPING)
&& m.ai.target.is_none()
&& m.ai.nav.is_done()
&& !alertable(m, cx.w)
&& !flag(m, POUNCING | CROUCHING)
}
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
self.looks_remaining > 0
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
self.reset_look(m);
self.looks_remaining = 2 + m.random.next_int_bound(3).unwrap_or(0);
set_flag(m, SITTING, true);
m.nav_stop();
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
set_flag(m, SITTING, false);
}
fn tick(&mut self, m: &mut Mob, _: &mut Cx) {
self.look_time -= 1;
if self.look_time <= 0 {
self.looks_remaining -= 1;
self.reset_look(m);
}
let at = [
m.position.x + self.rel[0],
m.eye_y(),
m.position.z + self.rel[1],
];
let (y, x) = (m.kind.max_head_y_rot, m.kind.max_head_x_rot);
m.look_at_speed(at, y, x);
}
}
/// `BlockState` with one property changed.
fn with_prop(w: &MobWorld, p: [i32; 3], key: &str, value: &str) -> Option<BlockStateId> {
let mut s = w.registry.state(w.world.block(p[0], p[1], p[2]))?.clone();
s.properties.insert(key.into(), value.into());
w.registry.id(&s)
}
/// `FoxEatBerriesGoal.isValidTarget`: a ripe sweet berry bush or glow berries.
fn has_berries(w: &MobWorld, p: [i32; 3]) -> bool {
match w.block_name(p[0], p[1], p[2]) {
"minecraft:sweet_berry_bush" => w
.prop(p[0], p[1], p[2], "age")
.and_then(|a| a.parse::<i32>().ok())
.is_some_and(|a| a >= 2),
"minecraft:cave_vines" | "minecraft:cave_vines_plant" => {
w.prop(p[0], p[1], p[2], "berries") == Some("true")
}
_ => false,
}
}
/// `Fox.FoxEatBerriesGoal`: walk to a ripe berry bush and pick it after 40 ticks.
#[derive(Clone, Debug)]
struct EatBerries {
block: MoveToBlock,
waited: i32,
}
impl EatBerries {
fn new() -> Self {
Self {
block: MoveToBlock::new(1.2, 12, 1),
waited: 0,
}
}
fn pick(&mut self, m: &mut Mob, cx: &mut Cx) {
let p = self.block.block;
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
f64::from(p[2]) + 0.5,
];
if cx.w.block_name(p[0], p[1], p[2]) == "minecraft:sweet_berry_bush" {
let age: i32 =
cx.w.prop(p[0], p[1], p[2], "age")
.and_then(|a| a.parse().ok())
.unwrap_or(0);
let mut count = 1 + m.random.next_int_bound(2).unwrap_or(0) + i32::from(age == 3);
if m.extra.main_hand.is_none() {
m.extra.main_hand = Some("minecraft:sweet_berries".into());
count -= 1;
}
if count > 0 {
cx.events.push(MobEvent::DropItem {
item: "minecraft:sweet_berries".into(),
count: count as u32,
at,
});
}
if let Some(state) = with_prop(cx.w, p, "age", "1") {
cx.events.push(MobEvent::SetBlock { pos: p, state });
}
cx.events.push(MobEvent::Sound(
"block.sweet_berry_bush.pick_berries",
at,
1.0,
1.0,
));
} else {
// CaveVines.use: one glow berry, the vine goes back to bare.
cx.events.push(MobEvent::DropItem {
item: "minecraft:glow_berries".into(),
count: 1,
at,
});
if let Some(state) = with_prop(cx.w, p, "berries", "false") {
cx.events.push(MobEvent::SetBlock { pos: p, state });
}
let pitch = 0.8 + m.random.next_float() * 0.4;
cx.events.push(MobEvent::Sound(
"block.cave_vines.pick_berries",
at,
1.0,
pitch,
));
}
}
}
impl Goal for EatBerries {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | JUMP
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if flag(m, SLEEPING) {
return false;
}
// MoveToBlockGoal.canUse draws the next delay only once the last one
// has run out.
if self.block.next_start > 0 {
self.block.next_start -= 1;
return false;
}
let next = reduced(200 + m.random.next_int_bound(200).unwrap_or(0));
self.block.can_use(m, cx.w, next, has_berries)
}
fn can_continue(&mut self, _: &mut Mob, cx: &mut Cx) -> bool {
self.block.can_continue(cx.w, has_berries)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.waited = 0;
set_flag(m, SITTING, false);
self.block.start(m, cx.w);
}
fn every_tick(&self) -> bool {
true
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if self.block.reached {
if self.waited >= 40 {
if cx.w.mob_griefing {
self.pick(m, cx);
}
} else {
self.waited += 1;
}
} else if m.random.next_float() < 0.05 {
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.fox.sniff", at, 1.0, 1.0));
}
self.block.tick_within(m, cx.w, 2.0);
}
}
/// `FoxRenderState` as the renderer reads it: the flags, `getHeadRollAngle`
/// and `getCrouchAmount` at the partial tick. `FoxModel.legMotionPos` gains
/// 0.67 a frame; this takes 60 frames a second.
pub fn pose(m: &Mob, partial: f32) -> super::body_anim::Fox {
super::body_anim::Fox {
crouching: flag(m, CROUCHING),
sleeping: flag(m, SLEEPING),
sitting: flag(m, SITTING),
pouncing: flag(m, POUNCING),
faceplanted: flag(m, FACEPLANTED),
head_roll_angle: m.anim.lerp(INTERESTED_ANGLE, partial) * 0.11 * std::f32::consts::PI,
crouch_amount: m.anim.lerp(CROUCH_AMOUNT, partial),
leg_motion_pos: (m.age as f32 + partial) * 0.67 * 3.0,
}
}
+301
View File
@@ -0,0 +1,301 @@
//! Frogs (`Frog`, `FrogAi`): the `Croak` and `ShootTongue` behaviours as
//! goals, the poses they set and the client's animation states.
//! AnimState states: jump, croak, tongue, swim idle.
//!
//! The brain's idle `RunOne` picks a croak three times as often as a stroll
//! (weights 3 and 1; it also holds a do-nothing option that only repeats
//! the pick), so a frog that is not walking croaks in three of four idle
//! picks: `Croak` takes those, and the frog's stroll then follows at once.
use super::{
Mob, MobEvent, MobWorld, Species, Who,
goals::{
Cx, Float, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone,
},
sync,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
const JUMP: usize = 0;
const CROAK: usize = 1;
const TONGUE: usize = 2;
const SWIM_IDLE: usize = 3;
/// `Croak.CROAK_TICKS`.
const CROAK_TICKS: i32 = 60;
/// `ShootTongue`'s duration and eating distance.
const TONGUE_TIME_OUT: i32 = 100;
const EATING_DISTANCE: f64 = 1.75;
/// `FrogAttackablesSensor.TARGET_DETECTION_DISTANCE` and the sensor's scan
/// rate.
const DETECTION_DISTANCE: f64 = 10.0;
const SCAN_RATE: i32 = 20;
/// `ShootTongue.State`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Phase {
MoveToTarget,
CatchAnimation,
EatAnimation,
#[default]
Done,
}
/// The frog's brain memories and `DATA_TONGUE_TARGET_ID`.
#[derive(Clone, Debug, Default)]
pub struct State {
croak: i32,
/// Ticks to the attackables sensor's next scan, and `NEAREST_ATTACKABLE`.
scan: i32,
nearest: Option<u32>,
/// `UNREACHABLE_TONGUE_TARGETS`: the mob and the ticks it stays in mind.
unreachable: Vec<(u32, i32)>,
/// `ATTACK_TARGET` while `ShootTongue` runs, and the synched tongue target.
target: Option<u32>,
pub tongue_target: Option<u32>,
phase: Phase,
eat_timer: i32,
path_counter: i32,
time: i32,
}
pub fn register(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(0, Float);
g.add(1, Panic::new(1.25));
g.add(2, ShootTongue);
g.add(4, Croak);
g.add(5, Stroll::avoiding_water(1.0).interval(1));
g.add(6, LookAtPlayer::new(6.0));
g.add(7, RandomLookAround::default());
}
/// `Entity.setPose` and `Frog.onSyncedDataUpdated(DATA_POSE)`.
pub fn set_pose(m: &mut Mob, id: u8) {
let old = std::mem::replace(&mut m.extra.pose, id);
if old != id {
pose_changed(m);
}
}
/// The animation states of the pose the frog has taken.
pub fn pose_changed(m: &mut Mob) {
let age = m.age as f32;
let pose = m.extra.pose;
let states = &mut m.anim.states;
for (state, on) in [
(JUMP, pose == sync::LONG_JUMPING),
(CROAK, pose == sync::CROAKING),
(TONGUE, pose == sync::USING_TONGUE),
] {
if on {
states[state].start(age);
} else {
states[state].stop();
}
}
}
/// `Frog.tick` on a client: the idle swim while it floats still. The server
/// runs it too, for a window that shows its own mobs.
pub fn tick(m: &mut Mob) {
let age = m.age as f32;
let still = m.fluids.in_water && m.limb_speed <= 1.0e-5;
m.anim.states[SWIM_IDLE].animate_when(still, age);
}
/// `Frog.canEat`: a slime or magma cube of size 1.
fn can_eat(species: Species, size: i32) -> bool {
matches!(species, Species::Slime | Species::MagmaCube) && size == 1
}
/// `FrogAttackablesSensor` (every 20 ticks) and the memory timers.
pub fn server_ai_step(m: &mut Mob, w: &MobWorld) {
let f = &mut m.extra.frog;
f.unreachable.retain_mut(|(_, ttl)| {
*ttl -= 1;
*ttl > 0
});
f.scan -= 1;
if f.scan > 0 {
return;
}
f.scan = SCAN_RATE;
let unreachable: Vec<u32> = f.unreachable.iter().map(|u| u.0).collect();
let eye = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let nearest = w
.nearby(m.position, DETECTION_DISTANCE, |s| {
can_eat(s.species, s.size)
&& !unreachable.contains(&s.id)
&& w.line_of_sight(eye, Vec3::new(s.position.x, s.eye_y, s.position.z))
})
.find(|s| s.position.distance_squared(m.position) < DETECTION_DISTANCE * DETECTION_DISTANCE)
.map(|s| s.id);
m.extra.frog.nearest = nearest;
}
/// `Croak`: the pose sync::CROAKING for 60 ticks (the croak is the frog's ambient
/// sound).
#[derive(Clone, Debug)]
struct Croak;
impl Goal for Croak {
goal_clone!();
fn flags(&self) -> u8 {
MOVE
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
// checkExtraStartConditions (standing), no walk target, and not in
// a liquid (start does nothing there).
m.extra.pose == sync::STANDING
&& m.on_ground
&& !m.fluids.in_water
&& !m.fluids.in_lava
&& m.ai.nav.is_done()
&& m.random.next_int_bound(4).unwrap_or(0) != 0
}
fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
m.extra.frog.croak < CROAK_TICKS
}
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
m.extra.frog.croak = 0;
set_pose(m, sync::CROAKING);
}
fn stop(&mut self, m: &mut Mob, _cx: &mut Cx) {
set_pose(m, sync::STANDING);
}
fn tick(&mut self, m: &mut Mob, _cx: &mut Cx) {
m.extra.frog.croak += 1;
}
fn every_tick(&self) -> bool {
true
}
}
/// `ShootTongue` with the tongue activity's `StartAttacking` and
/// `StopAttackingIfTargetInvalid`: walk up to a small slime or magma cube,
/// shoot the tongue, pull it in and eat it.
#[derive(Clone, Debug)]
struct ShootTongue;
impl Goal for ShootTongue {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let Some(id) = m.extra.frog.nearest else {
return false;
};
// The start conditions: no walk target, no panic, not croaking.
if m.ai.panicking || m.extra.pose == sync::CROAKING || !m.ai.nav.is_done() {
return false;
}
let Some(t) = cx.w.seen(id).filter(|t| t.alive) else {
m.extra.frog.nearest = None;
return false;
};
// canPathfindToTarget: a path that ends at the target.
let at = [t.position.x, t.position.y, t.position.z];
let ends = m
.create_path_to_entity(cx.w, at, 0)
.is_some_and(|p| f64::from(p.dist_to_target) < EATING_DISTANCE);
if !ends {
// The target is forgotten and not taken for 100 ticks.
let f = &mut m.extra.frog;
f.nearest = None;
if !f.unreachable.iter().any(|u| u.0 == id) {
if f.unreachable.len() == 5 {
f.unreachable.remove(0);
}
f.unreachable.push((id, 100));
}
return false;
}
m.extra.frog.target = Some(id);
true
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let f = &m.extra.frog;
f.phase != Phase::Done
&& f.time <= TONGUE_TIME_OUT
&& !m.ai.panicking
&& f.target
.is_some_and(|id| cx.w.seen(id).is_some_and(|t| t.alive))
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
let Some(t) = m.extra.frog.target.and_then(|id| cx.w.seen(id)) else {
return;
};
let at = [t.position.x, t.position.y, t.position.z];
let eye = [t.position.x, t.eye_y, t.position.z];
m.look_at(eye);
m.extra.frog.tongue_target = m.extra.frog.target;
m.nav_move_to_entity(cx.w, at, 0, 2.0);
let f = &mut m.extra.frog;
f.path_counter = 10;
f.time = 0;
f.phase = Phase::MoveToTarget;
}
fn stop(&mut self, m: &mut Mob, _cx: &mut Cx) {
let f = &mut m.extra.frog;
f.target = None;
f.tongue_target = None;
f.phase = Phase::Done;
set_pose(m, sync::STANDING);
}
fn every_tick(&self) -> bool {
true
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let Some(id) = m.extra.frog.target else {
return;
};
let Some(t) = cx.w.seen(id) else {
return;
};
let (at, pos) = ([t.position.x, t.position.y, t.position.z], t.position);
m.look_at([t.position.x, t.eye_y, t.position.z]);
m.extra.frog.tongue_target = Some(id);
m.extra.frog.time += 1;
let here = [m.position.x, m.position.y, m.position.z];
match m.extra.frog.phase {
Phase::MoveToTarget => {
if pos.distance_squared(m.position) < EATING_DISTANCE * EATING_DISTANCE {
cx.events
.push(MobEvent::Sound("entity.frog.tongue", here, 2.0, 1.0));
set_pose(m, sync::USING_TONGUE);
// The target is drawn in at 0.75 a tick.
let pull = m.position.subtract(pos).normalize().scale(0.75);
cx.events.push(MobEvent::Push {
target: Who::Mob(id),
velocity: [pull.x, pull.y, pull.z],
});
let f = &mut m.extra.frog;
f.eat_timer = 0;
f.phase = Phase::CatchAnimation;
} else if m.extra.frog.path_counter <= 0 {
m.nav_move_to_entity(cx.w, at, 0, 2.0);
m.extra.frog.path_counter = 10;
} else {
m.extra.frog.path_counter -= 1;
}
}
Phase::CatchAnimation => {
let f = &mut m.extra.frog;
f.eat_timer += 1;
if f.eat_timer > 6 {
f.phase = Phase::EatAnimation;
// eatEntity.
cx.events
.push(MobEvent::Sound("entity.frog.eat", here, 2.0, 1.0));
super::ai::do_hurt_target(m, Who::Mob(id), cx);
}
}
Phase::EatAnimation => {
let f = &mut m.extra.frog;
if f.eat_timer >= 10 {
f.phase = Phase::Done;
} else {
f.eat_timer += 1;
}
}
Phase::Done => {}
}
}
}
+68 -17
View File
@@ -654,12 +654,15 @@ impl Goal for Float {
#[derive(Clone, Debug)]
pub struct Panic {
pub speed: f64,
/// Only the damage of `#panic_environmental_causes` (an armadillo).
pub environmental: bool,
pos: [f64; 3],
}
impl Panic {
pub fn new(speed: f64) -> Self {
Self {
speed,
environmental: false,
pos: [0.0; 3],
}
}
@@ -675,7 +678,14 @@ impl Goal for Panic {
MOVE
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !should_panic(m) {
let wanted = if self.environmental {
m.last_damage.is_some_and(|(d, t)| {
super::armadillo::environmental(d.kind) && i64::from(m.age) - t <= 40
}) || m.fire_ticks > 0 && !m.kind.flags.fire_immune
} else {
should_panic(m)
};
if !wanted {
return false;
}
if m.fire_ticks > 0 {
@@ -1442,6 +1452,9 @@ pub struct HurtByTarget {
/// `Bee.BeeHurtByOtherGoal`: kept only while angry, and alerting others
/// only when the attacker is in sight.
pub bee: bool,
/// Whom `alertOther` calls: a filter on the mob (`PandaHurtByTargetGoal`
/// calls aggressive pandas only).
pub alert_if: Option<fn(&Seen) -> bool>,
/// `PolarBear.PolarBearHurtByTargetGoal`: a hurt cub only calls the
/// adult bears and drops the target itself.
pub polar_bear: bool,
@@ -1455,6 +1468,7 @@ impl HurtByTarget {
alert_except: vec![],
ignore: vec![],
bee: false,
alert_if: None,
polar_bear: false,
timestamp: 0,
st: TargetState {
@@ -1475,6 +1489,10 @@ impl HurtByTarget {
self.bee = true;
self
}
pub fn alert_if(mut self, f: fn(&Seen) -> bool) -> Self {
self.alert_if = Some(f);
self
}
pub fn polar_bear(mut self) -> Self {
self.polar_bear = true;
self
@@ -1552,6 +1570,7 @@ impl Goal for HurtByTarget {
&& (s.position.z - pos.z).abs() <= range + 1.0
})
.filter(|s| !self.alert_except.contains(&s.species))
.filter(|s| self.alert_if.is_none_or(|f| f(s)))
// PolarBearHurtByTargetGoal.alertOther: adults only.
.filter(|s| !(self.polar_bear && s.baby))
.filter(|s| Who::Mob(s.id) != by)
@@ -1591,6 +1610,10 @@ pub struct AvoidEntity {
pub sprint: f64,
/// Extra condition on the avoider (e.g. untamed cats only).
pub when: Option<fn(&Mob) -> bool>,
/// Extra condition on the player or mob avoided (`AvoidEntityGoal`'s
/// predicate: a sneaking player, a tame wolf).
pub player_when: Option<fn(&super::PlayerView) -> bool>,
pub seen_when: Option<fn(&Seen) -> bool>,
from: Vec3,
/// The mob being avoided (`toAvoid`), when it is one.
pub found: Option<u32>,
@@ -1603,6 +1626,8 @@ impl AvoidEntity {
walk,
sprint,
when: None,
player_when: None,
seen_when: None,
from: Vec3::ZERO,
found: None,
}
@@ -1611,6 +1636,14 @@ impl AvoidEntity {
self.when = Some(f);
self
}
pub fn player_when(mut self, f: fn(&super::PlayerView) -> bool) -> Self {
self.player_when = Some(f);
self
}
pub fn seen_when(mut self, f: fn(&Seen) -> bool) -> Self {
self.seen_when = Some(f);
self
}
}
impl Goal for AvoidEntity {
goal_clone!();
@@ -1631,6 +1664,7 @@ impl Goal for AvoidEntity {
.filter(|p| {
p.alive
&& !p.creative
&& self.player_when.is_none_or(|f| f(p))
&& m.distance_sqr(p.position) <= max * max
&& (p.position.y - m.position.y).abs() <= max + 3.0
})
@@ -1639,10 +1673,12 @@ impl Goal for AvoidEntity {
Avoid::Species(list) => {
let id = m.id;
let y = m.position.y;
let seen_when = self.seen_when;
let hit =
cx.w.nearby(m.position, max, move |s| {
s.id != id
&& list.contains(&s.species)
&& seen_when.is_none_or(|f| f(s))
&& (s.position.y - y).abs() <= 3.0 + max
})
.map(|s| (s.id, s.position))
@@ -1778,7 +1814,7 @@ impl Goal for Tempt {
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
let Some(p) = cx.w.player.clone() else { return };
let (y, x) = (m.kind.max_head_y_rot + 20.0, m.kind.max_head_x_rot);
let (y, x) = (m.kind.max_head_y_rot + 20.0, m.max_head_x_rot());
m.look_at_speed(p.eye, y, x);
if m.distance_sqr(p.position) < self.stop_distance * self.stop_distance {
m.nav_stop();
@@ -1887,7 +1923,7 @@ impl Goal for Breed {
let Some(p) = self.partner.and_then(|p| cx.w.seen(p)).cloned() else {
return;
};
let x = m.kind.max_head_x_rot;
let x = m.max_head_x_rot();
m.look_at_speed([p.position.x, p.eye_y, p.position.z], 10.0, x);
m.nav_move_to_entity(
cx.w,
@@ -2021,6 +2057,30 @@ impl Goal for RestrictSun {
m.ai.nav.avoid_sun = false;
}
}
/// `FleeSunGoal.getHidePos`: a spot within 10 blocks that the sky does not
/// see and the mob likes.
pub fn hide_pos(m: &mut Mob, w: &MobWorld) -> Option<[f64; 3]> {
let (bx, by, bz) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
for _ in 0..10 {
let p = [
bx + m.random.next_int_bound(20).unwrap_or(0) - 10,
by + m.random.next_int_bound(6).unwrap_or(0) - 3,
bz + m.random.next_int_bound(20).unwrap_or(0) - 10,
];
if !w.can_see_sky(p[0], p[1], p[2]) && m.walk_target_value(w, p) < 0.0 {
return Some([
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
]);
}
}
None
}
/// FleeSunGoal: find shade while burning.
#[derive(Clone, Debug)]
pub struct FleeSun {
@@ -2052,22 +2112,13 @@ impl Goal for FleeSun {
if !cx.w.can_see_sky(bx, by, bz) {
return false;
}
for _ in 0..10 {
let p = [
bx + m.random.next_int_bound(20).unwrap_or(0) - 10,
by + m.random.next_int_bound(6).unwrap_or(0) - 3,
bz + m.random.next_int_bound(20).unwrap_or(0) - 10,
];
if !cx.w.can_see_sky(p[0], p[1], p[2]) && m.walk_target_value(cx.w, p) < 0.0 {
self.wanted = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
return true;
match hide_pos(m, cx.w) {
Some(at) => {
self.wanted = at;
true
}
None => false,
}
false
}
fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
!m.ai.nav.is_done()
+443
View File
@@ -0,0 +1,443 @@
//! Goats (`Goat`, `GoatAi`): the lowered head (`lowerHeadTick`, entity
//! events 58 and 59) and the ram activity: `PrepareRamNearestTarget` picks
//! the nearest living thing it can see, walks to a spot four to seven
//! blocks away in a straight line and waits there 20 ticks with its head
//! down; `RamTarget` then charges at it, knocks it back, or breaks a horn
//! on a log, stone or ore. Both are one goal here.
use super::{
Damage, Mob, MobEvent, MobWorld, Who,
goals::{Cx, Goal, LOOK, MOVE, goal_clone},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// `GoatAi.TIME_BETWEEN_RAMS` and `TIME_BETWEEN_RAMS_SCREAMER`.
const RAMS: (i32, i32) = (600, 6000);
const RAMS_SCREAMER: (i32, i32) = (100, 300);
const RAM_PREPARE_TIME: i64 = 20;
const MIN_RAM_DISTANCE: i32 = 4;
const MAX_RAM_DISTANCE: i32 = 7;
/// `PrepareRamNearestTarget.TIME_OUT_DURATION` and `RamTarget`'s.
const PREPARE_TIME_OUT: i32 = 160;
const RAM_TIME_OUT: i32 = 200;
/// `NEAREST_VISIBLE_LIVING_ENTITIES` reaches 16 blocks.
const SENSE_RANGE: f64 = 16.0;
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Phase {
#[default]
Done,
Prepare,
/// `RAM_TARGET` is set; `RamTarget` starts on the next tick.
RamStart,
Ram,
}
#[derive(Clone, Copy, Debug)]
struct Candidate {
start: [i32; 3],
target_pos: [i32; 3],
target: Who,
}
/// `RAM_COOLDOWN_TICKS`, the lowered head and the ram behaviours' state.
#[derive(Clone, Debug, Default)]
pub struct State {
/// `RAM_COOLDOWN_TICKS`: 0 when absent.
pub ram_cooldown: i32,
lower_head_tick: i32,
lowering_head: bool,
phase: Phase,
candidate: Option<Candidate>,
reached_at: Option<i32>,
/// `RAM_TARGET`, and the direction of the charge.
ram_target: Option<Vec3>,
ram_direction: Vec3,
time: i32,
}
pub fn register(m: &mut Mob) {
m.ai.goals.add(2, Ram);
}
/// `Goat.handleEntityEvent`: 58 lowers the head, 59 raises it.
pub fn entity_event(m: &mut Mob, event: u8) {
match event {
58 => m.extra.goat.lowering_head = true,
59 => m.extra.goat.lowering_head = false,
_ => {}
}
}
/// `Goat.aiStep`'s `lowerHeadTick`; a client runs it too.
pub fn tick(m: &mut Mob) {
let g = &mut m.extra.goat;
g.lower_head_tick += if g.lowering_head { 1 } else { -2 };
g.lower_head_tick = g.lower_head_tick.clamp(0, 20);
if g.ram_cooldown > 0 {
g.ram_cooldown -= 1;
}
}
/// `Goat.getRammingXHeadRot`.
pub fn ramming_x_head_rot(m: &Mob) -> f32 {
let max = if m.is_baby() { 52.5_f32 } else { 30.0 };
m.extra.goat.lower_head_tick as f32 / 20.0 * max * (std::f32::consts::PI / 180.0)
}
/// A server's `level.broadcastEntityEvent(goat, 58 or 59)`, sent when it
/// changes what the goat does.
fn lower_head(m: &mut Mob, events: &mut Vec<MobEvent>, lower: bool) {
if m.extra.goat.lowering_head != lower {
let event: i8 = if lower { 58 } else { 59 };
entity_event(m, event as u8);
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::EntityEvent {
mob: m.id,
event,
at,
});
}
}
fn between(m: &mut Mob, (min, max): (i32, i32)) -> i32 {
m.random.next_int_between_inclusive(min, max).unwrap_or(min)
}
fn rams(m: &Mob) -> (i32, i32) {
if m.extra.screaming {
RAMS_SCREAMER
} else {
RAMS
}
}
fn block_of(p: Vec3) -> [i32; 3] {
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
}
fn play(m: &Mob, events: &mut Vec<MobEvent>, sound: &'static str, pitch: f32) {
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound(sound, at, 1.0, pitch));
}
}
/// `PrepareRamNearestTarget.isWalkableBlock`.
fn walkable(m: &Mob, w: &MobWorld, p: [i32; 3]) -> bool {
m.stable_destination(w, p) && m.malus(w.path_type_static(p[0], p[1], p[2])) == 0.0
}
/// `PrepareRamNearestTarget.calculateRammingStartPosition`.
fn ramming_start(m: &mut Mob, w: &MobWorld, target: [i32; 3]) -> Option<[i32; 3]> {
if !walkable(m, w, target) {
return None;
}
// Direction.Plane.HORIZONTAL: north, east, south, west.
let mut starts = Vec::new();
for (dx, dz) in [(0, -1), (1, 0), (0, 1), (-1, 0)] {
let mut p = target;
for _ in 0..MAX_RAM_DISTANCE {
p = [p[0] + dx, p[1], p[2] + dz];
if !walkable(m, w, p) {
p = [p[0] - dx, p[1], p[2] - dz];
break;
}
}
let manhattan =
(p[0] - target[0]).abs() + (p[1] - target[1]).abs() + (p[2] - target[2]).abs();
if manhattan >= MIN_RAM_DISTANCE {
starts.push(p);
}
}
let here = block_of(m.position);
let sqr = |p: &[i32; 3]| {
(0..3)
.map(|i| i64::from(p[i] - here[i]).pow(2))
.sum::<i64>()
};
starts.sort_by_key(sqr);
starts
.into_iter()
.find(|&p| m.create_path_to(w, p, 0).is_some_and(|path| path.reached))
}
/// `PrepareRamNearestTarget.chooseRamPosition`.
fn choose(m: &mut Mob, w: &MobWorld, target: Who) {
let g = &mut m.extra.goat;
g.reached_at = None;
let Some(t) = w.target_info(target) else {
g.candidate = None;
return;
};
let target_pos = block_of(t.position);
m.extra.goat.candidate = ramming_start(m, w, target_pos).map(|start| Candidate {
start,
target_pos,
target,
});
}
/// The nearest living thing the goat sees that `RAM_TARGET_CONDITIONS`
/// allow: not a goat, a player who is not creative or a spectator.
fn nearest_target(m: &mut Mob, w: &MobWorld) -> Option<Who> {
let mut best: Option<(Who, f64)> = None;
if let Some(p) = &w.player
&& p.alive
&& !p.creative
&& !p.spectator
&& !p.allies.contains(&m.id)
&& m.distance_sqr(p.position) <= SENSE_RANGE * SENSE_RANGE
&& m.sees(w, Who::Player)
{
best = Some((Who::Player, m.distance_sqr(p.position)));
}
let id = m.id;
let mobs: Vec<(u32, f64)> = w
.nearby(m.position, SENSE_RANGE, |s| {
s.id != id && s.species != super::Species::Goat && !s.playing_dead
})
.map(|s| (s.id, m.distance_sqr(s.position)))
.collect();
for (mob, d) in mobs {
if best.is_none_or(|b| d < b.1) && m.sees(w, Who::Mob(mob)) {
best = Some((Who::Mob(mob), d));
}
}
best.map(|b| b.0)
}
/// `PrepareRamNearestTarget` and `RamTarget` of the ram activity.
#[derive(Clone, Debug)]
struct Ram;
impl Goal for Ram {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
// The ram activity needs the cooldown to be over (it also waits for
// no tempting player and no breed target, which goats lack here).
m.extra.goat.ram_cooldown <= 0
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let g = &m.extra.goat;
match g.phase {
Phase::Done => false,
Phase::Prepare => {
g.time <= PREPARE_TIME_OUT
&& g.candidate
.is_some_and(|c| cx.w.target_info(c.target).is_some_and(|t| t.alive))
}
Phase::RamStart | Phase::Ram => g.ram_target.is_some() && g.time <= RAM_TIME_OUT,
}
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.goat.phase = Phase::Prepare;
m.extra.goat.time = 0;
m.extra.goat.reached_at = None;
m.extra.goat.candidate = None;
if let Some(target) = nearest_target(m, cx.w) {
choose(m, cx.w, target);
}
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
match m.extra.goat.phase {
// PrepareRamNearestTarget.stop: no ram target, so the head comes
// up and the cooldown starts.
Phase::Prepare => {
lower_head(m, cx.events, false);
m.extra.goat.ram_cooldown = rams(m).0;
}
// RamTarget.finishRam (also what its timeout leaves undone).
Phase::RamStart | Phase::Ram => finish_ram(m, cx.events),
Phase::Done => {}
}
m.extra.goat.phase = Phase::Done;
m.extra.goat.candidate = None;
}
fn every_tick(&self) -> bool {
true
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.goat.time += 1;
match m.extra.goat.phase {
Phase::Prepare => prepare_tick(m, cx),
Phase::RamStart => {
// RamTarget.start: charge from here at the ram target.
let (Some(target), here) = (m.extra.goat.ram_target, block_of(m.position)) else {
return;
};
let d = Vec3::new(
f64::from(here[0]) - target.x,
0.0,
f64::from(here[2]) - target.z,
);
m.extra.goat.ram_direction = d.normalize();
m.extra.goat.phase = Phase::Ram;
m.extra.goat.time = 0;
m.nav_move_to_entity(cx.w, [target.x, target.y, target.z], 0, 3.0);
ram_tick(m, cx);
}
Phase::Ram => ram_tick(m, cx),
Phase::Done => {}
}
}
}
/// `PrepareRamNearestTarget.tick`.
fn prepare_tick(m: &mut Mob, cx: &mut Cx) {
let Some(c) = m.extra.goat.candidate else {
return;
};
let Some(t) = cx.w.target_info(c.target) else {
return;
};
m.look_at([t.position.x, t.eye_y, t.position.z]);
let now = m.age as i32;
let at = [
f64::from(c.start[0]) + 0.5,
f64::from(c.start[1]),
f64::from(c.start[2]) + 0.5,
];
if m.ai.nav.is_done() && block_of(m.position) != c.start {
m.nav_move_to_entity(cx.w, at, 0, 1.25);
}
if block_of(t.position) != c.target_pos {
// The target moved: the head comes up and a new spot is chosen.
lower_head(m, cx.events, false);
m.nav_stop();
choose(m, cx.w, c.target);
} else if block_of(m.position) == c.start {
lower_head(m, cx.events, true);
let since = *m.extra.goat.reached_at.get_or_insert(now);
if i64::from(now - since) >= RAM_PREPARE_TIME {
// getEdgeOfBlock.
let (x, z) = (
f64::from(c.target_pos[0])
+ 0.5
+ 0.5 * f64::from(sign(c.target_pos[0] - c.start[0])),
f64::from(c.target_pos[2])
+ 0.5
+ 0.5 * f64::from(sign(c.target_pos[2] - c.start[2])),
);
m.extra.goat.ram_target = Some(Vec3::new(x, f64::from(c.target_pos[1]), z));
let sound = if m.extra.screaming {
"entity.goat.screaming.prepare_ram"
} else {
"entity.goat.prepare_ram"
};
let pitch = m.voice_pitch();
play(m, cx.events, sound, pitch);
m.extra.goat.candidate = None;
m.extra.goat.phase = Phase::RamStart;
}
}
}
fn sign(v: i32) -> i32 {
v.signum()
}
/// `RamTarget.tick`.
fn ram_tick(m: &mut Mob, cx: &mut Cx) {
let impact = if m.extra.screaming {
"entity.goat.screaming.ram_impact"
} else {
"entity.goat.ram_impact"
};
let from = [m.position.x, m.position.y, m.position.z];
if let Some(target) = rammed(m, cx.w) {
// hurtServer by `noAggroMobAttack`, then the knockback.
let amount = m.attrs.attack_damage;
match target {
Who::Player => cx.events.push(MobEvent::DamagePlayer {
amount,
from,
kind: "minecraft:mob_attack_no_aggro",
}),
Who::Mob(id) => cx.events.push(MobEvent::AttackMob {
target: id,
amount,
source: Damage::mob(m.id, from),
}),
}
let level = |effect: &str| {
m.extra
.effects
.iter()
.find(|e| e.0 == effect)
.map_or(0, |e| e.2 + 1)
};
let boost = 0.25 * (level("minecraft:speed") - level("minecraft:slowness")) as f32;
let factor = (m.ai.speed * 1.65).clamp(0.2, 3.0) + boost;
let force = if m.is_baby() { 1.0 } else { 2.5 };
let d = m.extra.goat.ram_direction;
cx.events.push(MobEvent::Knockback {
target,
power: f64::from(factor * force),
x: d.x,
z: d.z,
});
finish_ram(m, cx.events);
play(m, cx.events, impact, 1.0);
} else if rammed_horn_block(m, cx.w) {
play(m, cx.events, impact, 1.0);
if drop_horn(m, cx.events) {
play(m, cx.events, "entity.goat.horn_break", 1.0);
}
finish_ram(m, cx.events);
} else if m.ai.nav.is_done() || m.extra.goat.ram_target.is_none() {
// The walk target is gone: the charge ended where it could.
finish_ram(m, cx.events);
}
}
/// The first living thing in the goat's box that `RAM_TARGET_CONDITIONS`
/// allow.
fn rammed(m: &Mob, w: &MobWorld) -> Option<Who> {
let bounds = m.bounds();
if let Some(p) = &w.player
&& p.alive
&& !p.creative
&& !p.spectator
&& !p.allies.contains(&m.id)
&& p.bounds.intersects(bounds)
{
return Some(Who::Player);
}
w.others_near(bounds)
.find(|s| {
s.id != m.id
&& s.alive
&& s.species != super::Species::Goat
&& !s.playing_dead
&& s.bounds.intersects(bounds)
})
.map(|s| Who::Mob(s.id))
}
/// `RamTarget.hasRammedHornBreakingBlock`: the block one step ahead, or the
/// one above it, snaps a horn.
fn rammed_horn_block(m: &Mob, w: &MobWorld) -> bool {
let ahead = Vec3::new(m.velocity.x, 0.0, m.velocity.z).normalize();
let at = block_of(m.position.add_vector(ahead));
let snaps =
|p: [i32; 3]| w.has_tag("minecraft:snaps_goat_horn", w.block_name(p[0], p[1], p[2]));
snaps(at) || snaps([at[0], at[1] + 1, at[2]])
}
/// `Goat.dropHorn`: one of the horns it still has comes off and drops.
fn drop_horn(m: &mut Mob, events: &mut Vec<MobEvent>) -> bool {
if m.is_baby() {
return false;
}
let [left_lost, right_lost] = m.extra.lost_horns;
if left_lost && right_lost {
return false;
}
let lose_left = if left_lost {
false
} else if right_lost {
true
} else {
m.random.next_boolean()
};
m.extra.lost_horns[usize::from(!lose_left)] = true;
events.push(MobEvent::DropItem {
item: "minecraft:goat_horn".into(),
count: 1,
at: [m.position.x, m.position.y, m.position.z],
});
true
}
/// `RamTarget.finishRam`.
fn finish_ram(m: &mut Mob, events: &mut Vec<MobEvent>) {
lower_head(m, events, false);
let wait = rams(m);
m.extra.goat.ram_cooldown = between(m, wait);
m.extra.goat.ram_target = None;
m.extra.goat.phase = Phase::Done;
}
+33 -1
View File
@@ -157,12 +157,16 @@ impl Goal for OfferFlower {
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
self.ticks > 0
}
fn start(&mut self, m: &mut Mob, _: &mut Cx) {
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.ticks = 400;
m.extra.counter = 400;
// IronGolem.offerFlower(true): entity event 11.
entity_event(m, 11, cx);
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
m.extra.counter = 0;
// IronGolem.offerFlower(false): entity event 34.
entity_event(m, 34, cx);
// A copper golem the offer ran out beside takes the poppy on its
// antenna (#accepts_iron_golem_gift) when it has none.
if self.ticks == 0
@@ -198,6 +202,7 @@ impl Goal for OfferFlower {
/// the target is thrown upward.
pub fn iron_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
m.extra.anim_ticks = 10;
entity_event(m, 4, cx);
let attack = m.attrs.attack_damage;
let amount = if attack as i32 > 0 {
attack / 2.0 + m.random.next_int_bound(attack as i32).unwrap_or(0) as f32
@@ -224,6 +229,33 @@ pub fn iron_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
1.0,
));
}
/// `Level.broadcastEntityEvent` for an iron golem (guests run `client_event`).
fn entity_event(m: &Mob, event: i8, cx: &mut Cx) {
cx.events.push(MobEvent::EntityEvent {
mob: m.id,
event,
at: [m.position.x, m.position.y, m.position.z],
});
}
/// `IronGolem.handleEntityEvent` on a client: 4 the swing, 11 and 34 the
/// poppy offer starting and stopping.
pub fn client_event(m: &mut Mob, event: u8) {
match event {
4 => m.extra.anim_ticks = 10,
11 => m.extra.counter = 400,
34 => m.extra.counter = 0,
_ => {}
}
}
/// The swing and offer clocks of `IronGolem.aiStep` on a client.
pub fn client_tick(m: &mut Mob) {
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
}
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
}
/// `IronGolem.aiStep` timers and anger.
pub fn iron_ai_step(m: &mut Mob) {
if m.extra.anim_ticks > 0 {
+29 -17
View File
@@ -214,12 +214,9 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
}
true
}
/// `Guardian.aiStep`: the client animations, air, flopping on land, the
/// beam's clock, and the spikes' thorns.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() {
return;
}
/// `Guardian.aiStep`'s `clientSideTailAnimation` and
/// `clientSideSpikesAnimation` (the server's and a client's).
pub fn tail_and_spikes(m: &mut Mob) {
let moving = m.extra.flag2;
let in_water = m.fluids.in_water;
// Tail and spikes (clientSideTailAnimation / clientSideSpikesAnimation).
@@ -244,6 +241,16 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
} else {
a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06;
}
}
/// `Guardian.aiStep`: the client animations, air, flopping on land, the
/// beam's clock, and the spikes' thorns.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if !m.alive() {
return;
}
let moving = m.extra.flag2;
let in_water = m.fluids.in_water;
tail_and_spikes(m);
client_tick(m);
if in_water {
m.air = 300;
@@ -276,17 +283,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
None => w.player.as_ref().map(|p| p.eye),
};
if let Some(look) = look {
m.anim.v[EYE_Y] = if look[1] - m.eye_y() > 0.0 { 0.0 } else { 1.0 };
let yaw = m.head_yaw.to_radians();
let pitch = m.head_pitch.to_radians();
let view = [-yaw.msin() * pitch.mcos(), yaw.mcos() * pitch.mcos()];
let d = [m.position.x - look[0], m.position.z - look[2]];
let len = (d[0] * d[0] + d[1] * d[1]).sqrt().max(1e-9);
let (dx, dz) = ((d[0] / len) as f32, (d[1] / len) as f32);
// Vec3.yRot(pi / 2) of the direction from the target.
let side = [dz, -dx];
let dot = view[0] * side[0] + view[1] * side[1];
m.anim.v[EYE_X] = dot.abs().sqrt() * 2.0 * dot.signum();
look_at(m, look);
}
// Guardian.hurtServer: a still guardian's spikes hurt a melee attacker.
if m.hurt_time == 10
@@ -317,6 +314,21 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
/// `GuardianModel.setupAnim`'s eye: it follows `look` (the attack target's
/// eye, else the camera's), from where the guardian faces.
pub fn look_at(m: &mut Mob, look: [f64; 3]) {
m.anim.v[EYE_Y] = if look[1] - m.eye_y() > 0.0 { 0.0 } else { 1.0 };
let yaw = m.head_yaw.to_radians();
let pitch = m.head_pitch.to_radians();
let view = [-yaw.msin() * pitch.mcos(), yaw.mcos() * pitch.mcos()];
let d = [m.position.x - look[0], m.position.z - look[2]];
let len = (d[0] * d[0] + d[1] * d[1]).sqrt().max(1e-9);
let (dx, dz) = ((d[0] / len) as f32, (d[1] / len) as f32);
// Vec3.yRot(pi / 2) of the direction from the target.
let side = [dz, -dx];
let dot = view[0] * side[0] + view[1] * side[1];
m.anim.v[EYE_X] = dot.abs().sqrt() * 2.0 * dot.signum();
}
/// `ElderGuardian.customServerAiStep`: every minute, mining fatigue III for
/// players within 50 blocks; a home within 16 blocks.
pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) {
@@ -544,14 +544,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
m.ai.home = Some((here, radius));
}
}
// continuousHeal: a point every 30 seconds, every second in rain.
// continuousHeal: a point every 30 seconds, every second in rain or snow
// (Level.precipitationAt).
if m.alive() && m.death_time == 0 && m.health < m.attrs.max_health {
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let wet = w.raining && w.can_see_sky(x, y, z);
let wet = w.precipitation_at(x, y, z).is_some();
let interval = if wet { 20 } else { 600 };
if m.age.is_multiple_of(interval) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
@@ -165,6 +165,12 @@ impl AnimationState {
pub fn stop(&mut self) {
self.started = None;
}
/// `AnimationState.fastForward`: the animation is `ticks` further on.
pub fn fast_forward(&mut self, ticks: i32) {
if let Some(s) = &mut self.started {
*s -= ticks as f32;
}
}
pub fn animate_when(&mut self, condition: bool, age: f32) {
if condition {
self.start_if_stopped(age)
+158 -2
View File
@@ -9,8 +9,15 @@ use crate::{
};
use horizon_core::{
geometry::{Aabb, Vec3, trig},
position::BlockPos,
random::RandomSource,
};
use horizon_world::{
entity_fluid::{CurrentAccumulator, CurrentTarget},
fluid::{FluidKind, FluidState},
fluid_flow::{FluidFlowWorld, flow_at},
registry::BlockStateId,
};
/// `Entity.moveDist`, `flyDist` and `nextStep`.
#[derive(Clone, Copy, Debug)]
@@ -36,6 +43,50 @@ pub struct Fluids {
pub in_lava: bool,
pub eye_in_water: bool,
}
/// `FluidFlowWorld` over the mob's scene: the neighbours a flow vector reads.
struct FlowScene<'a, 'w>(&'a MobWorld<'w>);
impl FluidFlowWorld for FlowScene<'_, '_> {
type Error = std::convert::Infallible;
type Block = BlockStateId;
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Self::Error> {
Ok(self.0.fluid_tables.fluid(self.0.world.block(p.x, p.y, p.z)))
}
fn block(&mut self, p: BlockPos) -> Result<BlockStateId, Self::Error> {
Ok(self.0.world.block(p.x, p.y, p.z))
}
fn blocks_fluid_flow(&self, s: BlockStateId) -> bool {
self.0.fluid_tables.blocks_flow(s)
}
fn is_ice_class(&self, s: BlockStateId) -> bool {
self.0.fluid_tables.ice(s)
}
fn is_face_sturdy(
&mut self,
s: BlockStateId,
_: BlockPos,
d: horizon_core::geometry::Direction,
) -> Result<bool, Self::Error> {
Ok(self.0.fluid_tables.sturdy(s, d))
}
}
/// The mob's velocity, for `CurrentAccumulator::apply_to`.
struct Pushed<'a>(&'a mut Vec3);
impl CurrentTarget for Pushed<'_> {
type Error = std::convert::Infallible;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> Result<Vec3, Self::Error> {
Ok(*self.0)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Self::Error> {
let sum = Vec3::new(self.0.x + delta.x, self.0.y + delta.y, self.0.z + delta.z);
if delta.is_finite() && sum.is_finite() {
*self.0 = sum;
}
Ok(())
}
}
pub fn wrap(a: f32) -> f32 {
let mut a = a % 360.0;
if a >= 180.0 {
@@ -140,6 +191,40 @@ impl MobWorld<'_> {
}
out
}
/// Entity.updateFluidHeightAndDoFluidPushing's currents over the deflated
/// box: the flow of every water and lava block it touches, summed (a thin
/// fluid layer scales its flow down), as (water, lava).
fn fluid_currents(&self, bounds: Aabb) -> (CurrentAccumulator, CurrentAccumulator) {
let base_y = bounds.min_position().y;
let b = bounds.deflate(Vec3::new(0.001, 0.001, 0.001));
let (lo, hi) = (b.min_position(), b.max_position());
let mut scene = FlowScene(self);
let (mut water, mut lava) = (CurrentAccumulator::default(), CurrentAccumulator::default());
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
let p = BlockPos::new(x, y, z);
let state = self.fluid_tables.fluid(self.world.block(x, y, z));
let current = match state.kind() {
FluidKind::Empty => continue,
FluidKind::Water | FluidKind::FlowingWater => &mut water,
FluidKind::Lava | FluidKind::FlowingLava => &mut lava,
};
let surface = f64::from(y) + self.fluid_height(x, y, z).1;
if surface < lo.y {
continue;
}
current.height = current.height.max(surface - base_y);
let mut flow = flow_at(&mut scene, state, p).unwrap_or(Vec3::ZERO);
if current.height < 0.4 {
flow = flow.scale(current.height);
}
current.accumulate(flow);
}
}
}
(water, lava)
}
/// `BlockState.getCollisionShape(level, pos)` (the empty collision
/// context) as boxes in the world.
pub fn block_collision_boxes(&self, x: i32, y: i32, z: i32) -> Vec<Aabb> {
@@ -235,6 +320,18 @@ impl MobWorld<'_> {
};
scene.unobstructed(b).unwrap_or(false)
}
/// `Entity.isFree`: no collision and `LevelReader.containsAnyLiquid` false
/// over the blocks from the box's minimum corner to its maximum corner.
pub fn is_free(&self, b: Aabb) -> bool {
let (lo, hi) = (b.min_position(), b.max_position());
let liquid = (lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
(lo.y.floor() as i32..=hi.y.floor() as i32).any(|y| {
(lo.z.floor() as i32..=hi.z.floor() as i32)
.any(|z| self.facts.get(self.world.block(x, y, z)).fluid != Fluid::None)
})
});
!liquid && self.no_collision(b)
}
}
impl Mob {
/// `Entity.refreshDimensions` after growing from `old_w` by `old_h`:
@@ -281,6 +378,15 @@ impl Mob {
return (f64::from(0.2_f32), f64::from(0.2_f32), f64::from(0.2_f32));
}
let k = &self.kind;
// Avatar.POSES: a player's or mannequin's box follows its pose.
if matches!(&*k.id, "minecraft:player" | "minecraft:mannequin") {
let d = self.extra.avatar_pose.dimensions(1.0);
return (
f64::from(d.width),
f64::from(d.height),
f64::from(d.eye_height),
);
}
let (mut w, mut h, mut e) = (k.width as f32, k.height as f32, k.eye_height as f32);
// Sniffer.DIGGING_DIMENSIONS: 0.4 lower, its eyes at 0.81.
if k.species == super::Species::Sniffer && super::sniffer::digging(self) {
@@ -334,6 +440,27 @@ impl Mob {
pub fn is_in_liquid(&self) -> bool {
self.fluids.in_water || self.fluids.in_lava
}
/// `Entity.isPushedByFluid`: water animals, turtles, axolotls, frogs and
/// nautiluses keep their own course.
fn pushed_by_fluid(&self) -> bool {
use super::Species as S;
!matches!(
self.species(),
S::Tadpole
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Squid
| S::GlowSquid
| S::Dolphin
| S::Turtle
| S::Axolotl
| S::Frog
| S::Nautilus
| S::ZombieNautilus
)
}
fn affected_by_fluids(&self) -> bool {
true
}
@@ -428,6 +555,17 @@ impl Mob {
// Entity.checkSupportingBlock
self.supporting = self.supporting_block(w, moved);
self.movement_emission(w, moved, events);
// Entity.applyEffectsFromBlocks: bubble columns push the mob.
if crate::motion::bubble_column_push(
w.world,
w.registry,
w.facts,
w.collision,
self.bounds(),
&mut self.velocity,
) {
self.fall_distance = 0.0;
}
}
/// LivingEntity.checkFallDamage: the fluid state refreshed out of water
/// (touching water resets the fall), then Entity.checkFallDamage.
@@ -450,6 +588,22 @@ impl Mob {
pub(super) fn update_fluids(&mut self, w: &MobWorld, events: &mut Vec<MobEvent>) {
let was_touching_water = self.fluids.in_water;
self.fluids = w.fluids(self.bounds(), self.eye_y());
// EntityFluidInteraction.applyCurrentTo: the flow pushes the mob.
if (self.fluids.in_water || self.fluids.in_lava) && self.pushed_by_fluid() {
let (water, lava) = w.fluid_currents(self.bounds());
let lava_scale = if w.nether {
0.007
} else {
0.0023333333333333335
};
let mut pushed = Pushed(&mut self.velocity);
if water.height > 0.0 {
let _ = water.apply_to(&mut pushed, 0.014);
}
if lava.height > 0.0 {
let _ = lava.apply_to(&mut pushed, lava_scale);
}
}
if self.fluids.in_water {
self.fall_distance = 0.0;
if !was_touching_water && !self.first_tick {
@@ -529,13 +683,15 @@ impl Mob {
}
}
/// `Entity.nextStep`: the next whole metre walked; a warden's steps come
/// every 0.55, a strider's every 0.6.
/// every 0.55, a strider's every 0.6, a turtle's every 0.15.
fn next_step(&self) -> f32 {
let walked = self.ai.steps.move_dist;
if self.kind.species == super::Species::Warden {
walked + 0.55
} else if self.kind.species == super::Species::Strider {
walked + 0.6
} else if self.kind.species == super::Species::Turtle {
walked + 0.15
} else {
(walked as i32).wrapping_add(1) as f32
}
@@ -816,7 +972,7 @@ impl Mob {
// jumpOutOfFluid
let m = self.velocity;
if self.horizontal_collision
&& w.no_collision(self.bounds().move_by(Vec3::new(
&& w.is_free(self.bounds().move_by(Vec3::new(
m.x,
m.y + f64::from(0.6_f32) - self.position.y + old_y,
m.z,
File diff suppressed because it is too large. Load diff
+19
View File
@@ -469,6 +469,12 @@ fn find_piglin_repellent(m: &Mob, w: &MobWorld) -> Option<[i32; 3]> {
/// `HoglinBase.hurtAndThrowTarget`.
pub fn hoglin_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
m.extra.anim_ticks = 10;
// Entity event 4: clients start the headbutt.
cx.events.push(MobEvent::EntityEvent {
mob: m.id,
event: 4,
at: [m.position.x, m.position.y, m.position.z],
});
let attack = m.attrs.attack_damage;
let amount = if !m.is_baby() && attack as i32 > 0 {
attack / 2.0 + m.random.next_int_bound(attack as i32).unwrap_or(0) as f32
@@ -603,6 +609,19 @@ pub fn piglin_pose(
_ => {}
}
}
/// `Hoglin.handleEntityEvent` and `Zoglin`'s event 4 on a client: the
/// headbutt, which `aiStep` then counts down.
pub fn client_event(m: &mut Mob, event: u8) {
if event == 4 {
m.extra.anim_ticks = 10;
}
}
/// The headbutt's countdown (`Hoglin.aiStep`, `Zoglin.aiStep`) on a client.
pub fn client_tick(m: &mut Mob) {
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
}
}
/// `HoglinModel.setupAnim`.
pub fn hoglin_anim(m: &Mob, s: &State, p: &mut Poses) {
let (pos, sp) = (s.pos, s.speed);
+40 -1
View File
@@ -49,9 +49,14 @@ pub fn register(m: &mut Mob) {
pub fn immobile(m: &Mob) -> bool {
m.extra.counter > 0 || m.extra.counter2 > 0 || m.extra.anim_ticks > 0
}
/// `Ravager.doHurtTarget`: the bite animation.
/// `Ravager.doHurtTarget`: the bite animation (entity event 4 for clients).
pub fn on_attack(m: &mut Mob, events: &mut Vec<MobEvent>) {
m.extra.counter = 10;
events.push(MobEvent::EntityEvent {
mob: m.id,
event: 4,
at: [m.position.x, m.position.y, m.position.z],
});
events.push(MobEvent::Sound(
"entity.ravager.attack",
[m.position.x, m.position.y, m.position.z],
@@ -67,6 +72,12 @@ pub fn blocked(m: &mut Mob, events: &mut Vec<MobEvent>) -> bool {
}
if m.random.next_double() < 0.5 {
m.extra.counter2 = 40;
// Entity event 39: the stun starts on clients.
events.push(MobEvent::EntityEvent {
mob: m.id,
event: 39,
at: [m.position.x, m.position.y, m.position.z],
});
events.push(MobEvent::Sound(
"entity.ravager.stunned",
[m.position.x, m.position.y, m.position.z],
@@ -155,6 +166,34 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
/// `Ravager.handleEntityEvent` 4 and 39 on a client.
pub fn entity_event(m: &mut Mob, event: u8) {
match event {
4 => m.extra.counter = 10,
39 => m.extra.counter2 = 40,
_ => {}
}
}
/// `Ravager.aiStep`'s clocks on a client: the bite and roar run down, and
/// the end of the stun starts the roar (the sounds, particles and the roar's
/// damage are the server's).
pub fn client_tick(m: &mut Mob) {
if !m.alive() {
return;
}
if m.extra.anim_ticks > 0 {
m.extra.anim_ticks -= 1;
}
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
if m.extra.counter2 > 0 {
m.extra.counter2 -= 1;
if m.extra.counter2 == 0 {
m.extra.anim_ticks = 20;
}
}
}
/// `Ravager.roar`: 6 damage to everything but illagers within 4 blocks,
/// and a strong knockback (players too, through the client).
fn roar(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
+160 -14
View File
@@ -137,6 +137,10 @@ pub fn body(m: &Mob) -> (String, String) {
baby_or(m, "wolf#main", "wolf_baby#main"),
super::tame::texture(m),
),
S::Panda => (
baby_or(m, "panda#main", "panda_baby#main"),
super::panda::texture(m),
),
S::HappyGhast => (
baby_or(m, "happy_ghast#main", "happy_ghast_baby#main"),
if baby {
@@ -177,10 +181,87 @@ pub fn body(m: &Mob) -> (String, String) {
"frog#main".into(),
format!("entity/frog/frog_{}", climate(m)),
),
S::Rabbit => (
baby_or(m, "rabbit#main", "rabbit_baby#main"),
// RabbitRenderer.getTextureLocation: the Toast name tag, else the
// variant (the killer bunny's id is 99).
S::Rabbit => {
let name = if m.custom_name.as_deref() == Some("Toast") {
"toast"
} else {
match m.extra.variant {
1 => "white",
2 => "black",
3 => "white_splotched",
4 => "gold",
5 => "salt",
99 => "caerbannog",
_ => "brown",
}
};
(
baby_or(m, "rabbit#main", "rabbit_baby#main"),
b(&format!("entity/rabbit/rabbit_{name}")),
)
}
// ParrotRenderer.getVariantTexture.
S::Parrot => {
let name = ["red_blue", "blue", "green", "yellow_blue", "grey"]
[usize::from(m.extra.variant).min(4)];
(m.kind.model_key(), format!("entity/parrot/parrot_{name}"))
}
// AxolotlRenderer.getTextureLocation.
S::Axolotl => {
let name =
["lucy", "wild", "gold", "cyan", "blue"][usize::from(m.extra.variant).min(4)];
(
baby_or(m, "axolotl#main", "axolotl_baby#main"),
b(&format!("entity/axolotl/axolotl_{name}")),
)
}
// SalmonRenderer.submit: the model of the salmon's size.
S::Salmon => {
let model = match m.extra.variant {
0 => "salmon_small#main",
2 => "salmon_large#main",
_ => "salmon#main",
};
(model.into(), "entity/fish/salmon".into())
}
// The baby models and textures of AgeableMobRenderer's other
// subclasses.
S::Goat => (
baby_or(m, "goat#main", "goat_baby#main"),
b(&m.kind.texture),
),
S::PolarBear => (
baby_or(m, "polar_bear#main", "polar_bear_baby#main"),
b(&m.kind.texture),
),
S::Ocelot => (
baby_or(m, "ocelot#main", "ocelot_baby#main"),
b(&m.kind.texture),
),
S::Turtle => (
baby_or(m, "turtle#main", "turtle_baby#main"),
b(&m.kind.texture),
),
S::Armadillo => (
baby_or(m, "armadillo#main", "armadillo_baby#main"),
b(&m.kind.texture),
),
// FoxRenderer.getTextureLocation: snow variant, sleeping, baby.
S::Fox => (
baby_or(m, "fox#main", "fox_baby#main"),
format!(
"entity/fox/fox{}{}{}",
if m.extra.variant == 0 { "" } else { "_snow" },
if super::fox::flag(m, super::fox::SLEEPING) {
"_sleep"
} else {
""
},
if baby { "_baby" } else { "" },
),
),
S::Pig => {
let model = if baby {
"pig_baby#main".into()
@@ -241,7 +322,7 @@ pub fn body(m: &Mob) -> (String, String) {
),
S::ZombieVillager => (
baby_or(m, "zombie_villager#main", "zombie_villager_baby#main"),
"entity/zombie_villager/zombie_villager".into(),
b("entity/zombie_villager/zombie_villager"),
),
S::Villager => (
baby_or(m, "villager#main", "villager_baby#main"),
@@ -601,14 +682,6 @@ pub fn layers(m: &Mob) -> Vec<Layer> {
white,
false,
),
// EnergySwirlLayer (the scroll is not reproduced: atlas sprites do not wrap).
S::Creeper if m.extra.charged => push(
&mut v,
"creeper#armor",
"entity/creeper/creeper_armor",
0x80FF_FFFF,
true,
),
S::TropicalFish => {
// TropicalFishPatternLayer: the pattern tinted with the pattern colour.
let (model, letter, n) = if m.extra.variant < 6 {
@@ -765,7 +838,14 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
let rotation = match sleep {
// setupRotations for Pose.SLEEPING, with the head moved toward the pillow.
Some((angle, step)) => {
let head = (m.eye_y() - m.position.y) as f32 - 0.1;
// The eye height of the standing pose (LivingEntityRenderer
// extracts `getEyeHeight(Pose.STANDING)` for a bed).
let eye = if avatar(m) {
m.kind.eye_height as f32
} else {
(m.eye_y() - m.position.y) as f32
};
let head = eye - 0.1;
glam::Mat4::from_translation(glam::Vec3::new(-step[0] * head, 0.0, -step[1] * head))
* glam::Mat4::from_rotation_y(angle.to_radians())
* glam::Mat4::from_rotation_z(90.0_f32.to_radians())
@@ -833,10 +913,15 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4
base *= species_scale(m, alpha);
base * glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0))
}
/// `LivingEntityRenderer.isShaking`: converting zombies, piglins and
/// hoglins, and a zombie villager being cured.
/// `LivingEntityRenderer.isShaking`: a fully frozen mob (the skeleton's own
/// `isShaking` is its stray conversion only), converting zombies, piglins
/// and hoglins, a zombie villager being cured and a cold strider.
fn shaking(m: &Mob) -> bool {
if m.is_fully_frozen() && m.species() != Species::Skeleton {
return true;
}
match m.species() {
Species::Strider => super::strider::suffocating(m),
Species::ZombieVillager => super::zombie_villager::is_converting(m),
Species::Zombie | Species::Husk => m.extra.counter2 > 0,
Species::Piglin | Species::PiglinBrute | Species::Hoglin => {
@@ -854,7 +939,13 @@ fn flip_degrees(m: &Mob) -> f32 {
fn species_rotations(m: &Mob, alpha: f32) -> glam::Mat4 {
match m.species() {
Species::Cat => super::tame::cat_rotations(m, alpha),
Species::Panda => super::panda::rotations(m, alpha),
Species::Shulker => super::shulker::rotations(m),
// FoxRenderer.setupRotations: the body follows a pounce's pitch.
Species::Fox if super::fox::flag(m, super::fox::POUNCING | super::fox::FACEPLANTED) => {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
glam::Mat4::from_rotation_x((-pitch).to_radians())
}
_ => glam::Mat4::IDENTITY,
}
}
@@ -1151,10 +1242,50 @@ fn render_visible(
}
}
}
// EnergySwirlLayer: the armour model, additive, scrolling over the sprite.
if let Some((model, texture, x_offset)) = energy_swirl(m, s.age)
&& let Some(part) = m.kind.layer(model)
&& let Some([u0, v0, u1, v1]) = atlas(texture)
{
layer_poses.reset(part);
anim::setup(m, &s, &mut layer_poses, &data.animations);
// `RenderTypes.energySwirl`'s texture offset, as the sprite-local UV.
let (du, dv) = (x_offset % 1.0, s.age * 0.01 % 1.0);
let start = out.len();
draw(
part,
base,
&layer_poses,
[du, dv, 1.0 + du, 1.0 + dv],
crate::breaking::stage_light([u1 - u0, v1 - v0]),
crate::breaking::stage_light([u0, v0]),
crate::gfx::ENERGY_SWIRL_OVERLAY,
out,
);
// No lighting: the colour bytes carry the sprite's size (fs_energy_swirl).
for vertex in &mut out[start..] {
vertex.normal = [0; 4];
}
}
if shell_last {
draw_body(out);
}
}
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
/// (`CreeperPowerLayer`) or a powered wither (`WitherArmorLayer`); `t` is `ageInTicks`.
fn energy_swirl(m: &Mob, t: f32) -> Option<(&'static str, &'static str, f32)> {
match m.species() {
Species::Creeper if m.extra.charged => {
Some(("creeper#armor", "entity/creeper/creeper_armor", t * 0.01))
}
Species::Wither if super::wither::powered(m) => Some((
"wither#armor",
"entity/wither/wither_armor",
(t * 0.02).mcos() * 3.0,
)),
_ => None,
}
}
/// The equipment slot of a `HumanoidArmorLayer` model (`zombie#helmet`).
fn armor_slot(model: &str) -> Option<usize> {
Some(match model.rsplit('#').next()? {
@@ -1235,6 +1366,15 @@ fn hand_base(
0.0
};
let mut t = part_matrix(model, base, poses, arm, offset)?;
if m.species() == Species::Vex {
// `VexModel.translateToHand`: the held item is drawn at 0.55 scale.
t *= glam::Mat4::from_scale(glam::Vec3::splat(0.55));
t *= glam::Mat4::from_translation(glam::Vec3::new(
if left { -0.046_875 } else { 0.046_875 },
-0.156_25,
0.078_125,
));
}
let (ox, oy, oz) = if m.is_baby() {
(0.0, 1.0, -4.5)
} else {
@@ -1279,6 +1419,12 @@ pub fn held_item_transform(
data: &MobData,
left: bool,
) -> Option<glam::Mat4> {
// PandaHoldsItemLayer: the main hand's item in its mouth.
if m.species() == Species::Panda {
return super::panda::item_offset(m, alpha)
.filter(|_| !left)
.map(|offset| base_transform(m, alpha, camera) * offset);
}
if !super::illager::shows_item(m) {
return None;
}
+11
View File
@@ -143,6 +143,10 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
}
}
}
peek_step(m);
}
/// `Shulker.updatePeekAmount`: the lid eases to the raw peek.
fn peek_step(m: &mut Mob) {
let a = &mut m.anim;
let target = m.extra.counter as f32 * 0.01;
let cur = a.v[PEEK];
@@ -152,6 +156,13 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
a.v[PEEK] = (cur + 0.05).clamp(0.0, target);
}
}
/// `Shulker.tick` on a client that is not the server: the lid eases to the
/// peek it was sent, and its body stays square (`ShulkerBodyRotationControl`).
pub fn client_tick(m: &mut Mob) {
m.anim.vo = m.anim.v;
peek_step(m);
m.body_yaw = 0.0;
}
/// `Shulker.hurtServer` before the damage: arrows bounce off a closed shell.
pub fn accepts(m: &Mob, source: &Damage) -> bool {
!(m.extra.counter == 0 && source.kind == DamageKind::Arrow)
+22 -16
View File
@@ -74,23 +74,8 @@ pub fn head_block(m: &Mob) -> [i32; 3] {
/// the dropped seed and the water malus.
pub fn ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
use super::path::PathType;
let age = m.age as f32;
let current = state(m);
if current != m.extra.scratch[0] as u8 {
// resetAnimations, then the state's own.
for s in &mut m.anim.states[..5] {
s.stop();
}
match current {
FEELING_HAPPY => m.anim.states[0].start_if_stopped(age),
SCENTING => m.anim.states[1].start_if_stopped(age),
SNIFFING => m.anim.states[2].start_if_stopped(age),
DIGGING => m.anim.states[3].start_if_stopped(age),
RISING => m.anim.states[4].start_if_stopped(age),
_ => {}
}
m.extra.scratch[0] = i32::from(current);
}
animate(m);
if current == DIGGING && now(m) == m.extra.ints[3] {
let h = head_block(m);
events.push(MobEvent::GiftLoot {
@@ -110,6 +95,27 @@ pub fn ai_step(m: &mut Mob, events: &mut Vec<MobEvent>) {
let wet = m.fluids.in_water || m.fire_ticks > 0;
m.set_malus(PathType::Water, if wet { 0.0 } else { -1.0 });
}
/// `Sniffer.onSyncedDataUpdated(DATA_STATE)`: the animation states of the
/// state it changed to (on a client, each tick for the state it has).
pub fn animate(m: &mut Mob) {
let age = m.age as f32;
let current = state(m);
if current != m.extra.scratch[0] as u8 {
// resetAnimations, then the state's own.
for s in &mut m.anim.states[..5] {
s.stop();
}
match current {
FEELING_HAPPY => m.anim.states[0].start_if_stopped(age),
SCENTING => m.anim.states[1].start_if_stopped(age),
SNIFFING => m.anim.states[2].start_if_stopped(age),
DIGGING => m.anim.states[3].start_if_stopped(age),
RISING => m.anim.states[4].start_if_stopped(age),
_ => {}
}
m.extra.scratch[0] = i32::from(current);
}
}
/// The brain memory holding the explored positions
/// (`SNIFFER_EXPLORED_POSITIONS`).
pub fn to_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
+5 -1
View File
@@ -1500,7 +1500,7 @@ impl Def {
),
// Nautilus.handleAirSupply: it dries out on land.
needs_water: aquatic || s == S::Nautilus,
water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem),
water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem | S::Strider),
animal: !matches!(s, S::Bat | S::SulfurCube | S::Mannequin)
&& !self.hostile_class()
&& !aquatic
@@ -1629,6 +1629,7 @@ impl Def {
match self.species {
Species::Dolphin | Species::Axolotl => 1.0,
Species::Slime | Species::MagmaCube | Species::SulfurCube => 0.0,
Species::Guardian | Species::ElderGuardian | Species::Shulker => 180.0,
_ => 40.0,
}
}
@@ -1640,6 +1641,9 @@ impl Def {
Species::Sniffer => 50.0,
Species::Goat => 15.0,
Species::Armadillo => 32.0,
Species::Ravager => 45.0,
Species::Camel | Species::CamelHusk => 30.0,
Species::Shulker => 180.0,
_ => 75.0,
}
}
@@ -297,6 +297,7 @@ pub fn accepts_damage(m: &mut Mob, source: &Damage, amount: f32) -> bool {
| DamageKind::Fall
| DamageKind::Sting
| DamageKind::Cactus
| DamageKind::Freeze
);
if immune {
if let Some(from) = source.from {
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more