Compare commits

...
Author SHA1 Message Date
Ludovic Marie c002989c82 union: #2126 fix 2026-10-27 09:08:11 +01:00
Ludovic MarieandClaude Opus 5.5 523280e781 Draw the new level-event sound pitches from the client level's random
LevelEventHandler plays 1501, 1502, 1046 and 1047 with
ClientLevel.getRandom(), not the server level's random: drawing them from
the world's random moved the level random in use_item_on's respawn
anchor cases (12 of 4000 differed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:07:42 +01:00
Ludovic Marie 65b0cb2521 union: #2261
# Conflicts:
#	crates/horizon-client/src/game/potions.rs
2026-10-27 07:15:26 +01:00
Ludovic Marie add777206d union: #2259
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-10-27 07:13:37 +01:00
Ludovic Marie 91c84ca76d union: #2257
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-27 07:12:10 +01:00
Ludovic Marie de23ad4338 union: #2256
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-27 07:11:33 +01:00
Ludovic Marie 56108d1b37 union: #2254
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-10-27 07:10:55 +01:00
Ludovic Marie e6bd4cf76c union: #2252
# Conflicts:
#	crates/horizon-client/src/game/combat.rs
#	crates/horizon-client/src/game/wither.rs
2026-10-27 07:10:54 +01:00
Ludovic Marie cf7e635cb0 union: #2251
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-10-27 07:09:35 +01:00
Ludovic Marie d6f9367ff1 union: #2245
# Conflicts:
#	crates/horizon-client/src/advancements.rs
#	crates/horizon-client/src/game/advancements.rs
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:09:28 +01:00
Ludovic Marie fbbf0ac64c union: #2243
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-27 07:08:22 +01:00
Ludovic Marie c07049a77f union: #2235
# Conflicts:
#	crates/horizon-client/src/game/potions.rs
2026-10-27 07:08:01 +01:00
Ludovic Marie 83fcd89847 union: #2233
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-10-27 07:05:38 +01:00
Ludovic Marie 6f67726a5b union: #2229
# Conflicts:
#	crates/horizon-client/src/game/advancements.rs
2026-10-27 07:05:11 +01:00
Ludovic Marie 1318853025 union: #2280 2026-10-27 07:04:26 +01:00
Ludovic Marie 3fef8426d4 union: #2275 2026-10-27 07:04:26 +01:00
Ludovic Marie bd374a9f30 union: #2271 2026-10-27 07:04:26 +01:00
Ludovic Marie f1cb0920ce union: #2268 2026-10-27 07:04:26 +01:00
Ludovic Marie b2604a3963 union: #2267 2026-10-27 07:04:26 +01:00
Ludovic Marie 18d98c064a union: #2266 2026-10-27 07:04:26 +01:00
Ludovic Marie 686697840d union: #2265 2026-10-27 07:04:26 +01:00
Ludovic Marie dcfab95680 union: #2264 2026-10-27 07:04:26 +01:00
Ludovic Marie d560f1534f union: #2263 2026-10-27 07:04:26 +01:00
Ludovic Marie 8194ef9740 union: #2262 2026-10-27 07:04:26 +01:00
Ludovic Marie 2cb20cda62 union: #2260 2026-10-27 07:04:25 +01:00
Ludovic Marie fa09f3486f union: #2258 2026-10-27 07:04:25 +01:00
Ludovic Marie b3e41ac6b5 union: #2255 2026-10-27 07:04:25 +01:00
Ludovic Marie 6cead02e34 union: #2253 2026-10-27 07:04:25 +01:00
Ludovic Marie 02f0970cd4 union: #2250 2026-10-27 07:04:25 +01:00
Ludovic Marie cfbbc077cc union: #2249 2026-10-27 07:04:25 +01:00
Ludovic Marie db87ac7956 union: #2248 2026-10-27 07:04:25 +01:00
Ludovic Marie 5fc4723bab union: #2247 2026-10-27 07:04:25 +01:00
Ludovic Marie c3198c9c17 union: #2246 2026-10-27 07:04:25 +01:00
Ludovic Marie 3c83854938 union: #2244 2026-10-27 07:04:25 +01:00
Ludovic Marie 3e10fb465b union: #2242 2026-10-27 07:04:25 +01:00
Ludovic Marie 06f666db57 union: #2241 2026-10-27 07:04:25 +01:00
Ludovic Marie 789326bb92 union: #2240 2026-10-27 07:04:25 +01:00
Ludovic Marie c8d55feca3 union: #2239 2026-10-27 07:04:25 +01:00
Ludovic Marie 3f8f346851 union: #2238 2026-10-27 07:04:25 +01:00
Ludovic Marie 755a6acc76 union: #2237 2026-10-27 07:04:25 +01:00
Ludovic Marie 21117506d2 union: #2236 2026-10-27 07:04:24 +01:00
Ludovic Marie ed6125756c union: #2234 2026-10-27 07:04:24 +01:00
Ludovic Marie 8ae648d67d union: #2232 2026-10-27 07:04:24 +01:00
Ludovic Marie 7c92e49971 union: #2231 2026-10-27 07:04:24 +01:00
Ludovic Marie 6f29f197a0 union: #2230 2026-10-27 07:04:24 +01:00
Ludovic Marie e6605b2364 union: #2228 2026-10-27 07:04:24 +01:00
Ludovic Marie 11d72958f4 union: #2227 2026-10-27 07:04:24 +01:00
Ludovic Marie e304294056 union: #2226 2026-10-27 07:04:24 +01:00
Ludovic Marie 0904969572 union: #2225 2026-10-27 07:04:24 +01:00
Ludovic Marie a2950bce2f union: #2224 2026-10-27 07:04:24 +01:00
Ludovic Marie 4f6b549e5d union: #2223 2026-10-27 07:04:24 +01:00
Ludovic Marie 1397756847 union: #2222 2026-10-27 07:04:24 +01:00
Ludovic Marie 71827f3b4d union: #2221 2026-10-27 07:04:24 +01:00
Ludovic Marie 2cb278ab3c union: #2220 2026-10-27 07:04:24 +01:00
Ludovic Marie 56f2552241 union: #2219 2026-10-27 07:04:24 +01:00
Ludovic Marie aae895a096 union: #2218 2026-10-27 07:04:23 +01:00
Ludovic Marie e414d77f68 union: #2136 2026-10-27 07:04:23 +01:00
Ludovic Marie c33e47a33b union: #2135 2026-10-27 07:04:23 +01:00
Ludovic Marie 260b564fe1 union: #2134 2026-10-27 07:04:23 +01:00
Ludovic Marie 9d008e809f union: #2133 2026-10-27 07:04:23 +01:00
Ludovic Marie 98d98f4dde union: #2132 2026-10-27 07:04:23 +01:00
Ludovic Marie 6def29e16f union: #2129 2026-10-27 07:04:23 +01:00
Ludovic Marie fab334a930 union: #2128 2026-10-27 07:04:23 +01:00
Ludovic Marie fe0d9733ad union: #2126 2026-10-27 07:04:23 +01:00
Ludovic Marie b9381720d4 union: #2125 2026-10-27 07:04:23 +01:00
Ludovic Marie e70f4650ab union: #2124 2026-10-27 07:04:23 +01:00
Ludovic Marie 1c71afafab union: #2123 2026-10-27 07:04:23 +01:00
Ludovic Marie 8ff13ea185 union: #2121 2026-10-27 07:04:23 +01:00
Ludovic Marie 9f89c65d34 union: #2119 2026-10-27 07:04:23 +01:00
Ludovic Marie f2460134fe union: #2116 2026-10-27 07:04:23 +01:00
Ludovic Marie f0e1d992ef union: #2115 2026-10-27 07:04:23 +01:00
Ludovic Marie e60ce10870 union: #2111 2026-10-27 07:04:22 +01:00
Ludovic Marie 292cecbda3 union: #2099 2026-10-27 07:04:22 +01:00
Ludovic Marie 5211bdd224 union: #1969 2026-10-27 07:04:22 +01:00
Ludovic MarieandClaude Opus 5.5 e42a82e9b9 Name the difficulty id field as #2239 does
#2239 adds the same LocalDifficulty field as `id`; with the same lines
here the two branches merge without a conflict.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:03:08 +01:00
Ludovic MarieandClaude Opus 5.5 d77569c672 The dragon's ambient pitch draws from its own random
LivingEntity.getVoicePitch uses this.random, the same random the
ambient roll draws from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:35 +01:00
Ludovic MarieandClaude Opus 5.5 937dcba5b8 A TNT minecart's fuse blast uses its horizontal speed only
MinecartTNT.tick calls explode(getDeltaMovement().horizontalDistanceSqr())
when the fuse runs out; the vertical-speed branch has no vanilla source.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:02:05 +01:00
Ludovic MarieandClaude Opus 5.5 d8b49e8697 Merge main into fix/2213-frog-speed
Keeps both registrations: the frog's move control and main's turtle.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 07:00:33 +01:00
Ludovic MarieandClaude Opus 5.5 36f6ab9839 Infested, Weaving and Wind Charged draw from the bearer's random
InfestedMobEffect.onMobHurt, WeavingMobEffect.spawnCobwebsRandomlyAround
and WindChargedMobEffect.onMobRemoved all use mob.getRandom(): a mob's
own random, the world's for the player as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:59:59 +01:00
Ludovic MarieandClaude Opus 5.5 d3b28edfb6 Merge main into fix/death-and-hurt-effects
The Infested event stays immediate (onMobHurt runs in hurtServer); the
other events a hit raises are left in the outbox for the end of the
tick, as on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:58:20 +01:00
Ludovic MarieandClaude Opus 5.5 9c49424b4d Merge main into fix/wither-rose-kills
A blast's attacker is the projectile's owner, else main's exploding mob
(getIndirectSourceEntity); both CombatTracker fields kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:57:29 +01:00
Ludovic MarieandClaude Opus 5.5 6a65ee1124 A guest saved by a totem is not a player kill
The guest's health goes through the same totem path as the host's
(applied on the next tick_totem), so the kill check skips a fired totem
for both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:46 +01:00
Ludovic MarieandClaude Opus 5.5 ad70f755d7 Merge main into fix/stats-combat
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:43 +01:00
Ludovic MarieandClaude Opus 5.5 d2ca443d6f Merge main into fix/mob-effect-merge
Keeps this branch's add_effect (infinite durations, isShorterDurationThan)
over main's similar fix, and main's hidden_effects cleanup with infinite
effects kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:56:05 +01:00
Ludovic MarieandClaude Opus 5.5 716cc7f102 Merge main into fix/crossbow-advancement-triggers
Keeps main's piercing count with this branch's fired_from_weapon.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:55:21 +01:00
Ludovic MarieandClaude Opus 5.5 64279fa1ce Merge main into fix/detector-rail-container-signal
Keeps both GameLevel fields: cart_signals and main's arrow_boxes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:47 +01:00
Ludovic MarieandClaude Opus 5.5 c35436a7da Merge main into fix/instant-effects-rounding-splash-distance
Splashes carry both the potion's age (for the distance) and main's
thrower (for the indirectMagic source).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:54:15 +01:00
Ludovic MarieandClaude Opus 5.5 393b8ef7e6 Merge main into fix/player-hurt-entity-trigger
The trident's PLAYER_HURT_ENTITY now keys off main's hurt_at (kept for
Channeling).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:53:29 +01:00
Ludovic MarieandClaude Opus 5.5 15be176380 Merge main into fix/fishing-rod-hooked-trigger
Keeps the hooked trigger with main's item-entity damage (3) and main's
fish_caught per #fishes catch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:52:43 +01:00
Ludovic MarieandClaude Opus 5.5 8905aaf0dd Merge main into fix/armadillo-scutes
Keeps this branch's ShedScute event and main's GiftLoot (with its mob
and the turtle's mob_drops check).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:51:37 +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 b463f1c80a Waxed hanging signs play their own failed-interaction sound
HangingSignBlockEntity.getSignInteractionFailedSoundEvent is
block.hanging_sign.waxed_interact_fail, not the standing sign's.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:48:18 +01:00
Ludovic Marie 6f19f06634 Merge pull request #2350 from kacpherek/fix/mining-hit-sound-level-events
🤖 Agent: verified against vanilla 26.3 (mining hit sounds now come from the level events, once per hit as MultiPlayerGameMode/LevelRenderer do). I merged main into the branch (one conflict in level_particles.rs, both functions kept). Tested on a tree of main + this PR + #2279: the 33 harnesses its files reach passed, and two real games over LAN (host in-frame and on its server thread) heard 5-7 stone hits per block, with digging, cracks, instant digging, death-while-mining, respawn and hurt checks passing. Thanks @kacpherek!
2026-10-27 06:48:05 +01:00
Ludovic MarieandClaude Opus 5.5 bbc1ffb408 Merge main into fix/invisibility-mob-detection
Keeps both PlayerView methods: visibility_percent and main's has_effect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:47:27 +01:00
Ludovic MarieandClaude Opus 5.5 4c78d32e36 Merge main into fix/explosion-beehive-bees
Keeps main's ExplosionSource event and charged_exploders, and this
branch's Species on Explode and hive_emptying_blast.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:44:25 +01:00
Ludovic MarieandClaude Opus 5.5 9809bf38b6 Merge main into fix/remote-hurt-tilt
indicateDamage now uses main's knockback direction, which is zero when
the hit has no source position (as in dealDefaultKnockback).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:52 +01:00
Ludovic MarieandClaude Opus 5.5 38cd3095e5 Merge main into fix/missing-level-events
Keeps both sets of level-event sounds: this branch's chorus flower and
dripstone-into-cauldron, and main's zombie door and potion splashes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:43:02 +01:00
Ludovic MarieandClaude Opus 5.5 c752c21246 Merge main into fix/remote-entity-events
Keeps both entity-event handlers in remote.rs: this branch's events 20,
6/7/12/13/14/18 and 3, and main's honey slide (53).

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:41:27 +01:00
Ludovic MarieandClaude Opus 5.5 739d4fe187 Merge main into fix/2095-signal-save
Keeps both: the stop-signal counter from this branch and main's
release_free_memory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:40:30 +01:00
Ludovic Marie 89225ec081 Merge pull request #2279 from kacpherek/fix/2276-dead-hurt-tilt
🤖 Agent: verified against vanilla 26.3: LivingEntity.baseTick counts hurtTime down for a dead or riding player too, so the hurt tilt no longer restarts every frame and shakes the camera on the server-thread path. Tested on a tree of main + #2350 + this PR: the 33 harnesses its files reach passed, and the LAN hurt, death-while-mining and respawn checks passed with the host in-frame and on its server thread. Thanks @kacpherek!
2026-10-27 06:39:59 +01:00
Ludovic MarieandClaude Opus 5.5 24744aae44 Merge main into fix/1361-lingering-cloud
Resolves potions.rs against main's thrower tracking: splashes keep
their indirectMagic source, and a lingering cloud keeps the thrower too
(AreaEffectCloud.getOwner), so its instant damage names them, else the
cloud.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 06:39:43 +01:00
Ludovic Marie 6044c6ff88 Merge pull request #2623 from ludovic111/feat/launcher-hide-on-play
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:30:43 +01:00
Ludovic Marie d74d39cb05 Merge pull request #2622 from ludovic111/feat/launcher-console
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:21:26 +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
Ludovic Marie 91e332a5eb Merge pull request #2621 from ludovic111/feat/launcher-import-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:10:22 +01:00
Ludovic Marie a8fe843daa Merge pull request #2620 from ludovic111/feat/launcher-import
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 06:01:09 +01:00
Ludovic Marie 37d31da543 Merge pull request #2473 from kacpherek/fix/piglin-dance
🤖 Agent: Merged by Ludovic's AI agent (orchestrator). Reviewed and approved; verify passed. The two parity failures (server_player_sculk, remote_fireworks) are the load flakes fixed by #2629, which merged after this run started (03:30Z); both pass on the Mac mini at this head. Thanks @kacpherek!
2026-10-27 05:49:18 +01:00
Ludovic Marie c5165a98cf Merge pull request #2618 from ludovic111/feat/launcher-export-ui
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:48:52 +01:00
Ludovic Marie e1f7ad93ee Merge pull request #2617 from ludovic111/feat/launcher-export
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:30:25 +01:00
Ludovic Marie 7731b4274a Merge pull request #2616 from ludovic111/feat/launcher-datapack-browser
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:21:08 +01:00
Ludovic Marie 7df621fdb5 Merge pull request #2614 from ludovic111/feat/launcher-mods-per-instance
🤖 Agent: launcher-features-1: base main after #2612 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:19:29 +01:00
Ludovic Marie 3eb3d1fc5f Merge pull request #2612 from ludovic111/feat/launcher-datapacks
🤖 Agent: launcher-features-1: base main after #2609 merged, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 05:10:09 +01:00
Ludovic Marie 3052cc41db Merge pull request #2629 from ludovic111/fix/window-harnesses-under-load
🤖 Agent: playhorizon: remote.sh runs on CPUs 0-3 (CI keeps 4-7); world_reopen and server_player_sculk count ticks; 6 of 6 under load.
2026-10-27 05:10:05 +01:00
Ludovic Marie 43f5388038 Merge pull request #2464 from kacpherek/fix/damage-command-player-source
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/damage` builds `DamageSource(type, cause, by)` with the location or the direct entity's position for the knockback, and the causing mob gets the kill credit. It touched the same code as #2452, so the merged tree uses your `hurt_player_from` for both. Main merged in at f1f587e7c after #2452 landed (world_cmds.rs and creatures.rs: one `hurt_player_from` path that also records the direct entity, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 05:02:10 +01:00
Ludovic Marie 08492a3007 Merge pull request #2609 from ludovic111/feat/launcher-update-all
🤖 Agent: launcher-features-1: base main, merge-tree clean against main, CI verify + launcher green; queued.
2026-10-27 04:50:37 +01:00
Ludovic Marie 3d0ef4da75 Merge pull request #2441 from kacpherek/fix/effect-swirls
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickEffects` draws the swirls from `DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID`, only for visible effects, and your follow-up keeps `hideParticles` out of both, which matches. Main merged in at 0ab24d080 after #2432 landed (sprint dust and effect swirls both kept in client_ambient.rs and lan.rs, as in the union): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:48:54 +01:00
Ludovic MarieandClaude Opus 5.5 a2f62b1545 parity_hosts: playhorizon's workers run on CPUs 0-3, 3 harnesses at a time
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:42:19 +01:00
Ludovic MarieandClaude Opus 5.5 cb20bb508b remote.sh: runs get CPUs 0-3 by default; benchmarks hold them with --exclusive
playhorizon's CI runner has CPUs 4-7, and remote.sh pinned agents' runs there
too: CPUs 4-7 sat at 100% while 0-3 idled, and window harnesses in pull
requests' parity jobs lost ticks. Runs share a lock per CPU set; an
--exclusive run (a benchmark) holds it alone. world_reopen also waits for
the world to load before its first count, as after each reopen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:31:40 +01:00
Ludovic Marie 4168590221 Merge remote-tracking branch 'origin/main' into fix/window-harnesses-under-load 2026-10-27 04:30:35 +01:00
Ludovic MarieandClaude Opus 5.5 2a7b79dfb6 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:26:15 +01:00
Ludovic MarieandClaude Opus 5.5 f1f587e7c2 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:24:30 +01:00
Ludovic Marie 51447edda4 Merge pull request #2604 from ludovic111/feat/launcher-test-seams
🤖 Agent: launcher-features-1 checked this: base main after #2602 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:23:13 +01:00
Ludovic Marie 4018fa9559 Merge pull request #2452 from kacpherek/fix/magic-death-message
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `DamageSource.getLocalizedDeathMessage` (the causing entity, else the direct one: the potion or cloud) and `indirectMagic`. I pushed one fix: with no entity at all, vanilla sends the one-argument message and `TranslatableContents.decompose` shows a template it can't fill as written, so I removed the fallback to `magic` / `generic`. Main merged in at e338690bf after #2442 landed (potions.rs: `splash` keeps both the owner and the block): clippy and the horizon-client lib and bin tests pass on it, and CI parity runs on this head. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:23:09 +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 Marie 95e7cd69dd Merge pull request #2602 from ludovic111/feat/launcher-packs-ui
🤖 Agent: launcher-features-1 checked this: base main after #2601 merged, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 04:16:39 +01:00
Ludovic Marie 5a6ae76ec3 Merge pull request #2486 from ludovic111/fix/parity-reap-orphans
🤖 Agent: parity.py reaps the harness processes a failed or timed-out run leaves behind; parity passed on playhorizon.
2026-10-27 04:16:36 +01:00
Ludovic Marie 4a523b5ce8 Merge pull request #2484 from kacpherek/fix/2463-break-block-lagback
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: breaking the block under you no longer sends a position correction the client didn't need. Fixes #2463. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:16:33 +01:00
Ludovic Marie eae5b13fc6 Merge pull request #2483 from kacpherek/fix/held-item-outline
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the block outline uses the shape for the held item (light, scaffolding) and the shulker box's lid, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:39 +01:00
Ludovic Marie 1ad1e957a3 Merge pull request #2477 from kacpherek/fix/polar-bear-attack
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `PolarBear` (its goals, `PolarBearMeleeAttackGoal`'s rearing and warning sound, `PolarBearHurtByTargetGoal`, `PolarBearAttackPlayersGoal`, the stand animation and `DATA_STANDING_ID`). Left for later: the height doesn't grow while standing, the anger isn't saved, and anger at LAN guests isn't tracked. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:35 +01:00
Ludovic Marie b5f1e65162 Merge pull request #2467 from kacpherek/fix/execute-on-origin-leasher
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `execute on origin` (the thrower or shooter) and `on leasher`; your follow-up records the thrower of a guest's drops and the player behind a lead. Left for later: a fence knot leasher isn't an entity in Horizon, so it finds nothing. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:31 +01:00
Ludovic Marie 39910efabf Merge pull request #2466 from kacpherek/fix/autosave-indicator
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Gui.renderSavingIndicator` (the fade in and out around the autosave), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:28 +01:00
Ludovic Marie b0103bd62f Merge pull request #2465 from kacpherek/fix/baby-walk-position
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the baby leg animation uses the interpolated `walkAnimation` position, not the tick value, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:14:24 +01:00
Ludovic Marie 44eb7008f1 Merge pull request #2461 from kacpherek/fix/guest-text-commands
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `/tellraw`, `/title` and `/playsound` reach a server's players as their packets, and your follow-up plays a remote sound in the packet's category (`handleSoundEvent`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:11 +01:00
Ludovic Marie 179e410923 Merge pull request #2460 from kacpherek/fix/bonus-chest
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `BonusChestFeature.place` (shuffled columns, the chest with the `spawn_bonus_chest` loot table, a torch on each side that survives). I pushed one fix: `setInitialSpawn` passes the level random and the feature sets the blocks with flags 2 (`UPDATE_CLIENTS`). Left for later: it is skipped when the spawn chunk is not lit yet. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:07 +01:00
Ludovic Marie b0a6e63ce7 Merge pull request #2459 from kacpherek/fix/jigsaw-distance-padding-defaults
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `JigsawStructure`'s codec defaults (`max_distance_from_center` 80, the default `dimension_padding`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:04 +01:00
Ludovic Marie 4517d75a65 Merge pull request #2458 from kacpherek/fix/drowned-thrown-trident
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ThrownTrident` (8 damage, `dealtDamage`, the 0.99 water inertia, the hit and ground sounds, `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`) and the drowned's `TridentAttackGoal`. I pushed one fix: the damage goes through `EnchantmentHelper.modifyDamage` of the trident, so Impaling adds only for `#sensitive_to_impaling` (its `minecraft:damage` requirement), not for every mob that swims. Fixes #2447. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:12:00 +01:00
Ludovic Marie 4b042eae28 Merge pull request #2457 from kacpherek/fix/template-block-entity-nbt
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `StructureTemplate.placeInWorld` loads each block's template NBT into its block entity, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:11:57 +01:00
Ludovic Marie 30c6a4ac7b Merge pull request #2456 from kacpherek/fix/particle-count-zero
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ClientPacketListener.handleParticleEvent` with count 0 makes one particle moving at max speed times the delta. I pushed one fix: both are floats in the packet, multiplied as floats before the widening. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:46 +01:00
Ludovic Marie ce73764585 Merge pull request #2455 from kacpherek/fix/horse-tail-swing
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractHorse.aiStep`'s 1-in-200 `moveTail` and its 8-tick `tailCounter`, with the model's tail swing, match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:41 +01:00
Ludovic Marie 59d0b45349 Merge pull request #2454 from kacpherek/fix/score-text-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ScoreContents` resolution in `/tellraw` and `/title`; your follow-up gives armor stands, frames, paintings, boats, carts and others their UUID name, as `getScoreboardName` does. Left for later: the `*` holder resolves to the acting player, not to each recipient. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:37 +01:00
Ludovic Marie 7373ee8da5 Merge pull request #2453 from kacpherek/fix/entity-shadows-option
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the Entity Shadows option gates `EntityRenderDispatcher`'s shadow pass, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:33 +01:00
Ludovic Marie a6af0d647f Merge pull request #2445 from kacpherek/fix/2384-sort-cursor-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: the client's `AbstractContainerMenu.clicked` predicts the sort and tweak clicks like any other, so the last sorted item stays under the cursor until the server answers. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:09:29 +01:00
Ludovic MarieandClaude Opus 5.5 8b87d128e6 parity: world_reopen and server_player_sculk count ticks, not request round trips
On playhorizon (frames drawn on the CPU, control requests answered slowly)
world_reopen read the minecarts before the mineshaft's last one arrived (3
then 4 on every reopen), and the sculk walk polled the sensor with separate
requests that missed its 30-tick active window. The minecart list is read
once it stops changing; both clients sample the sensor every tick from one
ctl script, alongside the walk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 04:08:32 +01:00
Ludovic Marie 8a2c7eee4a Merge pull request #2444 from kacpherek/fix/enchanted-hit
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Player.attack` sends the enchanted hit (animate action 2, `MAGIC_CRITICAL_HIT`) when the weapon's enchantments add damage, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:17 +01:00
Ludovic Marie 8a572c9239 Merge pull request #2443 from kacpherek/fix/mob-death-poof
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.tickDeath` sends entity event 60 at 20 ticks and the client's `makePoofParticles` matches. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:12 +01:00
Ludovic Marie 6ea9f9580c Merge pull request #2440 from kacpherek/fix/bundle-selected-item
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BundleItem` / `ClientBundleTooltip` mouse-wheel selection (`ServerboundSelectBundleItemPacket`, cleared when the cursor leaves the slot, now on a server's client too). Left for later: the selected item's name line in the tooltip. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:09 +01:00
Ludovic Marie e9bcd10869 Merge pull request #2435 from kacpherek/fix/landing-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LivingEntity.checkFallDamage` sends the landing `block` particles (`150 * scale` of them, scaled by the fall distance, at speed 0.15) where the fall ends, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:05 +01:00
Ludovic Marie 3bd504c6c4 Merge pull request #2433 from kacpherek/fix/llama-spit-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LlamaSpit.recreateFromPacket` adds seven spit particles at 0.4 + 0.1 i of its movement, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:07:01 +01:00
Ludovic Marie a0343bceab Merge pull request #2431 from kacpherek/fix/explosion-emitter-size
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ServerExplosion.isSmall` (radius under 2, or the blocks kept) picks the small particle, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:41 +01:00
Ludovic Marie 25ad1db6d3 Merge pull request #2430 from kacpherek/fix/item-tooltip-lines
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `ItemStack.addDetailsToTooltip`: the order here (instrument, bees, container, banner patterns, written book, charged projectiles with their indented details, fireworks, potion with `When Applied:`, jukebox, trim, stored and plain enchantments, dyed, lore, attributes, unbreakable, block state, spawner, then the advanced durability, id and component count) is vanilla's, and the crossbow lines match `ChargedProjectiles.addProjectileTooltip`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:38 +01:00
Ludovic Marie 65cc4c6f6b Merge pull request #2429 from kacpherek/fix/lan-player-pose-flags
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `Entity.DATA_SHARED_FLAGS_ID` bits (on fire, crouching, swimming, invisible, glowing, fall flying) and the pose for LAN players; your follow-up also sends the crouching pose under a low ceiling, which matches `Player.updatePlayerPose`. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:34 +01:00
Ludovic Marie 8892f0fabf Merge pull request #2427 from kacpherek/fix/creaking-heart-explosion
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `CreakingHeartBlock` / `CreakingHeartBlockEntity.removeProtector` on an explosion kills the bound creaking. Left for later: no XP for a heart broken by a player's TNT (Horizon doesn't track the TNT's owner yet). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:31 +01:00
Ludovic Marie d6fc0f6e43 Merge pull request #2424 from kacpherek/fix/note-block-particle
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NoteBlock.triggerEvent` adds the note particle 1.2 above the block with note / 24 as its colour, as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:04:27 +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
Ludovic Marie 517cbe76bd Merge pull request #2423 from kacpherek/fix/entity-broadcast-range
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ChunkMap.TrackedEntity.getEffectiveRange` ends in `scaledRange`, which applies `entity-broadcast-range-percentage`, as done here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:02:02 +01:00
Ludovic Marie 236371d06d Merge pull request #2422 from kacpherek/fix/honey-block-slide
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's `HoneyBlock` (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects` with its 1-in-5 sound and particle rolls, entity event 53). Your follow-up runs it for a server's player too, with the sound to the others and event 53 to everyone, which is how vanilla splits it. Left for later: mobs don't slide yet, and there is no `honey_block_slide` criterion. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:58 +01:00
Ludovic Marie 06c2371360 Merge pull request #2421 from kacpherek/fix/bee-wither-rose-eyeblossom-contact
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `WitherRoseBlock`'s effect (wither for 40 ticks on the mobs inside it) and the eyeblossom's poison on bees. Since then you added `MobEffectInstance.update`'s rule (a stronger effect replaces, an equal one keeps the longer duration, a weaker one changes nothing), which matches. Left for later: a weaker, longer effect is not kept hidden to resume afterwards (`hiddenEffect`). Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:55 +01:00
Ludovic Marie b3a1f66c35 Merge pull request #2419 from kacpherek/fix/animate-tick-extras
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3's particle providers and `animateTick`s: honey drips (`DripParticle`), the poplar and azalea leaf particles, falling dust under gravity blocks and the campfire food smoke match vanilla's rates, sprites and velocities. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:51 +01:00
Ludovic Marie cca3b735cc Merge pull request #2462 from kacpherek/fix/mob-block-layers
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `SnowGolem.DATA_PUMPKIN_ID` and `MushroomCow.DATA_TYPE` with their layers (`SnowGolemHeadLayer`, `MushroomCowMushroomLayer`), as here. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 04:01:48 +01:00
Ludovic MarieandClaude Opus 5.5 e338690bf9 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:56 +01:00
Ludovic MarieandClaude Opus 5.5 0ab24d0805 Merge main into this branch (resolves the conflict with main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:58:55 +01:00
Ludovic Marie dfa907d28e Merge pull request #2601 from ludovic111/feat/launcher-packs-core
🤖 Agent: launcher-features-1 checked this: base main, clean merge with main, launcher CI green; fmt, clippy -D warnings and the launcher tests pass locally. Queued for merge.
2026-10-27 03:57:27 +01:00
Ludovic Marie d9dc1c866b Merge pull request #2442 from kacpherek/fix/splash-potion-burst
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `LevelEvent` 2002 / 2007 (`LevelEventHandler`: the item crumbs, then the coloured spell or instant effect particles) and the 1053 / 1054 break sounds. It conflicted with main in `game.rs` (the zombie door sounds 1019 / 1021 landed there), so I merged main in, keeping both lists. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:51 +01:00
Ludovic Marie ec8d5f1e06 Merge pull request #2432 from kacpherek/fix/sprint-dust
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Entity.canSpawnSprintParticle` and `spawnSprintParticle` (the block under `getOnPosLegacy`, clamped into it, fences and walls taken from the supporting block) match. Gate: this wave's 50 PRs merged on main in one tree (union 9033a9c3b): fmt and clippy `-D warnings` clean, the horizon-client (1136 lib plus the integration tests), horizon-client-core (37), horizon-world (165) and horizon-protocol (100) tests pass, the fast build links, and the 140 harnesses `parity.py` picks for the tree's changed files pass on playhorizon (remote_fireworks, a known load flake there, passed on a rerun on a quiet Mac mini). `lan_e2e.py` passes 14 checks with the host on its server thread and in-frame (chest, xp, damage, kills, projectiles, mobs, drops, body, stopsound, digging, instant_digging, books, knockback, barter); `hurt` fails on main too when run after `damage` (check-order dependency, not this wave). Thanks @kacpherek!
2026-10-27 03:56:48 +01:00
Ludovic MarieandClaude Opus 5.5 cde33a8e42 launcher: minimise or hide the window while the game runs
Settings > While playing: Stay (the default), Minimise, or Hide (macOS).
The window goes away when the game starts and comes back to the front
when it closes, with the run's play time, errors and log. CLI and MCP:
settings while-playing stay|minimize|hide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 0ccf3e110b launcher: live game console with search, follow, copy and share log
Home's Console shows the game's output of this run as it comes, with a
search, Follow, Copy and Open logs folder. Share log uploads it to mclo.gs
only after the player confirms, with the home folder, user name and
account names taken out first, and can delete the paste again. CLI:
share-log and unshare-log (MCP tools, the second needs confirm).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 b8b353c92e launcher: import a file dropped anywhere on the window
A file dropped on the window goes where it belongs:
- a shared instance becomes a new instance, which gets selected on Home;
- a Java modpack goes to the Modpacks import;
- a resource or shader pack goes into the selected instance;
- a data pack points to the world rows.

A note in the corner says what happened, until it is closed. The Packs
section and the world rows still take their own drops, and now stop the
drop there so it isn't imported twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 e28bcba3d3 launcher: import a shared instance, or route any dropped file
`horizon_launcher::import` works out what a file is from its contents:
- a shared instance: a .mrpack needing no mod loader (as `instance
  export` writes) or a game-folder zip;
- a Java modpack: a .mrpack with a loader, or a CurseForge zip;
- a resource or shader pack;
- a data pack.

A shared instance becomes a new instance named after the pack, with
"(2)" if the name is taken. Its overrides/ (or the zip's entries) are
copied in, but only the files an instance keeps, and only paths that
stay inside its folder. Its .mrpack files are downloaded from Modrinth
and checked against their SHA-512. If anything fails, the half-made
folder is removed.

CLI and MCP: `horizon-launcher instance import <file> [--instance <i>]`
does what dropping the file on the window will do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:31:52 +01:00
Ludovic MarieandClaude Opus 5.5 4482ff63f3 launcher: Export… on Home's Instance row
The Instance row gets Export…, which opens a panel to pick .mrpack or
.zip and switch on the worlds to include. The file goes to exports/ in
the launcher's data folder under a free name, and the panel offers Show
in folder. The panel closes when another instance is picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:25:19 +01:00
Ludovic MarieandClaude Opus 5.5 a9e5214ae4 launcher: export an instance as a .mrpack or a zip
`horizon-launcher instance export [<instance>] [--format mrpack|zip]
[--world <w>]... [--out <file>]` (and its MCP tool) writes what makes an
instance, or a port: options.txt, horizon-*.txt, servers.dat, resource
and shader packs, horizon-mods/ and config/, plus the worlds named. It
never includes logs, screenshots, backups, caches or account files. A
world leaves out playerdata/, stats/ and advancements/, and its level.dat
is written without the player's UUID.

A .mrpack follows Modrinth's published format: modrinth.index.json
lists the resource packs the browser installed from Modrinth that are
unchanged, by their download with SHA-1 and SHA-512 asked from
Modrinth's API. The rest goes under overrides/. An instance with nothing
to share is refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:24:53 +01:00
Ludovic MarieandClaude Opus 5.5 2a7420651e launcher: find data packs on Modrinth and install one into a world
The Mods page gains a Data packs tab: Modrinth's data packs for 26.3,
each marked Partly with what Horizon runs of a data pack today
(functions, function tags, predicates, item modifiers). Install puts the
project's data pack file (not its mod build), checksum checked, into the
datapacks/ of a world picked on the tab, and says what of that zip runs
and what isn't read yet. A pack with nothing Horizon runs (worldgen
only) is refused. The world's list on Home shows it.

CLI and MCP: `mods search --kind datapack`, `mods install <pack> --world
<world>`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:15:49 +01:00
Ludovic MarieandClaude Opus 5.5 9cb2779608 launcher: install mods and packs into the selected instance
The Mods page and `mods` commands installed everything into the default
game folder whichever instance was picked. Mods, resource packs and
shader packs now go into the selected instance's folder, and the
Installed list, updates, Remove and the mod switches are that
instance's; picking another instance reads its list. The page says which
instance it installs into when there are several. `mods list|install|
update|remove|enable|disable` take `--instance` (default: the selected
one). Java mods' checks stay shared in the default folder for the
search results.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 03:02:09 +01:00
Ludovic MarieandClaude Opus 5.5 aa6fdac501 Launcher: data packs into a world
datapacks.rs lists a world's datapacks/ (zips and folders with
pack.mcmeta, as the game discovers them; off when datapacks.json lists them
disabled), adds a data pack zip under a free name (refusing resource packs:
no data/ folder) and removes one to the trash, forgetting it in
datapacks.json. The game turns a new pack on when the world opens.

Home: each world gets "Data packs", which opens its packs under the row
with Add data pack… and Remove; a zip dropped on a world goes into it.
CLI: `worlds datapack-list|datapack-add|datapack-remove <world>`;
datapack-remove needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:49:42 +01:00
Ludovic MarieandClaude Opus 5.5 cac63dedca Launcher: Update all for installed packs
The Mods page's Installed list gets "Update all (N)" when newer versions
were found: every pack with one is updated in turn on a thread (each
download checked as an install is), with "1 of 2: …" progress, and one that
fails is named without stopping the others. The CLI already does this with
`mods update` and no pack name (MCP: mods_update).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:42:09 +01:00
Ludovic MarieandClaude Opus 5.5 3c257bb3e6 AGENTS.md: launcher test runs set HORIZON_LAUNCHER_TRASH and HORIZON_LAUNCHER_CLIPBOARD
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:33:31 +01:00
Ludovic MarieandClaude Opus 5.5 2ef155348f Launcher: keep test runs away from the real trash and clipboard
desktop_seams.rs holds the launcher's two hard-to-undo desktop effects.
HORIZON_LAUNCHER_TRASH=<folder> makes Delete (worlds, screenshots, packs)
move into that folder instead of the system's trash, and
HORIZON_LAUNCHER_CLIPBOARD=<file> makes every Copy in the window write into
that file instead of the clipboard. Test runs set both inside their scratch
folders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:32:14 +01:00
Ludovic MarieandClaude Opus 5.5 e5c3d7cff7 Launcher: Packs section on Home, with drag and drop
Home lists the selected instance's resource and shader packs with a switch
each (disabled while the game runs: it rewrites options.txt itself), Remove
after a confirmation, and Add pack… or a .zip/.hshader dropped on the
section. switch() now takes any id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 ddaa514f3a Launcher: resource and shader packs per instance (library and CLI)
packs.rs lists an instance's resource packs (resourcepacks/, a .zip or a
folder with pack.mcmeta) and shader packs (shaderpacks/: Horizon's own and
the shaders/ format, as the game's Shader Packs page lists them), and turns
them on or off the game's own way: resourcePacks in options.txt (a pack
turned on goes on top) and pickedShaderPack in horizon-graphics.txt (one at
a time); every other line of both files stays. add() copies a dropped file
into the right folder under a free name; remove() turns it off and moves it
to the trash.

CLI: `packs [list|add|on|off|remove] [--kind] [--instance]`; `packs remove`
needs confirm over MCP.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Kacpherek 8f78fdeb7b Keep leash knot ids per level and clear remote knots with the other remote objects 2026-10-26 22:08:35 +01:00
Kacpherek ed9fdb5208 Point an explosion's hurt tilt at the blast 2026-10-26 22:04:40 +01:00
Ludovic MarieandClaude Opus 5.5 3f0e70882c Death messages: no fallback for a message with no entity behind it
DamageSource.getLocalizedDeathMessage sends the one-argument message, and
TranslatableContents.decompose shows a template it cannot fill as written.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:03:42 +01:00
Kacpherek 6120c8c0ad Attack the block when a guest's START_DESTROY_BLOCK arrives 2026-10-26 22:03:17 +01:00
Ludovic MarieandClaude Opus 5.5 54ae097faa Merge main into the branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:46 +01:00
Ludovic MarieandClaude Opus 5.5 1b0937abff Piglin celebration: the dance roll and wasHurtBy as in PiglinAi
wantsToDance draws from RandomSource.createThreadLocalInstance(gameTime),
not the piglin's random, and wasHurtBy ignores another piglin's hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:00:05 +01:00
Kacpherek 53ea3e7224 Play the rain sound for this client only 2026-10-26 22:00:04 +01:00
Ludovic MarieandClaude Opus 5.5 34f57dbcac Trident: Impaling through the enchantment's damage effect
ThrownTrident.onHitEntity runs EnchantmentHelper.modifyDamage on the
trident, so Impaling adds to the 8 for the entity types in
#sensitive_to_impaling (its minecraft:damage requirement), not for every
mob that swims or breathes water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:31 +01:00
Kacpherek 462787aee1 Keep the Infested event when a mob is hit by a player or another mob 2026-10-26 21:58:25 +01:00
Ludovic MarieandClaude Opus 5.5 83b544f04f Bonus chest: draw from the level random and set blocks with UPDATE_CLIENTS
MinecraftServer.setInitialSpawn passes level.getRandom() to
BonusChestFeature.place, which sets the chest and torches with flags 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:57:31 +01:00
Ludovic MarieandClaude Opus 5.5 1258c2473b Multiply the particle speed and delta as floats, as handleParticleEvent does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:54:53 +01:00
Kacpherek 3bb6e40304 Clear a bundle's selection when the cursor leaves it on a server's client too 2026-10-26 21:46:13 +01:00
Kacpherek eab8d0f911 Slide on honey for the server's player and show the slide effects to multiplayer clients 2026-10-26 21:45:02 +01:00
Kacpherek d7fece5825 Keep hideParticles of mob effects: no swirls drawn or sent for them 2026-10-26 21:42:52 +01:00
Kacpherek bab4251715 Give armor stands, frames, paintings, boats, carts and other entities a scoreboard name 2026-10-26 21:37:47 +01:00
Kacpherek 9eb913affe Show the crouching pose of a guest who is not holding sneak 2026-10-26 21:37:33 +01:00
Kacpherek c808fae288 Keep a stronger active effect when a weaker one is added 2026-10-26 21:37:30 +01:00
Kacpherek da736d56d6 Lingering clouds skip mobs none of their effects can affect 2026-10-26 21:30:42 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek abb4e7b5cd Record the thrower of guest drops and the player behind a lead 2026-10-26 21:21:40 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
Ludovic MarieandClaude Opus 5.5 3755bb7d77 parity: stop harnesses on SIGTERM, and reap orphaned harness JVMs
Nine vanilla Java servers from harness runs had been orphaned on the Mac mini for 2 to 5 days. Each harness runs in
its own session (killpg on a timeout), so when a CI job was cancelled, its harnesses and their servers outlived
parity.py.
- parity.py: SIGTERM and SIGHUP exit through the finally blocks, which now stop the local executor's harnesses
  too, not only the remote hosts'. The worker (--worker) stops its harnesses the same way.
- runner-gc.sh (every 6 h from launchd): stops Java processes whose parent is gone (launchd, init or
  systemd --user) and whose command line names a runner's checkout or a worktree's .local/, once ORPHAN_HOURS
  (default 3) old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:10:41 +01:00
Kacpherek 94748162bc Handle a dig packet before the move queued behind it
The client breaks the block under its feet and falls in the same tick. The
hub checked that move against the block that was still there and sent the
player back. Moves queued behind a dig packet now wait for the next tick,
after the break.
2026-10-26 21:08:59 +01:00
Kacpherek 06b046d8fc Light, scaffolding and shulker box outlines follow the held item and the lid 2026-10-26 21:07:17 +01:00
Kacpherek c85f6f2e39 Give polar bears their attack AI and rearing pose
Polar bears only panicked and strolled. They now follow vanilla's goals: melee attack that rears up and growls before each strike, hurt-by and cub-defending targets, anger at the player, and fox hunting. A hurt cub calls the adults. The standing flag is synched and the client animates it.
2026-10-26 20:50:13 +01:00
Kacpherek 36c06d6133 Let a piglin celebrate a killed target and dance over a hoglin 2026-10-26 20:41:38 +01:00
Kacpherek ffc343aadd Make execute on origin and leasher find their entity 2026-10-26 20:37:39 +01:00
Kacpherek 4cf9ffd847 Draw the autosave indicator 2026-10-26 20:36:40 +01:00
Kacpherek e23fcb1040 Scale a baby's walk position when it is read, as vanilla does 2026-10-26 20:35:46 +01:00
Kacpherek 27b81d9a8f Give /damage on a player its source, knockback and kill credit 2026-10-26 20:35:42 +01:00
Kacpherek 7b3595b8f2 Draw the snow golem's pumpkin and the mooshroom's mushrooms 2026-10-26 20:34:18 +01:00
Kacpherek c417556834 Send /tellraw, /title and /playsound to LAN guests 2026-10-26 20:33:36 +01:00
Kacpherek bf62ccdabc Place the bonus chest in new worlds 2026-10-26 20:33:16 +01:00
Kacpherek 41d8b9e48a Jigsaw structures: default max distance vertical and padding top like vanilla 2026-10-26 20:32:20 +01:00
Kacpherek 0e658e3e62 Make drowned throw real tridents instead of arrows 2026-10-26 20:31:21 +01:00
Kacpherek 041ecb7b7a Structures: load the template's block entity NBT into every container and sign 2026-10-26 20:31:16 +01:00
Kacpherek 41ca489d4e Move /particle count 0 particles at speed times delta 2026-10-26 20:31:14 +01:00
Kacpherek 82d2b61a4a Swing a horse's tail now and then 2026-10-26 20:30:33 +01:00
Kacpherek f9b8b40a7d Show score components in /tellraw and /title 2026-10-26 20:30:13 +01:00
Kacpherek 08cd05ed6e Make the Entity Shadows option turn shadows off 2026-10-26 20:30:05 +01:00
Kacpherek f312aae7db Name the potion, cloud or thrower in magic death messages 2026-10-26 20:29:22 +01:00
Kacpherek e872d3ae54 Send isFlat in every dimension and use the dimension's min y for the horizon 2026-10-26 20:26:12 +01:00
Kacpherek 591b7b74da Predict the sort and tweak clicks on a server like an ordinary click 2026-10-26 20:15:10 +01:00
Kacpherek d78238f765 Show the enchanted hit when a melee weapon's enchantments add damage
Player.attack shows magic crit particles whenever the enchantments added
damage. Horizon showed only the critical hit, for the window's player and
for a guest's attack.
2026-10-26 20:14:32 +01:00
Kacpherek 303281ed09 Puff a poof where a dead mob's body goes
A mob removes its body twenty ticks after dying and tells everyone with
entity event 60, which draws twenty poofs about it. Horizon removed dead
mobs silently.
2026-10-26 20:12:28 +01:00
Kacpherek 18f8a6868b Burst a breaking potion into item crumbs and coloured spell particles
A thrown potion or experience bottle that breaks sends level events 2002 or
2007 with its colour and the splash sound (1053, 1054). Horizon spawned only
eight terrain crumbs, on the server's own side, so a server player saw
nothing.
2026-10-26 20:09:23 +01:00
Kacpherek 43d1f4b801 Draw the swirls of potion effects around players and mobs
A client draws the particles of an entity's visible effects itself, from
the synched effect particle list (LivingEntity.tickEffects). Horizon drew
none and sent no such list: now the window's own player, a server's mobs
and players, and the mobs and guests of a game in the frame all swirl.
2026-10-26 20:06:26 +01:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Kacpherek 96b538d48f Throw up landing dust where a hard fall lands
A landing that hurts sends the block's crumbs from the server
(LivingEntity.checkFallDamage). Horizon built the event but dropped it, for
the window's player and for every server player.
2026-10-26 19:59:59 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Kacpherek 4efa49ae82 Raise sprint dust under running players
A sprinting player kicks up dust of the block under their feet every tick
(Entity.spawnSprintParticle). Horizon drew none, for the window's player
or for the other players it sees.
2026-10-26 19:58:22 +01:00
Kacpherek 85db9a23a9 Use the small explosion particle when an explosion keeps blocks 2026-10-26 19:57:54 +01:00
Kacpherek 00cb813dc8 Show the missing item tooltip lines in vanilla order
Custom names are italic. Unbreakable, dyed, written book author and
generation, music disc songs, goat horn instruments, loaded crossbow
projectiles, hive bees and honey, the potion "When Applied" block and
enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency)
now appear, and advanced tooltips (F3+H) end with the item id and the
component count. Attribute lines use the attribute's sentiment colour.
2026-10-26 19:57:18 +01:00
Kacpherek 9e848670db Send swimming, gliding, fire, invisible and glowing for LAN players 2026-10-26 19:57:11 +01:00
Kacpherek dbd24e2e16 A creaking heart blown up by an explosion makes its creaking die 2026-10-26 19:57:02 +01:00
Kacpherek 5a70691d63 Show the note particle above a note block when it plays 2026-10-26 19:56:39 +01:00
Kacpherek e7e6e5bc07 Scale entity tracking range by entity-broadcast-range-percentage
The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance.
2026-10-26 19:56:22 +01:00
Kacpherek 4ebfaef0d3 Slide slowly down the sides of honey blocks 2026-10-26 19:56:07 +01:00
Kacpherek 7112d0cd64 Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison 2026-10-26 19:55:27 +01:00
Kacpherek 818b377c02 Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke 2026-10-26 19:54:32 +01:00
Kacpherek 484cc945aa Add the rockets list to the remote motion test fixture 2026-10-26 19:23:52 +01:00
Kacpherek 0c127ec1d9 Stub breaking_block_effects in the remote mining test fixture 2026-10-26 19:23:34 +01:00
Kacpherek a14da6bb70 Merge origin/main into fix/1302-speed-after-travel 2026-10-26 19:23:02 +01:00
Kacpherek 3114fc66e9 Merge origin/main into fix/2067-deflect-fireballs 2026-10-26 19:22:08 +01:00
Kacpherek 452fca8bdd Merge origin/main into fix/1938-ghast-dismount 2026-10-26 19:21:07 +01:00
Kacpherek ab40cd63e6 Merge origin/main into fix/remote-leash-knots 2026-10-26 19:20:16 +01:00
Kacpherek 00f2663e7b Merge origin/main into fix/harvest-full-beehive 2026-10-26 19:19:20 +01:00
Kacpherek 91d6bdba85 Fly firework rockets on by themselves between server updates 2026-10-26 19:11:21 +01:00
Kacpherek 4248048ab6 Merge origin/main into fix/elytra-firework-trail 2026-10-26 19:08:50 +01:00
Kacpherek 7f5e3384b3 Play mining hit sounds once, from the server's destroy level events
The server now sends level events 2019 and 2020 every tick a player digs
(ServerPlayerGameMode.tick), and clients handle them like LevelEventHandler:
a crack on the face, plus the hit sound for 2020. A remote client plays only
the first hit itself and no longer adds its own sound every fourth tick, so
the hit was heard twice, out of phase, for every block.
2026-10-26 19:07:11 +01:00
Kacpherek 0300ad8922 Keep the current attack target regardless of invisibility 2026-10-26 18:13:33 +01:00
Kacpherek e8ae26c270 Carry the exploding mob's kind with its blast and anger bees at guests too 2026-10-26 18:09:01 +01:00
Kacpherek b62095dfcb Run the trident player_hurt_entity trigger as the thrower 2026-10-26 17:25:37 +01:00
Kacpherek ba3b567f54 Predict a full beehive reacting to shears and glass bottles on the client 2026-10-26 17:15:29 +01:00
Kacpherek 5d3e9d218d Clean copper for a skeleton trap's visual lightning too 2026-10-26 17:05:35 +01:00
Kacpherek 36ef05b0b9 Keep infinite mob effects through the effect tick 2026-10-26 16:51:22 +01:00
Kacpherek 49ad61035b Count shield damage only for accepted hits and skip kills saved by a totem 2026-10-26 16:47:11 +01:00
Kacpherek 3842d851d8 Match the brewed_potion trigger's potion condition as a PotionsPredicate 2026-10-26 16:38:07 +01:00
Kacpherek c987778e97 Decorated pot: keep every quad and cull back faces like entitySolid 2026-10-26 16:34:24 +01:00
Kacpherek b28108128d Keep a hopper minecart's items in sync with a leftover menu copy 2026-10-26 16:33:08 +01:00
Kacpherek c50557ea0b Credit the projectile owner for explosion damage to mobs 2026-10-26 16:29:05 +01:00
Kacpherek f364c4f9c6 Leave wandering traders out of the spawn egg baby check 2026-10-26 16:28:28 +01:00
Kacpherek 9841ce7824 Revert "Decorated pot: draw one face of its top and bottom planes"
This reverts commit 408a75aeb3.
2026-10-26 16:27:13 +01:00
Kacpherek 83a6f5c7b9 Revert "Decorated pot: say why only the outer faces stay"
This reverts commit d338bcd070.
2026-10-26 16:27:13 +01:00
Kacpherek 18f4dd92ae Play container open and close sounds only when the opener count crosses zero 2026-10-26 16:24:42 +01:00
Kacpherek 47654b62a3 Save the armadillo scute timer and reset it only when a scute can drop 2026-10-26 16:24:26 +01:00
Kacpherek 2f3dade42f Show the firework trail on a boosting rocket and hide its sprite
On a server-thread client an elytra boost rocket drew no spark trail and
showed up as a static item sprite. The client now follows the entity the
rocket is attached to, spawns the trail each tick like
FireworkRocketEntity.tick, and does not draw an attached rocket.
2026-10-26 16:19:53 +01:00
Kacpherek 6f4c2cfbbc Count the hurt tilt down while the player is dead or riding 2026-10-26 16:16:19 +01:00
Kacpherek af49fd7dc3 Use a collision-shape raycast for the spyglass line-of-sight check 2026-10-26 16:07:14 +01:00
Kacpherek af6faa88e5 Report the hook's open-water flag to the fishing_rod_hooked trigger 2026-10-26 16:06:30 +01:00
Kacpherek e71d4281ac Spawn a baby when a spawn egg is used on its own mob kind
Mob.checkAndHandleImportantInteractions: a spawn egg of the clicked mob's
kind spawns a baby at the mob's position (SpawnEggItem.spawnOffspringFromSpawnEgg),
inheriting the parent's variant, and uses one egg outside creative.

Fixes #2269.
2026-10-26 16:04:35 +01:00
Kacpherek 26626e8c27 Give frogs vanilla's smooth swimming move control 2026-10-26 15:45:51 +01:00
Kacpherek 7102727197 Play the container close sound when a server-thread player closes it 2026-10-26 15:24:26 +01:00
Kacpherek 70868f3208 Give Oozing, Infested, Weaving and Wind Charged their effects
These four 26.x effects only existed as potion entries. Oozing spawns two slimes, Weaving places cobwebs and Wind Charged makes a gust when the bearer dies. Infested sometimes releases silverfish when the bearer is hurt.
2026-10-26 15:20:34 +01:00
Kacpherek aff673a08d Give the Ender Dragon its ambient sound 2026-10-26 15:20:10 +01:00
Kacpherek 2e642857fc Fire the levitation and using_item advancement triggers 2026-10-26 15:20:03 +01:00
Kacpherek 147e5269c7 Play the rain sound the way vanilla picks it 2026-10-26 15:19:20 +01:00
Kacpherek b3a499d85d Play chest, barrel and shulker box sounds with a random pitch from the right spot 2026-10-26 15:18:05 +01:00
Kacpherek 3c013dac75 An axe turns an unoxidized copper golem statue back into a golem
The golem stands where the statue was, facing the same way, the axe
loses durability and the statue block is removed.
2026-10-26 15:18:03 +01:00
Kacpherek b913dab5e5 Walk, follow and flee at the camel's real AI speeds 2026-10-26 15:17:53 +01:00
Kacpherek e87e839c97 Fire the construct_beacon trigger when a beacon activates 2026-10-26 15:17:44 +01:00
Kacpherek 8c8f857313 Fire item_used_on_block when an axe, honeycomb, disc or vault key is used on a block 2026-10-26 15:17:42 +01:00
Kacpherek 9c427dfae0 Mobs killed by a wither leave a wither rose 2026-10-26 15:16:54 +01:00
Kacpherek 13144a82a1 Pull the third-person camera back further when riding a happy ghast 2026-10-26 15:16:51 +01:00
Kacpherek ef711f8b1f Fire the brewed_potion trigger when a potion is taken from a brewing stand 2026-10-26 15:16:44 +01:00
Kacpherek 601e7918a8 Lightning scrapes one oxidation step off a copper golem
A copper golem hit by a bolt goes back one weathering step, and its
weathering timer (or wax) is reset.
2026-10-26 15:16:43 +01:00
Kacpherek dd3697bb4d Hopper minecarts take from the container above, one item per tick 2026-10-26 15:16:43 +01:00
Kacpherek 273729aac8 Count the combat statistics
damage_resisted, damage_absorbed and damage_blocked_by_shield now add up when
Resistance, absorption hearts or a shield take damage, and a player who lands
the killing blow on another player gets player_kills.
2026-10-26 15:16:34 +01:00
Kacpherek 94592a35ac Use the flat world's horizon height for the dark disc and void fog 2026-10-26 15:16:33 +01:00
Kacpherek 728bf14aa7 Use vanilla ambient sound intervals for guardians, hoglins, shulkers and allays
A happy ghast with a passenger waits six times as long.
2026-10-26 15:16:06 +01:00
Kacpherek 53501ab94e Merge a mob's repeated effects like vanilla
A stronger effect now replaces the old one with its own duration, instead of taking the longer duration and the higher level separately.
2026-10-26 15:15:55 +01:00
Kacpherek 581dc08cf5 Fire shot_crossbow and killed_by_arrow advancement triggers 2026-10-26 15:15:43 +01:00
Kacpherek 85a6ce0921 Lightning scrapes oxidation off copper
A struck copper block goes back to unoxidized, and 3 to 5 random walks
take one step of oxidation off the weathering copper around it. Waxed
blocks stay as they are but still start the walks.
2026-10-26 15:15:30 +01:00
Kacpherek 1b4fe0f067 Wither rose and open eyeblossom give bees an effect instead of breeding them 2026-10-26 15:15:28 +01:00
Kacpherek fd0f09d6f6 End the Absorption effect once the absorption is used up
The effect used to stay in the list until its timer ran out, even with no absorption hearts left.
2026-10-26 15:15:20 +01:00
Kacpherek 06fb03567e Count the block interaction statistics
Eating a cake slice, using a cauldron (fill, use, clean armor, banner or shulker
box), tuning a note block, ringing a bell, opening an ender chest and putting a
record in a jukebox now add to their statistics, which stayed at 0 before.
2026-10-26 15:15:11 +01:00
Kacpherek c418034f10 Let goats breed, follow wheat and be milked
Goats only strolled and panicked, so wheat did nothing after the love
hearts and a bucket gave no milk. Give them the GoatAi core and idle
behaviours that exist here (panic 2.0, breed, tempt 1.25, baby follow
1.25) and Goat.mobInteract's milking with the goat milk sounds.
2026-10-26 15:15:07 +01:00
Kacpherek c273a7271e Make breeze shots follow the world difficulty and aim higher at riders 2026-10-26 15:14:59 +01:00
Kacpherek 5b9a66089e Give a partial item pickup the random pitch of a full pickup 2026-10-26 15:14:43 +01:00
Kacpherek 4c72a3a44f Make a detector rail's comparator read the minecart on it 2026-10-26 15:14:42 +01:00
Kacpherek 8691d9a8e8 Let players harvest a full beehive with shears or a glass bottle 2026-10-26 15:14:34 +01:00
Kacpherek d2105fe66c Round instant potion effects and measure splash range from the body
Instant health and harm round to the nearest point instead of truncating. A splash potion measures the distance to the entity's box, not its centre, so a potion landing next to a player hits harder and reaches farther.
2026-10-26 15:14:30 +01:00
Kacpherek 79bfb8fcb8 Let a brush take a scute off an armadillo
Right-clicking an adult armadillo with a brush did nothing. Follow
Armadillo.mobInteract: drop a scute from the brush loot table, play the
brush sound, post the entity_interact game event and wear the brush by 16.
2026-10-26 15:14:26 +01:00
Kacpherek 20630b1230 Fire the enchanted_item trigger when an enchanting table enchants 2026-10-26 15:14:25 +01:00
Kacpherek 8d77913ea5 Note blocks play on a left click, and take mob heads on top
Mining a note block now plays its note and counts the play_noteblock
statistic. A mob head used on the top face is placed instead of tuning
the block.
2026-10-26 15:14:10 +01:00
Kacpherek 66637220ef Play the step sound of the block above the floor for the player
Powder snow, lily pads, carpets, snow layers and similar blocks now sound like vanilla when walked on: inside blocks replace the floor's step, combination blocks play their own step with the floor's step muffled.
2026-10-26 15:13:58 +01:00
Kacpherek 5fa423169f Fire player_hurt_entity and entity_hurt_player advancement triggers 2026-10-26 15:13:51 +01:00
Kacpherek 183382cd2b Fire the fishing_rod_hooked trigger when a hook is reeled in 2026-10-26 15:13:34 +01:00
Kacpherek 84aba7b3d4 Count talking to villagers under vanilla's statistic name
The statistic was written as talk_to_villager, but vanilla's key is
talked_to_villager, so the statistics screen and scoreboard criteria always
showed 0. Wandering traders already count through the same code.
2026-10-26 15:13:27 +01:00
Kacpherek 329ce1b724 Make armadillos shed scutes on their own
Armadillos never dropped scutes because nothing ran their shed timer.
Follow Armadillo.customServerAiStep: count down 5 to 10 minutes, then
drop a scute, play the sound and post the entity_place game event.
2026-10-26 15:13:27 +01:00
Kacpherek e2a399aff0 Reopen signs on right click and apply dyes, ink sacs and honeycomb
Right-clicking a placed sign opened an empty inventory screen. It now
opens the editor for the side the player faces, or plays the waxed-sign
fail sound. Dyes, glow ink sacs, ink sacs and honeycomb change the
colour, glow or wax of the side the player faces and are used up.
2026-10-26 15:13:19 +01:00
Kacpherek 2a0824cf5f Invisible players are harder for mobs to notice
Mobs now scale their detection range by 0.7 times the worn armor fraction (at least 0.1) when the player has Invisibility, like vanilla's getVisibilityPercent.
2026-10-26 15:13:15 +01:00
Kacpherek a4d0f76edd Count movement statistics as vanilla does
Fall distance is counted once per landing from 2 blocks or more instead of on
every downward tick. Riding counts only for the mounts vanilla lists (pig,
horse family by full distance, strider, happy ghast, nautilus, boat, minecart),
and airborne sideways moves over 25 cm count as flying.
2026-10-26 15:13:08 +01:00
Kacpherek 00e2cfec29 Let tadpoles be fed slime balls to grow up faster 2026-10-26 15:12:57 +01:00
Kacpherek a5fa40d2ee Fire the villager_trade trigger so What a Deal! can be earned 2026-10-26 15:12:48 +01:00
Kacpherek cc39d57e07 Explosions empty and anger the bees of destroyed hives 2026-10-26 15:12:42 +01:00
Kacpherek 0a5e80de7b Step out of a minecart in vanilla's order 2026-10-26 15:12:33 +01:00
Kacpherek b7c47ccbf9 Let a sneaking player open chest and hopper minecarts 2026-10-26 15:11:52 +01:00
Kacpherek 9c3450622b Update comparators when a copper golem moves items in or out of a chest 2026-10-26 15:11:49 +01:00
Kacpherek 3615c65d18 Make allays drop what they carry on death and use the default pickup delay when throwing 2026-10-26 15:11:01 +01:00
Kacpherek 7cc8def23f Let a lit TNT minecart explode when it hits a wall 2026-10-26 15:10:57 +01:00
Kacpherek e553db9bb5 Let frogs take fall damage five points below the base amount 2026-10-26 15:10:09 +01:00
Kacpherek 790b191ed1 Bees let out of a sedated hive stay out for 400 ticks 2026-10-26 15:09:20 +01:00
Kacpherek d136dda092 Bee stings only poison on Normal and Hard 2026-10-26 15:08:48 +01:00
Kacpherek a8205dd80d Infested blocks release their silverfish when broken 2026-10-26 13:21:45 +01:00
Kacpherek f252941ac1 Charge melee exhaustion only when the hit hurts 2026-10-26 13:20:39 +01:00
Kacpherek 30fa41ddbd Make the fall, drowning, fire and freeze damage game rules work 2026-10-26 13:20:37 +01:00
Kacpherek e2781f7939 Apply suspicious stew effects when the stew is eaten 2026-10-26 13:20:35 +01:00
Ludovic MarieandClaude Opus 5.5 d338bcd070 Decorated pot: say why only the outer faces stay
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:31 +01:00
Kacpherek 8697f3fd3f Furnaces: drying a wet sponge fills the bucket in the fuel slot 2026-10-26 13:13:32 +01:00
Kacpherek 6bc8a8b889 Send sweep, damage indicator and fishing particles to a server's players
Vanilla sends these with ServerLevel.sendParticles, so every player
nearby sees them. Horizon drew them only in the host window's own
particle engine, which a window on a server (and a guest next to the
host) never sees.

One helper now sends a ClientboundLevelParticlesPacket with the same
count, offset and speed to each player within 32 blocks. While the host
holds the players (a guest's turn, a host hit on a guest) the packet
waits and goes out when the tick ends.
2026-10-26 13:09:45 +01:00
Kacpherek 4825e537cc Tilt a server player's camera towards the hit
The server never sent ClientboundHurtAnimationPacket, and the client
ignored the yaw it carried, so a player on a server got the flash but the
camera never tilted towards where the hit came from.

ServerPlayer.indicateDamage now runs where the hit's knockback is dealt
(and with no direction for hits that have none), the packet goes out when
the guest's turn ends, and the client keeps the yaw as Player.hurtDir for
GameRenderer.bobHurt.
2026-10-26 13:05:51 +01:00
Kacpherek 2e91bd84e9 Send villager, taming, love, spawn poof and snowball entity events to server clients
Single player on the server thread drew these particles only into the server's own engine. The server now sends the entity event byte where vanilla does (20, 12/13/14, 6/7, 18 and 3), and the client draws it in handleEntityEvent's way.
2026-10-26 13:05:47 +01:00
Kacpherek 5c0f7142f3 Draw the level events the client dropped: lava and torch smoke, drips, chorus, sniffer eggs, dragon fireball burst 2026-10-26 13:03:44 +01:00
Kacpherek 5d72fdd8c2 Add ambient effects for levers, repeaters, dripstone, wither roses, End gateways and lightning rods
These blocks have an animateTick in vanilla but nothing happened around them in Horizon. Each gets the same particles and odds as vanilla.
2026-10-26 13:02:23 +01:00
Kacpherek 3287a74d64 Send fence knots to server players so leads tied to fences are drawn 2026-10-26 13:00:40 +01:00
Kacpherek 946f47b3ec Keep a happy ghast solid and still under a rider who gets off it
On the server thread the window never learned the ghast was on its still
timeout, and its copy trailed the server's, so a rider dropped through the
ghast and the ghast flew off. Sync STAYS_STILL, assume the timeout on
dismount, and put the window's ghast under the landing spot.
2026-10-26 12:48:36 +01:00
Kacpherek 09078949d7 Send a guest's explosion to the players once their turn ends
An end crystal (or minecart) hit by a player on the server thread blew up
with sound and damage but no explosion particles. The blast runs inside the
guest's turn, while the host holds the players, so lan_explode had no hub to
send the explosion packet through and dropped it. Queue it like a wind burst
and send it when the turn is over.
2026-10-26 12:36:29 +01:00
Kacpherek 265965445d Save the world when the game gets SIGTERM, Ctrl+C or SIGHUP, and disconnect on native quit 2026-10-26 12:34:59 +01:00
Kacpherek f5f7448909 Lingering potions leave an effect cloud
A thrown lingering potion used to act as one weaker splash. It now leaves an area effect cloud like vanilla: radius 3, a 10 tick wait, 600 ticks, shrinking as it goes and by 0.5 each time it affects someone. Every 5 ticks it affects the mobs and players inside, with a 20 tick delay per entity, at a quarter of the duration (instant effects at half strength). Server players see it as an area_effect_cloud, like the dragon's breath clouds.
2026-10-26 12:31:40 +01:00
Kacpherek 9e3956282a Send a deflected fireball's new direction to the player at once
A ghast fireball or wind charge hit back by a player was turned on the server, but the player kept seeing it fly on until its next update ten ticks later. Mark the turn to be sent right away, as vanilla does with needsSync, and reset the acceleration power to 0.1 as an attack deflection does.
2026-10-26 12:18:36 +01:00
Kacpherek 408a75aeb3 Decorated pot: draw one face of its top and bottom planes 2026-10-26 11:45:03 +01:00
Ludovic MarieandClaude Opus 5.5 19a5098b14 Leave a respawned body's reset to the swap that builds it
Respawning drops the guest's body, so the next swap builds a fresh one,
which now clears what the old body was given (equipment, effects and
armor protection) itself.

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

Vanilla keeps the same ServerPlayer through ServerPlayer.teleport into
another level, with its attributes and MobEffect modifiers. A new body
now starts with nothing applied, and its effects are applied as soon as
it is created, before the movement that brought it is handled.
2026-10-26 00:16:57 +01:00
204 changed files with 14121 additions and 1279 deletions

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **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`. Never write to
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
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
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
ArrowSound::TridentHit => "trident_hit",
},
volume.to_bits(),
pitch.to_bits()
@@ -54,7 +54,7 @@ pub fn main() -> Result<()> {
match c["axis"].as_i64() {
Some(axis @ 0..=2) => {
let line = record("electric_spark");
particles::along_axis(&mut random, axis as usize, p, |p, v| {
particles::along_axis(&mut random, axis as usize, p, (10, 19), |p, v| {
lines.push(line(p, v))
});
}
@@ -75,6 +75,10 @@ pub fn main() -> Result<()> {
registry: assets.registry,
raining_at: &|_| false,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
gateway_particles: &|_, _| 0,
thundering: false,
ultrawarm: false,
};
engine.seed_randoms(1057, 0, 0);
engine.requested = Some(Vec::new());
@@ -580,7 +580,7 @@ fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
MobEvent::Explode(at, radius, _) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
@@ -1050,6 +1050,7 @@ pub fn main() -> horizon_client::Result<()> {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
@@ -1061,6 +1062,7 @@ pub fn main() -> horizon_client::Result<()> {
uuid: PLAYER_UUID,
allies: Default::default(),
remote: false,
passenger: false,
}
});
let mut scratch = LegacyRandom::new(0);
@@ -1098,6 +1100,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
@@ -208,7 +208,7 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> {
)),
MotionEvent::Emission {
state,
in_water: true,
muffled: true,
..
} => Some(format!("muffled-step {}", state.0)),
MotionEvent::BubbleColumnParticle { bubble, position } => Some(format!(
@@ -353,6 +353,7 @@ pub fn main() -> Result<()> {
horizon_world::food::Difficulty::Hard,
][op["difficulty"].as_u64().unwrap() as usize],
natural_regeneration: op["regenerate"].as_bool().unwrap(),
..Default::default()
};
}
"food" => {
+153 -22
View File
@@ -4,7 +4,7 @@
//! conditions advancements use).
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap},
collections::{BTreeMap, BTreeSet, HashMap},
path::Path,
};
@@ -437,6 +437,8 @@ pub struct EntityView {
/// mainhand, offhand, head, chest, legs, feet, body.
pub equipment: [Option<ItemView>; 7],
pub vehicle: Option<Box<EntityView>>,
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
pub looking_at: Option<Box<EntityView>>,
pub effects: Vec<(String, i32, i32)>,
/// Player: game mode name.
pub gamemode: Option<String>,
@@ -458,7 +460,7 @@ fn range_f(v: &Value, x: f64) -> bool {
_ => false,
}
}
fn range_i(v: &Value, x: i64) -> bool {
pub fn range_i(v: &Value, x: i64) -> bool {
range_f(v, x as f64)
}
/// A registry id, `#tag`, or list matched against one id.
@@ -473,6 +475,51 @@ pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
_ => false,
}
}
/// `PotionsPredicate`: the potion in a `HolderSet`, plus a `CollectionPredicate`
/// over the potion's effects (`contains`, `count`, `size`).
pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
if v.is_null() {
return true;
}
if !v["potions"].is_null() && !potion.is_some_and(|p| id_matches(&v["potions"], "potion", p, w))
{
return false;
}
let c = &v["effects"];
if c.is_null() {
return true;
}
let effects: Vec<(String, i32, i32)> = potion
.and_then(crate::brewing::potion)
.map(|d| {
d.effects
.iter()
.map(|(id, dur, amp)| (format!("minecraft:{id}"), *amp, *dur))
.collect()
})
.unwrap_or_default();
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
let test = |pred: &Value, e: &(String, i32, i32)| {
pred.as_object().is_some_and(|m| {
m.get(&e.0).is_some_and(|want| {
range_i(&want["amplifier"], i64::from(e.1))
&& range_i(&want["duration"], i64::from(e.2))
&& want["ambient"].as_bool().is_none_or(|b| !b)
&& want["visible"].as_bool().is_none_or(|b| b)
})
})
};
c["contains"]
.as_array()
.is_none_or(|l| l.iter().all(|p| effects.iter().any(|e| test(p, e))))
&& c["count"].as_array().is_none_or(|l| {
l.iter().all(|entry| {
let n = effects.iter().filter(|e| test(&entry["test"], e)).count();
range_i(&entry["count"], n as i64)
})
})
&& range_i(&c["size"], effects.len() as i64)
}
/// `ItemPredicate`.
pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
if v.is_null() {
@@ -614,6 +661,18 @@ fn components(expected: &Value, have: &[(String, String)]) -> bool {
})
})
}
/// `DistancePredicate.matches` between two points.
pub fn distance(v: &Value, a: [f64; 3], b: [f64; 3]) -> bool {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
range_f(&v["x"], d[0].abs())
&& range_f(&v["y"], d[1].abs())
&& range_f(&v["z"], d[2].abs())
&& range_f(&v["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&v["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
/// `EntityPredicate` (26.x keyed sub-predicates).
pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
@@ -629,21 +688,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
"minecraft:stepping_on" => {
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
}
"minecraft:distance" | "distance" => {
let d = [
e.position[0] - origin[0],
e.position[1] - origin[1],
e.position[2] - origin[2],
];
range_f(&p["x"], d[0].abs())
&& range_f(&p["y"], d[1].abs())
&& range_f(&p["z"], d[2].abs())
&& range_f(&p["horizontal"], (d[0] * d[0] + d[2] * d[2]).sqrt())
&& range_f(
&p["absolute"],
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt(),
)
}
"minecraft:distance" | "distance" => distance(p, e.position, origin),
"minecraft:flags" | "flags" => {
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
f("is_on_fire", e.on_fire)
@@ -678,11 +723,16 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
})
})
}),
"minecraft:type_specific/player" => p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}),
"minecraft:type_specific/player" => {
p["gamemode"].as_array().is_none_or(|modes| {
e.gamemode
.as_deref()
.is_some_and(|g| modes.iter().any(|m| m.as_str() == Some(g)))
}) && (p["looking_at"].is_null()
|| e.looking_at
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], 0)
&& (p["entity_struck"].is_null()
@@ -703,6 +753,87 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// `KilledByArrowTrigger.TriggerInstance.matches`: the weapon, each listed
/// victim condition matched by a distinct victim, and the count of entity types.
pub fn killed_by_arrow(
c: &Value,
me: &EntityView,
victims: &[EntityView],
weapon: Option<&ItemView>,
w: &dyn World,
) -> bool {
fn ctx<'a>(e: &'a EntityView, me: &EntityView) -> Entities<'a> {
Entities {
this: Some(e),
origin: me.position,
tool: None,
block_pos: None,
}
}
if !conditions(&c["player"], &ctx(me, me), w) {
return false;
}
if !c["fired_from_weapon"].is_null()
&& !weapon.is_some_and(|it| item(&c["fired_from_weapon"], it, w))
{
return false;
}
let mut left: Vec<&EntityView> = victims.iter().collect();
for want in c["victims"].as_array().into_iter().flatten() {
let Some(i) = left.iter().position(|e| conditions(want, &ctx(e, me), w)) else {
return false;
};
left.remove(i);
}
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
/// The damage type id (`minecraft:arrow`).
pub kind: String,
/// `DamageSource.getDirectEntity` and `getEntity`.
pub direct: Option<EntityView>,
pub source: Option<EntityView>,
/// `DamageSource.isDirect`: the direct entity is the causing one.
pub is_direct: bool,
/// The damage before and after the shield took its share.
pub original: f32,
pub actual: f32,
pub blocked: bool,
}
/// `DamagePredicate`, with its `DamageSourcePredicate` as `type`.
pub fn damage(v: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]) -> bool {
if v.is_null() {
return true;
}
let by = |key: &str, e: &Option<EntityView>| {
v[key].is_null() || e.as_ref().is_some_and(|e| entity(&v[key], e, w, origin))
};
if !range_f(&v["dealt"], f64::from(d.original))
|| !range_f(&v["taken"], f64::from(d.actual))
|| !by("source_entity", &d.source)
|| v["blocked"].as_bool().is_some_and(|b| b != d.blocked)
{
return false;
}
let ty = &v["type"];
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
let tag = t["id"].as_str().unwrap_or("");
let tag = tag.strip_prefix('#').unwrap_or(tag);
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
});
let direct_ok = ty["direct_entity"].is_null()
|| d.direct
.as_ref()
.is_some_and(|e| entity(&ty["direct_entity"], e, w, origin));
let source_ok = ty["source_entity"].is_null()
|| d.source
.as_ref()
.is_some_and(|e| entity(&ty["source_entity"], e, w, origin));
tags_ok && direct_ok && source_ok && ty["is_direct"].as_bool().is_none_or(|b| b == d.is_direct)
}
/// The entities a loot-style condition list may refer to.
pub struct Entities<'a> {
pub this: Option<&'a EntityView>,
+372 -12
View File
@@ -8,7 +8,7 @@ use crate::{
blocks::{BlockFacts, Fluid},
command::args::GameMode,
items::ItemStack,
particles::{ParticleEngine, ParticleOptions, ParticleType as T},
particles::{ParticleEngine, ParticleOptions, ParticleType as T, along_axis},
world::World,
};
use horizon_core::{
@@ -35,8 +35,9 @@ enum Animate {
Campfire {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
Leaves(Option<(T, f32)>),
/// 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>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,6 +50,8 @@ enum Animate {
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -70,6 +73,22 @@ enum Animate {
waterlogged: bool,
},
EndPortal,
/// A powered lever: its facing and the direction it is attached toward.
Lever {
facing: [i32; 3],
connected: [i32; 3],
},
/// A powered repeater.
Repeater {
facing: [i32; 3],
delay: i32,
},
/// A free-hanging stalactite tip that is not waterlogged.
PointedDripstone,
WitherRose,
EndGateway,
/// A lightning rod, by the axis it points along (0, 1, 2 for X, Y, Z).
LightningRod(usize),
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -84,6 +103,7 @@ fn step(facing: &str) -> [i32; 3] {
fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let lit = prop("lit").as_deref() == Some("true");
let powered = prop("powered").as_deref() == Some("true");
let facing = || step(&prop("facing").unwrap_or_default());
match name {
"torch" => Animate::Torch(T::Flame),
@@ -104,10 +124,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))),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
| "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),
// 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))
}
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -119,6 +146,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// 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"),
)),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
@@ -149,6 +184,38 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
"end_portal" => Animate::EndPortal,
"lever" if powered => Animate::Lever {
facing: facing(),
// FaceAttachedHorizontalDirectionalBlock.getConnectedDirection.
connected: match prop("face").as_deref() {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing(),
},
},
"repeater" if powered => Animate::Repeater {
facing: facing(),
delay: prop("delay").and_then(|d| d.parse().ok()).unwrap_or(1),
},
"pointed_dripstone"
if prop("vertical_direction").as_deref() == Some("down")
&& prop("thickness").as_deref() == Some("tip")
&& prop("waterlogged").as_deref() != Some("true") =>
{
Animate::PointedDripstone
}
"wither_rose" => Animate::WitherRose,
"end_gateway" => Animate::EndGateway,
_ if name == "lightning_rod" || name.ends_with("_lightning_rod") => {
let [dx, dy, _] = facing();
Animate::LightningRod(if dx != 0 {
0
} else if dy != 0 {
1
} else {
2
})
}
"sculk_sensor" | "calibrated_sculk_sensor"
if prop("sculk_sensor_phase").as_deref() == Some("active") =>
{
@@ -161,6 +228,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
_ => Animate::None,
}
}
/// `DyeColor.getMapColor` of a concrete powder colour.
fn concrete_powder_color(dye: &str) -> u32 {
match dye {
"white" => 0xFFFFFF,
"orange" => 0xD87F33,
"magenta" => 0xB24CD8,
"light_blue" => 0x6699D8,
"yellow" => 0xE5E533,
"lime" => 0x7FCC19,
"pink" => 0xF27FA5,
"gray" => 0x4C4C4C,
"light_gray" => 0x999999,
"cyan" => 0x4C7F99,
"purple" => 0x7F3FB2,
"blue" => 0x334CB2,
"brown" => 0x664C33,
"green" => 0x667F33,
"red" => 0x993333,
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
fn redstone_color(power: u8) -> [f32; 3] {
let f = f32::from(power) / 15.0;
@@ -198,6 +286,8 @@ pub struct AnimateTables {
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
/// Per state: 1 for a pointed dripstone, 2 for one whose tip points down.
dripstone: Vec<u8>,
}
impl AnimateTables {
pub fn load(root: &Path, registry: &BlockRegistry, biomes: &Biomes) -> crate::Result<Self> {
@@ -263,8 +353,25 @@ impl AnimateTables {
}
}
}
let dripstone = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(0, |s| {
let name = s.name.strip_prefix("minecraft:").unwrap_or(&s.name);
if name != "pointed_dripstone" {
0
} else if s.properties.get("vertical_direction").map(String::as_str)
== Some("down")
{
2
} else {
1
}
})
})
.collect();
Ok(Self {
kinds,
dripstone,
impermeable: by_state("minecraft:impermeable"),
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
@@ -296,6 +403,14 @@ pub struct AnimateContext<'a> {
pub raining_at: &'a dyn Fn([i32; 3]) -> bool,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
pub marker: Option<(&'a str, &'a dyn Fn(BlockStateId) -> Option<ParticleOptions>)>,
/// `BlockState.getOffset` of a state at (x, z), for blocks with a random model offset.
pub model_offset: &'a dyn Fn(BlockStateId, i32, i32) -> Option<[f64; 3]>,
/// `TheEndGatewayBlockEntity.getParticleAmount` of the gateway state at a position.
pub gateway_particles: &'a dyn Fn(BlockStateId, [i32; 3]) -> usize,
/// `Level.isThundering`.
pub thundering: bool,
/// The dimension has `water_evaporates` and drips lava from dripstone (the Nether).
pub ultrawarm: bool,
}
/// `ClientLevel.getMarkerParticleTarget`: only the selected main-hand item counts.
@@ -344,6 +459,9 @@ impl AnimateContext<'_> {
],
))
}
fn offset_of(&self, state: BlockStateId, p: [i32; 3]) -> [f64; 3] {
(self.model_offset)(state, p[0], p[2]).unwrap_or([0.0; 3])
}
fn foliage(&self, p: [i32; 3]) -> u32 {
let biome = self
.world
@@ -504,6 +622,53 @@ fn try_spawn_drip(
}
}
}
/// `BeehiveBlock.trySpawnDripParticles`.
fn beehive_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
return;
}
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
return;
};
if top < 1.0
|| tables
.impermeable
.get(state.0 as usize)
.copied()
.unwrap_or(false)
{
return;
}
let y = if bottom > 0.0 {
f64::from(pos[1]) + bottom - 0.05
} else {
let below = [pos[0], pos[1] - 1, pos[2]];
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
if (below_top < 1.0 || !ctx.sturdy(below))
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
{
f64::from(pos[1]) - 0.05
} else {
return;
}
};
let r = engine.random();
let (a, b) = (r.next_double(), r.next_double());
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
let p = [
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
y,
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -685,7 +850,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint) => {
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
@@ -705,7 +870,7 @@ fn block_animate_tick(
let r = engine.random();
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
let options = if ty == T::TintedLeaves {
let c = ctx.foliage(pos);
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
} else {
@@ -941,6 +1106,99 @@ fn block_animate_tick(
let p = [fx + r.next_double(), fy + 0.8, fz + r.next_double()];
add(engine, T::Smoke, p, [0.0; 3]);
}
Animate::Lever { facing, connected } => {
// LeverBlock.animateTick: a powered lever puffs red dust a quarter of the time.
if engine.random().next_float() < 0.25 {
// LeverBlock.makeParticle with scale 0.5.
let at = |base: f64, i: usize| {
base + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(fx, 0), at(fy, 1), at(fz, 2)];
engine.add(&ParticleOptions::dust([1.0, 0.0, 0.0], 0.5), p, [0.0; 3]);
}
}
Animate::Repeater { facing, delay } => {
// RepeaterBlock.animateTick: dust at one of the torches.
let r = engine.random();
let x = fx + 0.5 + (r.next_double() - 0.5) * 0.2;
let y = fy + 0.4 + (r.next_double() - 0.5) * 0.2;
let z = fz + 0.5 + (r.next_double() - 0.5) * 0.2;
let mut offset = -5.0f32;
if r.next_boolean() {
offset = (delay * 2 - 1) as f32;
}
offset /= 16.0;
let xo = f64::from(offset * facing[0] as f32);
let zo = f64::from(offset * facing[2] as f32);
redstone(engine, [x + xo, y, z + zo]);
}
Animate::PointedDripstone => dripstone_drip(tables, engine, ctx, state, pos),
Animate::WitherRose => {
// WitherRoseBlock.animateTick: smoke puffs at the centre of the shape.
let center = ctx.shape_y(pos).map_or([0.5; 2], |s| {
[(s.2[0] + s.2[1]) / 2.0, (s.2[2] + s.2[3]) / 2.0]
});
let offset = ctx.offset_of(state, pos);
let x = fx + center[0] + offset[0];
let z = fz + center[1] + offset[2];
for _ in 0..3 {
let r = engine.random();
if r.next_boolean() {
let p = [
x + r.next_double() / 5.0,
fy + (0.5 - r.next_double()),
z + r.next_double() / 5.0,
];
add(engine, T::Smoke, p, [0.0; 3]);
}
}
}
Animate::EndGateway => {
// EndGatewayBlock.animateTick: portal particles, one for each visible face.
for _ in 0..(ctx.gateway_particles)(state, pos) {
let r = engine.random();
let mut p = [
fx + r.next_double(),
fy + r.next_double(),
fz + r.next_double(),
];
let mut v = [
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
(r.next_double() - 0.5) * 0.5,
];
let flip = next_int(r, 2) * 2 - 1;
if r.next_boolean() {
p[2] = fz + 0.5 + 0.25 * f64::from(flip);
v[2] = f64::from(r.next_float() * 2.0 * flip as f32);
} else {
p[0] = fx + 0.5 + 0.25 * f64::from(flip);
v[0] = f64::from(r.next_float() * 2.0 * flip as f32);
}
add(engine, T::Portal, p, v);
}
}
Animate::LightningRod(axis) => {
// LightningRodBlock.animateTick: in a thunderstorm, sparks along the
// topmost rod, more often as the game time within 200 ticks grows.
if ctx.thundering
&& i64::from(next_int(engine.level_random(), 200)) <= ctx.world.game_time % 200
&& pos[1]
== ctx
.world
.world_surface(pos[0], pos[2], |id| ctx.facts.is_air(id))
- 1
{
let mut sparks = Vec::new();
along_axis(engine.level_random(), axis, pos, (1, 2), |p, v| {
sparks.push((p, v));
});
let options = ParticleOptions::simple(T::ElectricSpark);
for (p, v) in sparks {
engine.add(&options, p, v);
}
}
}
Animate::EnderChest => {
for _ in 0..3 {
let r = engine.random();
@@ -977,6 +1235,13 @@ fn block_animate_tick(
}
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
let attempts = next_int(engine.random(), 1) + 1;
for _ in 0..attempts {
beehive_drip(tables, engine, ctx, state, pos);
}
}
Animate::RedstoneOre => {
for d in [
[0, -1, 0],
@@ -1189,6 +1454,68 @@ fn block_animate_tick(
}
}
}
/// `PointedDripstoneBlock.animateTick`: a free-hanging stalactite tip drips the
/// fluid above the root of its stalactite.
fn dripstone_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
let roll = engine.random().next_float();
if roll > 0.12 {
return;
}
// getFluidAboveStalactite: findRootBlock climbs at most ten blocks of
// stalactite and stops at the first block that is not pointed dripstone.
let mut root = None;
for step in 1..11 {
let p = [pos[0], pos[1] + step, pos[2]];
match tables
.dripstone
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(0)
{
0 => {
root = Some(p);
break;
}
2 => {}
_ => return,
}
}
let Some(root) = root else {
return;
};
let above = ctx.facts.get(ctx.state([root[0], root[1] + 1, root[2]]));
// Mud above the root counts as a water source unless water evaporates here.
let (fluid, source) = if &*above.name == "minecraft:mud" && !ctx.ultrawarm {
(Fluid::Water, true)
} else {
(above.fluid, above.fluid_level >= 8 && !above.fluid_falling)
};
// canFillCauldron: only a water or lava source passes below a 2% roll.
if roll >= 0.02 && !(fluid != Fluid::None && source) {
return;
}
// getDripParticle: no fluid drips what the dimension's default says.
let lava = fluid == Fluid::Lava || (fluid == Fluid::None && ctx.ultrawarm);
let ty = if lava {
T::DrippingDripstoneLava
} else {
T::DrippingDripstoneWater
};
let offset = ctx.offset_of(state, pos);
// STALACTITE_DRIP_START_PIXEL is the tip's lowest point, 5/16.
let p = [
f64::from(pos[0]) + 0.5 + offset[0],
f64::from(pos[1]) + 0.3125 - 0.0625,
f64::from(pos[2]) + 0.5 + offset[2],
];
add(engine, ty, p, [0.0; 3]);
}
/// `AbstractCandleBlock.addParticlesAndSound`.
fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
let r = engine.random();
@@ -1209,21 +1536,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
}
add(engine, T::SmallFlame, p, [0.0; 3]);
}
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
/// which of a campfire's four slots hold an item.
pub fn campfire_particles<'a>(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
) {
for &(pos, state) in blocks {
if !tables.is_lit_campfire(state) {
continue;
}
let signal = ctx
let properties = ctx
.registry
.state(BlockStateId(state))
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
.map(|s| &s.properties);
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
let signal = prop("signal_fire") == Some("true");
// Direction.get2DDataValue: south, west, north, east.
let rotation = match prop("facing") {
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let r = engine.random();
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
@@ -1231,6 +1569,28 @@ pub fn campfire_particles<'a>(
make_campfire_smoke(engine, pos, signal, false);
}
}
// The smoke over each cooking item, at the slot's corner of the fire.
let occupied = cooking(pos);
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
if engine.random().next_float() >= 0.2 {
continue;
}
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
let (step, clockwise) = match (slot + rotation) % 4 {
0 => ([0.0, 1.0], [-1.0, 0.0]),
1 => ([-1.0, 0.0], [0.0, -1.0]),
2 => ([0.0, -1.0], [1.0, 0.0]),
_ => ([1.0, 0.0], [0.0, 1.0]),
};
let at = [
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
];
for _ in 0..4 {
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
+50 -4
View File
@@ -9,7 +9,7 @@ use crate::{
collisions::SceneCollisions,
mobs::Who,
motion::{FluidResources, LAVA, WATER},
projectiles::{Hit, Projectile},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
@@ -132,6 +132,10 @@ pub enum ArrowSound {
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
TridentHitGround,
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
TridentHit,
}
/// What a burning arrow does to the block it hits (`onProjectileHit`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -155,6 +159,8 @@ pub struct ArrowState {
pub pickup: Pickup,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
pub fired_from_weapon: Option<crate::items::ItemStack>,
pub no_physics: bool,
/// `AbstractArrow.getPierceLevel`.
pub pierce: u8,
@@ -169,6 +175,9 @@ pub struct ArrowState {
pub was_eye_in_water: bool,
pub first_tick: bool,
pub has_been_shot: bool,
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
/// for over 4 ticks) and hits no more.
pub dealt_damage: bool,
pub removed: bool,
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
@@ -201,6 +210,7 @@ impl ArrowState {
last_state: None,
pickup: Pickup::Disallowed,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
pierce: 0,
pierced: Vec::new(),
@@ -211,6 +221,7 @@ impl ArrowState {
was_eye_in_water: false,
first_tick: true,
has_been_shot: false,
dealt_damage: false,
removed: false,
random,
events: Vec::new(),
@@ -451,12 +462,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
let Some(mut st) = p.arrow.take() else {
return false;
};
let trident = matches!(p.kind, ProjectileKind::Trident);
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
EntityHit::Hurt => {
ticker.hit_sound();
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
@@ -501,7 +514,16 @@ impl Ticker<'_, '_, '_, '_> {
p.z,
))
}
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
/// inertia, sounds and hit).
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
}
let physics = !self.st.no_physics;
let movement = self.p.velocity;
let block_pos = self.block_position();
@@ -549,8 +571,9 @@ impl Ticker<'_, '_, '_, '_> {
self.st.in_ground_time = 0;
let original = self.position();
if self.st.was_touching_water {
// getWaterInertia, then the bubbles behind it.
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
let v = self.p.velocity;
for _ in 0..4 {
self.st.events.push(ArrowEvent::Particle {
@@ -687,6 +710,8 @@ impl Ticker<'_, '_, '_, '_> {
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
// ThrownTrident.findHitEntity: none once it has dealt damage.
let targets = if self.st.dealt_damage { &[] } else { targets };
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
@@ -757,12 +782,33 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else {
ArrowSound::HitGround
};
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
sound,
volume: 1.0,
pitch,
});
}
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
/// trident keeps flying (it has dealt its damage) and falls.
fn trident_bounce(&mut self) {
self.st.dealt_damage = true;
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::TridentHit,
volume: 1.0,
pitch: 1.0,
});
// ProjectileDeflection.REVERSE.
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
self.p.yaw += rotation;
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
@@ -5,7 +5,11 @@
//! (`entity-models.json`); transforms, animation and textures follow each
//! vanilla renderer. Faces are lit like the `entity` shader: the lightmap at
//! the block plus `minecraft_mix_light` with the level's two diffuse lights.
use crate::{hud::Rect, mesher::Vertex, world::World};
use crate::{
hud::Rect,
mesher::{CULL_BACK, Vertex},
world::World,
};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_core::geometry::{
Direction,
@@ -932,6 +936,30 @@ impl BlockEntityRenderer {
let lights = diffuse_lights(scene.nether);
self.render_one(scene, pos, kind, base, packed, lights, false, false, out);
}
/// `TheEndGatewayBlockEntity.shouldRenderFace` for each face of the gateway
/// at `pos`: the faces its portal is drawn on.
pub fn gateway_faces(
&self,
world: &World,
state: BlockStateId,
pos: [i32; 3],
) -> Vec<Direction> {
Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(state, neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect()
}
/// Whether this block state has a block-entity renderer here.
pub fn renders(&self, state: u32) -> bool {
!matches!(self.kind(state), Kind::None)
@@ -1385,6 +1413,7 @@ impl BlockEntityRenderer {
m *= around(Mat4::from_rotation_y(turn * decay), pivot);
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
@@ -1402,6 +1431,11 @@ impl BlockEntityRenderer {
draw_part(part, side, m, &none, paint, out);
}
}
// `DecoratedPotRenderer` submits every part with `entitySolid`, which culls
// back faces: the flat `top` and `bottom` planes show one side each.
for vertex in &mut out[first..] {
vertex.normal = CULL_BACK;
}
}
Kind::EndPortal => {
// TheEndPortalRenderer.TRANSFORMATION: y from 6/16 to 12/16; only the
@@ -1432,22 +1466,8 @@ impl BlockEntityRenderer {
out,
);
}
// AbstractEndPortalRenderer.extractRenderState: TheEndGatewayBlockEntity.shouldRenderFace.
let faces: Vec<Direction> = Direction::ALL
.into_iter()
.filter(|&d| {
let [dx, dy, dz] = d.normal();
let neighbor = scene.world.block(pos[0] + dx, pos[1] + dy, pos[2] + dz);
self.visibility.as_ref().is_none_or(|v| {
v.lock()
.map(|mut v| {
v.should_render_face(BlockStateId(*state), neighbor, d, true)
.unwrap_or(true)
})
.unwrap_or(true)
})
})
.collect();
// AbstractEndPortalRenderer.extractRenderState.
let faces = self.gateway_faces(scene.world, BlockStateId(*state), pos);
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
+1 -1
View File
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
14200723, 8365974,
];
/// The 12 edges of the unit cube (outline data predating the export).
fn cube_edges() -> Vec<[f64; 6]> {
pub fn cube_edges() -> Vec<[f64; 6]> {
let mut edges = Vec::new();
for a in 0..3 {
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
+48 -8
View File
@@ -1,6 +1,7 @@
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
//! overrides on it.
use crate::item_data::BundleSelected;
use crate::items::{ItemStack, Items};
use horizon_world::nbt::Tag;
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
pub struct Contents {
items: Vec<ItemStack>,
weight: Weight,
/// `selectedItem`: an index into `items`, -1 for none.
selected: i32,
}
impl Contents {
@@ -104,9 +107,11 @@ impl Contents {
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
let inside = contents_of(items, stack)?.to_vec();
let weight = contents_weight(items, &inside);
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
Some(Self {
items: inside,
weight,
selected,
})
}
@@ -154,18 +159,26 @@ impl Contents {
amount
}
/// `removeOne`: the first stack (Horizon keeps no selected item).
/// `removeOne`: the selected stack, else the first; nothing is selected after.
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
if self.items.is_empty() {
return None;
}
let stack = self.items.remove(0);
let at =
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
let stack = self.items.remove(at);
self.weight = self
.weight
.sub(item_weight(items, &stack).times(stack.count));
self.selected = -1;
Some(stack)
}
/// `getSelectedItemIndex`.
pub fn selected(&self) -> i32 {
self.selected
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
@@ -200,11 +213,28 @@ impl Contents {
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
let data = bundle.data_mut();
data.bundle_contents = self.items;
data.bundle_selected = BundleSelected(self.selected);
bundle.tidy();
}
}
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
/// selected one already or outside the contents.
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
let Some(data) = bundle.extra.as_deref_mut() else {
return;
};
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
let selected = if data.bundle_selected.0 != index && inside {
index
} else {
-1
};
data.bundle_selected = BundleSelected(selected);
}
#[cfg(test)]
mod tests {
use super::Weight;
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
/// Each slot's top-left and the stack in it, in drawing order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The position in `slots` of the selected item, which is drawn highlighted.
pub selected: Option<usize>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
selected: None,
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut selected = None;
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
} else if slot < shown.len() {
// `extractSlot`: the cell of slot number n holds item `size - n`.
let index = shown.len() - 1 - slot;
if usize::try_from(contents.selected()) == Ok(index) {
selected = Some(slots.len());
}
slots.push((at, shown[index].clone()));
slot += 1;
}
}
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
selected,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
@@ -349,8 +389,8 @@ mod tooltip_tests {
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
// the bottom-right cell, the first in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
+23 -8
View File
@@ -40,6 +40,8 @@ pub struct Interaction {
/// The cauldron's new state.
pub block: Option<BlockState>,
pub sound: Option<&'static str>,
/// The custom statistic the player earns (`use_cauldron`, ...).
pub stat: Option<&'static str>,
}
fn state(name: &str, level: Option<u8>) -> BlockState {
@@ -57,6 +59,7 @@ fn pass() -> Interaction {
outcome: Outcome::Pass,
block: None,
sound: None,
stat: None,
}
}
@@ -117,11 +120,12 @@ fn lowered(cauldron: &BlockState) -> BlockState {
}
}
fn changed(block: BlockState, sound: Option<&'static str>) -> Interaction {
fn changed(block: BlockState, sound: Option<&'static str>, stat: &'static str) -> Interaction {
Interaction {
outcome: Outcome::Success,
block: Some(block),
sound,
stat: Some(stat),
}
}
@@ -179,18 +183,22 @@ pub fn interact(
stack.data_mut().dyed_color = None;
stack.tidy();
}
return changed(lowered(cauldron), None);
return changed(lowered(cauldron), None, "clean_armor");
}
let fill_bucket = |player: &mut dyn Player, new: &str, can_fill: bool, sound| {
if !can_fill {
return pass();
}
filled(player, ItemStack::new(new, 1), true);
changed(state("minecraft:cauldron", None), Some(sound))
changed(
state("minecraft:cauldron", None),
Some(sound),
"use_cauldron",
)
};
let empty_bucket = |player: &mut dyn Player, block: BlockState, sound| {
filled(player, ItemStack::new("minecraft:bucket", 1), true);
changed(block, Some(sound))
changed(block, Some(sound), "fill_cauldron")
};
match item {
"minecraft:lava_bucket" => {
@@ -199,6 +207,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -221,6 +230,7 @@ pub fn interact(
outcome: Outcome::Consume,
block: None,
sound: None,
stat: None,
}
} else {
empty_bucket(
@@ -241,6 +251,7 @@ pub fn interact(
changed(
state("minecraft:water_cauldron", Some(1)),
Some("minecraft:item.bottle.empty"),
"use_cauldron",
)
}
("water", "minecraft:bucket") => fill_bucket(
@@ -255,7 +266,11 @@ pub fn interact(
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"),
true,
);
changed(lowered(cauldron), Some("minecraft:item.bottle.fill"))
changed(
lowered(cauldron),
Some("minecraft:item.bottle.fill"),
"use_cauldron",
)
}
("water", "minecraft:potion") => {
if level == 3 || !water_potion {
@@ -266,7 +281,7 @@ pub fn interact(
// BlockState.cycle on a 1..=3 property
next.properties
.insert("level".into(), (level % 3 + 1).to_string());
changed(next, Some("minecraft:item.bottle.empty"))
changed(next, Some("minecraft:item.bottle.empty"), "use_cauldron")
}
("water", name)
if name
@@ -283,7 +298,7 @@ pub fn interact(
cleaned.data_mut().banner_patterns.pop();
cleaned.tidy();
filled(player, cleaned, false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_banner")
}
("water", name)
if name
@@ -292,7 +307,7 @@ pub fn interact(
.is_some_and(|c| COLORS.contains(&c)) =>
{
filled(player, held.transmute("minecraft:shulker_box", 1), false);
changed(lowered(cauldron), None)
changed(lowered(cauldron), None, "clean_shulker_box")
}
("lava", "minecraft:bucket") => fill_bucket(
player,
+12
View File
@@ -728,6 +728,18 @@ impl Furnace {
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
// AbstractFurnaceBlockEntity.burn: drying a wet sponge fills
// the bucket in the fuel slot.
if self.items[0]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:wet_sponge")
&& self.items[1]
.as_ref()
.is_some_and(|f| &*f.item == "minecraft:bucket")
{
self.items[1] =
Some(ItemStack::new("minecraft:water_bucket", 1));
}
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
+13 -9
View File
@@ -129,15 +129,16 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
_ => None,
}
}
/// `InstantenousMobEffect.applyInstantenousEffect` (potions apply these directly).
/// `HealOrHarmMobEffect.applyInstantaneousEffect` (potions apply these directly;
/// the amount rounds to the nearest whole point).
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30))) as i32 as f32,
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30))) as i32 as f32,
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -213,8 +214,9 @@ impl PlayerEffects {
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
/// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
/// (on its last tick). Returns the actions to apply, in order, and
/// whether Raid Omen was newly added.
/// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
/// answer: the effect ends once the absorption is used up. Returns the
/// actions to apply, in order, and whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -224,6 +226,7 @@ impl PlayerEffects {
pub fn tick_with(
&mut self,
omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
absorption_empty: bool,
) -> (Vec<EffectAction>, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
let mut actions = Vec::new();
@@ -246,6 +249,7 @@ impl PlayerEffects {
let answer = match e.id {
"bad_omen" => omen(e),
"raid_omen" if t == 1 => omen(e),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
match answer {
@@ -465,13 +469,13 @@ mod tests {
};
// The first tick adds Raid Omen and stops there (the iterator's
// ConcurrentModificationException): Bad Omen and Speed stay as they were.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(added);
assert_eq!(fx.get("bad_omen").unwrap().duration, 100);
assert_eq!(fx.get("speed").unwrap().duration, 50);
assert_eq!(fx.get("raid_omen").unwrap().duration, 600);
// The second refreshes Raid Omen and drops Bad Omen.
let (_, added) = fx.tick_with(&mut convert);
let (_, added) = fx.tick_with(&mut convert, false);
assert!(!added);
assert!(fx.get("bad_omen").is_none());
assert_eq!(fx.get("speed").unwrap().duration, 49);
@@ -486,9 +490,9 @@ mod tests {
seen.push(e.duration);
OmenTick::Remove
};
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_some());
fx.tick_with(&mut watch);
fx.tick_with(&mut watch, false);
assert!(fx.get("raid_omen").is_none());
assert_eq!(seen, vec![1]);
}
+26
View File
@@ -545,6 +545,32 @@ impl Enchantments {
}
out
}
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
/// (attribute, id, amount, operation, slot group), one per slot group the
/// enchantment lists.
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
let mut rows = Vec::new();
for (id, level) in stack.enchantments() {
let Some(e) = self.get(id) else { continue };
for a in e.effects["minecraft:attributes"]
.as_array()
.into_iter()
.flatten()
{
for slot in &e.slots {
rows.push((
a["attribute"].as_str().unwrap_or("").to_owned(),
a["id"].as_str().unwrap_or("").to_owned(),
f64::from(level_value(&a["amount"], *level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
slot.clone(),
));
}
}
}
rows
}
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
stack.map_or(0.0, |s| {
+7 -2
View File
@@ -44,6 +44,8 @@ struct Out {
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
// 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type.
@location(11) @interpolate(flat) cull: u32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -92,7 +94,10 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
// minecraft_mix_light: the vertex colour times the cardinal light only.
var c = color;
var back = c;
if normal.w != 0.0 {
// Normal w: 0 keeps the baked colour, +-127 shades by the normal, and a small positive 1
// keeps the baked colour but culls back faces (entitySolid models such as the decorated pot).
out.cull = select(0u, 1u, normal.w > 0.0 && normal.w < 0.5);
if abs(normal.w) >= 0.5 {
c = vec4(c.rgb * entity_light(normal.xyz, globals.time.y != 0.0), c.a);
back = select(c, vec4(back.rgb * entity_light(-normal.xyz, globals.time.y != 0.0), back.a), normal.w < 0.0);
}
@@ -198,7 +203,7 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
if color.a < 0.1 { discard; }
if color.a < 0.1 || (in.cull != 0u && !front) { discard; }
// entity.fsh calculateFinalColor: the overlay (hurt red, TNT white) over the
// lit texture, then the lightmap, then the glint and fog.
color = vec4(mix(in.overlay_color.rgb, color.rgb, in.overlay_color.a), color.a);
+10
View File
@@ -487,6 +487,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.insert("anger".into(), Tag::compound(Vec::<(&str, Tag)>::new()));
}
}
// Armadillo.addAdditionalSaveData: the time left to the next scute.
"minecraft:armadillo" => {
map.insert("scute_time".into(), Tag::Int(m.extra.egg_time));
}
"minecraft:ravager" => {
map.insert("AttackTick".into(), Tag::Int(m.extra.counter));
map.insert("StunTick".into(), Tag::Int(m.extra.counter2));
@@ -1065,6 +1069,12 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
st.vibration = crate::sculk::Data::from_nbt(listener);
}
}
// Armadillo.readAdditionalSaveData: keeps the constructor's time when absent.
if m.species() == crate::mobs::Species::Armadillo
&& let Some(t) = number(tag.get("scute_time"))
{
m.extra.egg_time = t as i32;
}
if m.species() == crate::mobs::Species::Ravager {
m.extra.counter = number(tag.get("AttackTick")).unwrap_or(0) as i32;
m.extra.counter2 = number(tag.get("StunTick")).unwrap_or(0) as i32;
File diff suppressed because it is too large. Load diff
@@ -9,6 +9,7 @@ use horizon_client::{
text::{self, Style, Text},
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -82,6 +83,8 @@ pub(super) struct AdvState {
pub(super) quiet: bool,
/// The inventory last seen by `inventory_changed`.
pub(super) last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
pub(super) levitation: Option<([f64; 3], u64)>,
/// While the game acts for a network player (`as_guest`): the
/// advancements whose progress changed (`progressChanged`), the recipes
/// unlocked and the announcements, for their client and everyone's.
@@ -180,6 +183,14 @@ impl adv::World for View<'_, '_> {
"block" => items.block_tags.get(tag).is_some_and(|t| t.contains(id)),
"entity_type" => self.0.res.mob_data().entity_has(tag, id),
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"damage_type" => self
.0
.res
.stations()
.enchantments
.damage_tags
.get(tag)
.is_some_and(|t| t.contains(id)),
_ => false,
}
}
@@ -647,6 +658,28 @@ impl Game<'_> {
};
adv::conditions(&c["player"], &ctx, w)
});
// ServerPlayer.onEffectAdded and onEffectsRemoved keep the start of a
// levitation; tick fires LEVITATION while there is one.
let levitating = self
.potions
.effects
.list
.iter()
.any(|e| e.id == "levitation");
match (levitating, self.adv.levitation) {
(true, None) => {
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.adv.levitation = at.map(|at| (at, self.ticks));
}
(false, Some(_)) => self.adv.levitation = None,
_ => {}
}
if let Some((start, since)) = self.adv.levitation {
self.trigger_levitation(start, (self.ticks - since) as i64);
}
if self.ticks.is_multiple_of(20) {
let me = self.player_entity();
self.trigger("minecraft:location", |c, w| {
@@ -681,6 +714,136 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && adv::conditions(&c[&key], &ectx, w)
});
}
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
let me = self.player_entity();
self.trigger("minecraft:levitation", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
&& adv::range_i(&c["duration"], duration)
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
/// what the player looks at (`PlayerPredicate.lookingAt`).
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let mut me = self.player_entity();
me.looking_at = self.looking_at_entity().map(Box::new);
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
});
}
/// The mob a 100 block ray from the eyes meets first, if no block is in
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
fn looking_at_entity(&self) -> Option<EntityView> {
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let (m, _) = self
.mobs
.iter()
.filter(|m| m.alive())
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
.min_by(|a, b| a.1.total_cmp(&b.1))?;
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
let from = Vec3::new(eye[0], eye[1], eye[2]);
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
let world = self.mob_world_base(&[]);
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
clear.then(|| self.mob_entity(m))
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
pub(super) fn trigger_killed_by_arrow(
&mut self,
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
adv::killed_by_arrow(c, &me, &victims, weapon.as_ref(), w)
});
}
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
pub(super) fn trigger_player_hurt_entity(
&mut self,
victim: &EntityView,
damage: &adv::DamageView,
) {
let me = self.player_entity();
let (victim, damage) = (victim.clone(), damage.clone());
self.trigger("minecraft:player_hurt_entity", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&victim),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
&& adv::conditions(&c["entity"], &vctx, w)
});
}
/// The player's hit on `victim` as `LivingEntity.hurtServer` reports it:
/// `kind` damage of `amount` from `direct` (the player's own hand when
/// `None`), nothing blocked.
pub(super) fn player_hit(
&mut self,
victim: &EntityView,
kind: &str,
direct: Option<EntityView>,
amount: f32,
) {
let me = self.player_entity();
self.trigger_player_hurt_entity(
victim,
&adv::DamageView {
kind: kind.to_owned(),
is_direct: direct.is_none(),
direct: direct.or_else(|| Some(me.clone())),
source: Some(me),
original: amount,
actual: amount,
blocked: false,
},
);
}
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
let me = self.player_entity();
let damage = damage.clone();
self.trigger("minecraft:entity_hurt_player", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::damage(&c["damage"], &damage, w, me.position)
});
}
/// A player condition plus one item (`item` key).
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
let me = self.player_entity();
@@ -708,6 +871,101 @@ impl Game<'_> {
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w) && adv::range_i(&c["level"], i64::from(levels))
});
}
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
let me = self.player_entity();
let potion = stack.potion().map(str::to_owned);
self.trigger("minecraft:brewed_potion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::potions(&c["potion"], potion.as_deref(), w)
});
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["item"], &it, w)
&& adv::range_i(&c["levels"], i64::from(levels))
});
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let vctx = adv::Entities {
this: Some(&villager),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["villager"], &vctx, w)
&& adv::item(&c["item"], &it, w)
});
}
/// `FishingRodHookedTrigger`: the rod, what the hook is in (or the hook)
/// and the items it caught.
pub(super) fn trigger_fishing_rod_hooked(
&mut self,
rod: &ItemStack,
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<ItemView> = items.iter().map(item_view).collect();
self.trigger("minecraft:fishing_rod_hooked", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&hooked),
..pctx
};
adv::conditions(&c["player"], &pctx, w)
&& adv::item(&c["rod"], &rod, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& (c["item"].is_null() || items.iter().any(|i| adv::item(&c["item"], i, w)))
});
}
/// `EnterBlockTrigger`: the block, its state properties and the player.
pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
+3 -2
View File
@@ -131,7 +131,8 @@ impl Game<'_> {
let m = &mut self.mobs[i];
m.extra.ints[1] -= 1;
m.extra.ints[3] = 60;
self.spawn_item(ItemStack::new(&item, 1), from, v, 40);
// ItemEntity.setDefaultPickUpDelay.
self.spawn_item(ItemStack::new(&item, 1), from, v, 10);
if self.world.game_time % 7 == 0 && self.world.random.next_double() < 0.9 {
const PITCHES: [f32; 16] = [
0.5625, 0.625, 0.75, 0.9375, 1.0, 1.0, 1.125, 1.25, 1.5, 1.875, 2.0, 2.25, 2.5,
@@ -160,7 +161,7 @@ impl Game<'_> {
}
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.particles_around_mob(id, "heart", horizon_client::particles::Burst::ALLAY);
self.mob_event_particles(id, 18);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.consume_held();
+24
View File
@@ -164,11 +164,35 @@ impl Game<'_> {
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.activate");
let levels = b.levels;
self.for_each_player(|g| g.beacon_constructed([x, y, z], levels));
} else if was && !is {
self.beacon_sound([x, y, z], "minecraft:block.beacon.deactivate");
}
}
}
/// `BeaconBlockEntity.tick` after activating: `CONSTRUCT_BEACON` for a
/// player in the box `AABB(x, y, z, x, y - 4, z).inflate(10, 5, 10)`.
fn beacon_constructed(&mut self, p: [i32; 3], levels: i32) {
let Some(bb) = self
.player
.as_ref()
.map(|pl| pl.dimensions.bounds(pl.position))
else {
return;
};
let (lo, hi) = (bb.min_position(), bb.max_position());
let [x, y, z] = p.map(f64::from);
if lo.x < x + 10.0
&& hi.x > x - 10.0
&& lo.y < y + 5.0
&& hi.y > y - 9.0
&& lo.z < z + 10.0
&& hi.z > z - 10.0
{
self.trigger_construct_beacon(levels);
}
}
pub(super) fn beacon_sound(&mut self, p: [i32; 3], sound: &str) {
let at = [
f64::from(p[0]) + 0.5,
+138 -5
View File
@@ -3,8 +3,10 @@
//! player breaks the hive or fire reaches it.
use super::Game;
use horizon_client::{
Result,
beehive::{self, Beehive, HiveHost, Release},
containers::BlockEntity,
items::ItemStack,
mobs::{Mob, Who, bee},
};
use horizon_core::{
@@ -194,19 +196,139 @@ impl Game<'_> {
}
self.anger_nearby_bees(at);
}
/// `BeehiveBlock.useItemOn` with `held` in the acting hand: shears take
/// the honeycombs and a glass bottle a honey bottle from a full hive
/// (honey level 5), then the honey level resets and, unless a campfire
/// smokes the hive, the bees come out angry. True when it took the click.
pub(super) fn use_beehive(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> Result<bool> {
let Some(held) = held else { return Ok(false) };
let shears = &*held.item == "minecraft:shears";
if !shears && &*held.item != "minecraft:glass_bottle" {
return Ok(false);
}
let state = self.world.block(pos[0], pos[1], pos[2]);
if !matches!(
&*self.res.facts().get(state).name,
"minecraft:beehive" | "minecraft:bee_nest"
) {
return Ok(false);
}
let prop = |key: &str| {
self.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get(key).cloned())
};
if prop("honey_level").and_then(|v| v.parse::<i32>().ok()) != Some(5) {
return Ok(false);
}
let facing = beehive::facing_step(prop("facing").as_deref());
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if shears {
// dropHoneycomb (harvest/beehive), the shear sound, hurtAndBreak.
for stack in self.sequence_loot(
"minecraft:harvest/beehive",
"minecraft:harvest/beehive",
Some(held),
) {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:block.beehive.shear", at, 1.0, 1.0);
self.damage_held(1);
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
} else {
// shrink(1), then the honey bottle in the hand, the inventory or
// on the ground.
self.audio.play("minecraft:item.bottle.fill", at, 1.0, 1.0);
let cell = self.held_mut();
if let Some(s) = cell.as_mut() {
s.count -= 1;
if s.count == 0 {
*cell = None;
}
}
let bottle = ItemStack::new("minecraft:honey_bottle", 1);
if self.held().is_none() {
*self.held_mut() = Some(bottle);
} else if let Some(rest) = self.give(bottle)
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0, 0.1, 0.0], 40);
}
self.post_block_game_event("minecraft:fluid_pickup", pos, self.sculk_actor(), None);
}
self.stats.add("used", &held.item, 1);
let smokey = beehive::smokey(&self.mob_world_base(&[]), pos);
let key = (pos[0], pos[1], pos[2]);
if !smokey
&& matches!(self.world.block_entities.get(&key), Some(BlockEntity::Beehive(h)) if !h.bees.is_empty())
{
self.anger_nearby_bees(pos);
}
// resetHoneyLevel.
if let Some(reset) = self.with_props(state, &[("honey_level", "0")]) {
self.level_set(pos, reset)?;
}
if !smokey
&& let Some(BlockEntity::Beehive(mut hive)) = self.world.block_entities.take(&key)
{
// emptyAllLivingFromHive: the bees within 4 blocks of the player
// go for them.
let released = hive.release_all(
pos,
facing,
None,
Release::Emergency,
&mut GameHive { game: self },
);
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if let Some(p) = self.player_view().map(|p| p.position) {
for m in self.mobs.iter_mut().filter(|m| released.contains(&m.id)) {
if m.distance_sqr(p) <= 16.0 {
m.ai.target = Some(Who::Player);
}
}
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, held);
Ok(true)
}
/// `BeehiveBlock.angerNearbyBees`: bees within 8 blocks (6 up and down)
/// without a target go for a player in the same box.
fn anger_nearby_bees(&mut self, at: [i32; 3]) {
/// without a target go for a random player in the same box, the window's
/// own or a guest (`None` is the window's player).
pub(super) fn anger_nearby_bees(&mut self, at: [i32; 3]) {
let min = Vec3::new(
f64::from(at[0]) - 8.0,
f64::from(at[1]) - 6.0,
f64::from(at[2]) - 8.0,
);
let area = Aabb::new(min, Vec3::new(min.x + 17.0, min.y + 13.0, min.z + 17.0));
if !self
let mut players: Vec<Option<u32>> = Vec::new();
if self
.player_view()
.is_some_and(|p| p.bounds.intersects(area))
{
players.push(None);
}
if let Some(host) = self.lan.as_ref() {
players.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.filter(|g| super::lan_mobs::guest_bounds(g).intersects(area))
.map(|g| Some(g.conn)),
);
}
if players.is_empty() {
return;
}
let mut bees: Vec<usize> = (0..self.mobs.len())
@@ -219,9 +341,20 @@ impl Game<'_> {
.collect();
bees.retain(|&i| self.mobs[i].ai.target.is_none());
for i in bees {
// Util.getRandom over the one player.
let _ = self.world.random.next_int_bound(1);
// Util.getRandom over the players.
let pick = self
.world
.random
.next_int_bound(players.len() as i32)
.unwrap_or(0) as usize;
self.mobs[i].ai.target = Some(Who::Player);
// The player a mob sees is its guest binding, else the window's.
if let Some(host) = self.lan.as_mut() {
match players[pick] {
Some(conn) => host.mob_players.insert(self.mobs[i].id, conn),
None => host.mob_players.remove(&self.mobs[i].id),
};
}
}
}
}
@@ -76,23 +76,24 @@ impl Game<'_> {
/// A player opened (`open`) or closed the container at `key`: the opener
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
/// on both halves of a double chest (`CompoundContainer`). True when the
/// count went from 0 to 1 or back to 0: the only moments the container
/// plays its open or close sound.
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) -> bool {
// Spectators never count as openers.
if !self.spectator() {
self.container_openers_changed(key, open);
}
!self.spectator() && self.container_openers_changed(key, open)
}
/// `container_opener_event` for a player who is not a spectator (a guest).
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
pub(super) fn container_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let pos = [key.0, key.1, key.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.lid_openers_changed(key, open);
let edge = self.lid_openers_changed(key, open);
if let Some(other) = self.chest_other_half(key) {
self.lid_openers_changed(other, open);
}
edge
}
_ if self
.res
@@ -102,26 +103,27 @@ impl Game<'_> {
.as_ref()
== "minecraft:barrel" =>
{
self.barrel_openers_changed(key, open);
self.barrel_openers_changed(key, open)
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
let (count, edge) = if open {
shulker.start_open()
} else {
shulker.stop_open()
};
self.block_entity_event(pos, 1, count);
self.lan_block_event(pos, 1, count);
edge
}
_ => {}
_ => false,
}
}
/// `ContainerOpenersCounter.incrementOpeners` / `decrementOpeners` of the
/// chest at `key` (the host and the guests are counted), then
/// `ChestBlockEntity.signalOpenCount`: block event 1 with the count, here
/// and for the guests.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// and for the guests. True when the count crossed zero.
fn lid_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
let pos = [key.0, key.1, key.2];
self.block_entity_event(pos, 1, count);
@@ -139,12 +141,15 @@ impl Game<'_> {
b.update_neighbors_at(level, ticks, p.offset(0, -1, 0), block);
});
}
(before == 0) != (count == 0)
}
/// `BarrelBlockEntity.openersCounter`: the first opener opens the barrel
/// (`open=true`), the last one to leave closes it (`updateBlockState`).
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) {
/// True when the count crossed zero.
fn barrel_openers_changed(&mut self, key: (i32, i32, i32), open: bool) -> bool {
let (before, count) = self.count_opener(key, open);
if (before == 0) != (count == 0) {
let edge = (before == 0) != (count == 0);
if edge {
self.set_bool_prop(
key,
"open",
@@ -152,6 +157,7 @@ impl Game<'_> {
horizon_world::level::flags::UPDATE_ALL,
);
}
edge
}
/// One opener more (`open`) or less at `key`: the count before and after
/// (`ContainerOpenersCounter.incrementOpeners` / `decrementOpeners`).
+103
View File
@@ -0,0 +1,103 @@
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
//! one of the items it shows, which a click that takes one out of it then takes
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
use super::{Game, Screen};
use horizon_client::{
bundle::{Contents, toggle_selected},
mouse::ScrollWheelHandler,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
#[derive(Default)]
pub(super) struct BundleMouse {
wheel: ScrollWheelHandler,
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
hovered: Option<usize>,
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
fn bundle_slot(&mut self, slot: usize) -> bool {
(slot < 36 || slot == 504 || (300..500).contains(&slot))
&& (self.remote.is_none() || self.remote_storage_open())
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
}
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
/// through the items its tooltip shows. True when the bundle took the wheel.
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
let Some(slot) = self.inventory_target().0 else {
return false;
};
if !self.bundle_slot(slot) {
return false;
}
let stack = self.slot_mut(slot).clone();
let items = self.res.items();
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
return false;
};
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
if shown == 0 {
return false;
}
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
if wheel != 0 {
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
if next != selected {
self.select_bundle(slot, next);
}
}
true
}
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
fn select_bundle(&mut self, slot: usize, index: i32) {
if let Some(stack) = self.slot_mut(slot) {
toggle_selected(stack, index);
}
if self.remote.is_some() {
let (_, menu) = self.remote_menu();
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
self.send(S::SelectBundleItem(play::SelectBundleItem {
slot: menu_slot as i32,
index,
}));
}
}
}
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
pub(super) fn unselect_bundle(&mut self, slot: usize) {
if !self.bundle_slot(slot) {
return;
}
let selected = self
.slot_mut(slot)
.as_ref()
.and_then(|s| s.data())
.is_some_and(|d| d.bundle_selected.0 >= 0);
if selected {
self.select_bundle(slot, -1);
}
}
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
/// screen, clears the bundle's selection.
pub(super) fn tick_bundle_hover(&mut self) {
let now = if self.screen == Screen::Inventory {
self.inventory_target().0
} else {
None
};
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
&& Some(left) != now
{
self.unselect_bundle(left);
}
}
}
+5
View File
@@ -789,6 +789,11 @@ impl Game<'_> {
pub(super) fn sync_rules(&mut self) {
self.world.health_rules.natural_regeneration =
self.ui.rules.bool("natural_health_regeneration");
// Player.isInvulnerableTo: the damage-type rules.
self.world.health_rules.drowning_damage = self.ui.rules.bool("drowning_damage");
self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage");
self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage");
self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage");
use horizon_world::food::Difficulty;
self.world.health_rules.difficulty = match self.ui.difficulty {
0 => Difficulty::Peaceful,
@@ -4,8 +4,12 @@
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::motion::{LAVA, WATER};
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
use horizon_client::pose::Pose;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
@@ -33,5 +37,209 @@ impl Game<'_> {
}
}
self.eye_particles();
self.sprint_dust();
self.effect_swirls();
}
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
&& !p.spectator
&& p.pose != Pose::Crouching
&& !p.health.dead_or_dying()
&& !p.fluids.was_touching_water
&& !p.is_in_lava()
&& p.fluid_height(WATER) <= 0.0
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
&& &*m.kind.id == "minecraft:player"
&& self
.res
.facts()
.get(self.world.block(feet[0], feet[1], feet[2]))
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
}
}
for (position, movement, support) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
let name = game.block_name(s);
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|tag| game.block_in_tag(tag, &name))
};
let at = match support {
Some(s) if tall(self, s) => s,
Some(s) => [s[0], below, s[2]],
None => [position.x.floor() as i32, below, position.z.floor() as i32],
};
let state = self.world.block(at[0], at[1], at[2]);
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
// Clamped into the block when the feet are in another column.
if position.x.floor() as i32 != at[0] {
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
}
if position.z.floor() as i32 != at[2] {
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
}
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[x, position.y + 0.1, z],
[movement.x * -4.0, 1.5, movement.z * -4.0],
);
}
}
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
/// with visible effects, from the particles its synched data lists. The
/// window's player has its own effects, a server's mobs and players the
/// list the server sent, and the mobs and guests of a game in the frame
/// theirs.
fn effect_swirls(&mut self) {
if let Some(p) = self.player.as_ref() {
let effects = &self.potions.effects.list;
let list: Vec<_> = effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
swirl(
&mut self.particles,
p.position,
size,
&list,
ambient,
invisible,
);
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
if !m.alive() {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
.iter()
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.filter_map(|e| effect_particle(e.0, false))
.collect();
swirl(
&mut self.particles,
m.position,
size,
&list,
false,
invisible,
);
} else {
let list = &m.extra.effect_particles;
let ambient = m.extra.effect_ambient;
swirl(
&mut self.particles,
m.position,
size,
list,
ambient,
invisible,
);
}
}
}
}
/// `MobEffect.particleFactory`: the omens have particles of their own, any
/// other effect an entity effect in its colour, faint when ambient.
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
Some(match effect.trim_start_matches("minecraft:") {
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
other => {
let color = horizon_client::brewing::effect(other)?.color;
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
// MobEffect.AMBIENT_ALPHA is 38 of 255.
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), alpha],
)
}
})
}
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
/// chance (one in fifteen while invisible, a fifth of either when all the
/// effects are ambient) of one of the particles at a random spot of the box.
fn swirl(
particles: &mut ParticleEngine,
feet: Vec3,
size: (f64, f64),
list: &[ParticleOptions],
ambient: bool,
invisible: bool,
) {
if list.is_empty() {
return;
}
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
let r = particles.random();
let Ok(0) = r.next_int_bound(bound) else {
return;
};
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
let y = feet.y + size.1 * r.next_double();
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
particles.add(&list[pick], [x, y, z], [1.0; 3]);
}
+56
View File
@@ -10,6 +10,9 @@ struct Entry {
fall_distance: f32,
/// The causing entity: its id (for comparisons) and display name.
attacker: Option<(u32, Text)>,
/// The direct entity's display name when no entity caused the damage
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
#[derive(Default)]
pub(super) struct CombatTracker {
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text))>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -31,6 +36,13 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The blast now going off comes from a primed TNT, creeper, wither
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
pub(super) hive_emptying_blast: bool,
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
/// the mobs the blast hurts record it as their attacker.
pub(super) explosion_owner: Option<u32>,
/// Charged creepers whose blast just went off and that have not dropped
/// a head yet (`Creeper.droppedSkulls`): they are gone from the mob list,
/// but the mobs they killed still ask for their loot.
@@ -66,6 +78,14 @@ impl Game<'_> {
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
}
/// `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]) {
@@ -77,6 +97,19 @@ impl Game<'_> {
self.recheck_combat();
for e in events {
let (name, amount) = match e {
// LivingEntity.getDamageAfterMagicAbsorb (Resistance) and
// Player.actuallyHurt (absorption hearts) and
// LivingEntity.hurtServer (the shield, only for an accepted
// hit), in tenths.
MotionEvent::Health(HealthEvent::Statistic { name, amount }) => {
match *name {
"resisted" => self.stats.custom("damage_resisted", *amount),
"absorbed" => self.stats.custom("damage_absorbed", *amount),
"blocked" => self.stats.custom("damage_blocked_by_shield", *amount),
_ => {}
}
continue;
}
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
let Some(d) = self.motion.health.damage.definition(*source) else {
continue;
@@ -94,6 +127,15 @@ impl Game<'_> {
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending.remove(i).1);
let direct = match attacker {
Some(_) => None,
None => self
.combat
.pending_direct
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending_direct.remove(i).1),
};
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
@@ -111,10 +153,16 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
// InfestedMobEffect.onMobHurt.
if self.potions.effects.get("infested").is_some() {
self.player_infested();
}
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -224,6 +272,7 @@ impl Game<'_> {
let entries = std::mem::take(&mut self.combat.entries);
let killing = entries.last().expect("checked above");
let killer = killing.attacker.clone();
let direct = killing.direct.clone();
if def.1 == "fall_variants"
&& let Some(knock) = knock.and_then(|i| entries.get(i))
{
@@ -248,15 +297,22 @@ impl Game<'_> {
.append(Text::literal("]"));
return Text::translate(&format!("{key}.message"), &[me, link]);
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
}
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
if let Some(((_, name), at)) = &self.combat.last_hurt_by
&& self.world.game_time - at <= 100
{
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
}
// No entity at all: the one-argument message, as vanilla sends it (a
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
}
+1 -2
View File
@@ -541,8 +541,7 @@ impl Game<'_> {
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
@@ -91,7 +91,12 @@ impl Game<'_> {
}
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
m.add_effect(namespaced(effect.id), duration, amplifier);
let name = namespaced(effect.id);
m.add_effect(name, duration, amplifier);
m.extra.hidden_effects.retain(|h| *h != name);
if !particles {
m.extra.hidden_effects.push(name);
}
true
}
None => false,
@@ -154,6 +159,10 @@ impl Game<'_> {
m.extra
.effects
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
let left = &m.extra.effects;
m.extra
.hidden_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra.effects.len() != before
}
None => false,
@@ -640,6 +640,72 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
// Leashable.getLeashHolder: a fence knot is not an entity here.
Some("leasher") => match entity {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
.and_then(|(holder, player)| match holder {
// The player who tied it (the host's when unrecorded).
horizon_client::mobs::leash::Holder::Player => {
let uuid = player.or(self.level.player_uuid)?;
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
}
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
_ => Vec::new(),
},
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
if self.lan_acting == Some(conn) =>
{
Some(Target::Player)
}
(horizon_client::mobs::Who::Player, Some(conn)) => {
Some(Target::Guest(conn))
}
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
})
.into_iter()
.collect(),
Target::Item(id) => self
.dropped
.iter()
.find(|d| d.id == id)
.and_then(|d| d.thrower)
.and_then(|words| {
let uuid = words
.iter()
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
self.target_by_uuid(uuid)
})
.into_iter()
.collect(),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
.map(|m| m.extra.counter2 as u32)
.filter(|owner| *owner != 0)
.map(Target::Mob)
.into_iter()
.collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
@@ -14,10 +14,11 @@ impl Game<'_> {
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
// NbtContents.resolve: the path's values in the entity, block or
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
// ScoreContents.resolve: the holder's score in the objective.
let nbt_values = std::cell::RefCell::new(Vec::new());
{
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
if o.contains_key("nbt") {
if o.contains_key("nbt") || o.contains_key("score") {
nbt_values.borrow_mut().push(o.clone());
}
};
@@ -40,7 +41,11 @@ impl Game<'_> {
}
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
for o in nbt_values.into_inner() {
let t = self.resolve_nbt_component(&o);
let t = if o.contains_key("score") {
self.resolve_score_component(&o)?
} else {
self.resolve_nbt_component(&o)
};
resolved.push((o, t));
}
let mut resolve = |raw: &str| -> Text {
@@ -73,6 +78,54 @@ impl Game<'_> {
},
))
}
/// The text of a `score` component (ScoreContents.resolve): the named
/// holder's score, `*` being the player the command runs for.
fn resolve_score_component(
&mut self,
o: &serde_json::Map<String, serde_json::Value>,
) -> R<Text> {
let score = o.get("score");
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
let (name, objective) = (
field("name").unwrap_or(""),
field("objective").unwrap_or(""),
);
// findTargetName: a selector that matches one entity is that entity,
// one that matches none is just the name.
let mut reader = horizon_client::command::Reader::new(name);
let ty = horizon_client::command::args::ArgType::Entity {
single: false,
players: false,
};
let mut holder = name.to_owned();
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
&& !reader.can_read()
{
let found = self.select(&sel);
if found.len() > 1 {
return Err(err("argument.entity.toomany", &[]));
}
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
holder = t;
}
}
if holder == "*" {
holder = self.acting_player_name().to_owned();
}
let board = &self.score.board;
let (Some(objective), Some(score)) =
(board.objective(objective), board.score(&holder, objective))
else {
return Ok(Text::empty());
};
// numberFormatOrDefault(StyledFormat.NO_STYLE).
let format = (score.number_format.clone())
.or_else(|| objective.number_format.clone())
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
serde_json::json!({}),
));
Ok(format.format(score.value))
}
/// The text of an `nbt` component.
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
use horizon_world::nbt::Tag;
@@ -136,28 +189,40 @@ impl Game<'_> {
}
out
}
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
// Only the local player can receive messages and titles.
/// The players the command reaches: the window's own and the LAN guests.
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
let targets = self.entities(ctx, "targets", true)?;
Ok(targets.iter().filter(|t| **t == Target::Player).count())
Ok(targets
.into_iter()
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
.collect())
}
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let players = self.player_targets(ctx)?;
let message = self.component_arg(ctx, "message")?;
if n > 0 {
self.system_message(message);
// Each player's chat (a guest's through the connection).
for &t in &players {
self.for_player(t, |g| g.system_message(message.clone()));
}
Ok(n as i32)
Ok(players.len() as i32)
}
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let name = self.target_name(Target::Player);
let players = self.player_targets(ctx)?;
let n = players.len();
let name = players
.first()
.map_or_else(Text::empty, |t| self.target_name(*t));
let single = n == 1;
let key = if ctx.has("clear") {
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
self.titles_or_packet(
&players,
Titles::clear,
C::ClearTitles(play::Bool1 { value: false }),
);
"cleared"
} else if ctx.has("reset") {
self.titles_or_packet(
&players,
|t| {
t.clear();
t.reset_times();
@@ -172,6 +237,7 @@ impl Game<'_> {
};
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
self.titles_or_packet(
&players,
|t| t.set_times(fade_in, stay, fade_out),
C::SetTitlesAnimation(play::TitlesAnimation {
fade_in,
@@ -186,13 +252,17 @@ impl Game<'_> {
text: component_nbt(&text.component()),
};
if ctx.has("subtitle") {
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
"show.subtitle"
} else if ctx.has("actionbar") {
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
self.titles_or_packet(
&players,
|t| t.set_overlay(text, false),
C::SetActionBarText(sent),
);
"show.actionbar"
} else {
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
"show.title"
}
};
@@ -207,13 +277,27 @@ impl Game<'_> {
self.feedback(message);
Ok(n as i32)
}
/// The player the title is for: the guest acting now is sent the packet
/// (TitleCommand's `connection.send`), the window's own player shows it.
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
if self.lan_acting.is_some() {
self.guest_packets.push(packet);
} else {
local(&mut self.titles);
/// TitleCommand's `connection.send` for each player: a guest is sent the
/// packet, the window's own player shows it.
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
let mut local = Some(local);
for &t in players {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => {
if let Some(local) = local.take() {
local(&mut self.titles);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
}
}
@@ -141,27 +141,37 @@ impl Game<'_> {
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
_ => None,
};
let cause = match ctx.get("cause") {
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
_ => None,
};
let from = match ctx.get("location") {
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
_ => by.and_then(|t| self.target_position(t)),
};
let hurt = match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player => {
if self.creative || self.spectator() {
// `DamageSource(type, cause, by)`: the death message names the
// causing entity, else the direct one. A mob is noted by
// `hurt_player_from`, a player here.
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
let name = self.target_name(t);
self.note_direct_entity(&ty, name);
}
if self.spectator() {
false
} else if let Some(player) = self.player.as_mut() {
match self.motion.health.damage.id(&ty) {
Some(id) => player
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false),
None => false,
}
} else {
false
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
}
Target::Mob(id) => {
let attacker = by.and_then(|t| match t {
let attacker = cause.or(by).and_then(|t| match t {
Target::Player => Some(Who::Player),
Target::Mob(i) => Some(Who::Mob(i)),
_ => None,
@@ -302,9 +312,18 @@ impl Game<'_> {
float("minVolume", 0.0),
);
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
// PlaySoundCommand: one seed for every player.
let seed = self.world.random.next_long();
let here = self.dim.id();
let mut tracker = Tracker::default();
for t in targets {
if t != Target::Player {
if !matches!(t, Target::Player | Target::Guest(_)) {
continue;
}
// Only players in the source's level hear it.
if let Target::Guest(conn) = t
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
{
continue;
}
let Some(p) = self.target_position(t) else {
@@ -328,8 +347,26 @@ impl Game<'_> {
} else {
(pos, volume)
};
self.audio.play_on(source, &sound, Some(at), v, pitch);
tracker.mark(&holder(Text::literal(self.acting_player_name())));
// The connection is sent the sound; the window's own player
// plays it.
let guest = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
Some((conn, host)) => {
let packet = host
.hub
.sound_packet(&sound, at, v, pitch, source as i32, seed);
if t == Target::Player {
self.guest_packets.push(packet);
} else {
host.hub.send_to(conn, &packet);
}
}
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
}
tracker.mark(&holder(self.target_name(t)));
}
let name = Text::literal(sound.clone());
let (message, total) = tracker.message(
@@ -606,8 +643,10 @@ impl Game<'_> {
let reached = reached.len() as i32;
if !local {
} else if count <= 0 {
// No count: one particle with the delta as velocity.
self.particles.create(&options, pos, delta);
// handleParticleEvent, count 0: one particle, the speed times
// the delta as velocity (both sent as floats, multiplied as floats).
self.particles
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
} else {
for _ in 0..count {
let r = self.particles.random();
+11
View File
@@ -57,6 +57,17 @@ impl Game<'_> {
self.audio
.play("minecraft:entity.player.burp", self.feet(), 0.5, pitch);
}
// SuspiciousStewEffects.onConsume, a ConsumableListener: each entry is
// added as a `MobEffectInstance(effect, duration)` (amplifier 0).
if let Some(data) = stack.extra.as_deref() {
for (id, duration) in &data.stew_effects {
if let Some(def) = horizon_client::brewing::effect(id) {
self.add_player_effect(horizon_client::effects::EffectInstance::new(
def.id, *duration, 0,
));
}
}
}
for effect in &consumable.effects {
self.apply_consume_effect(effect);
}
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
@@ -204,6 +204,64 @@ impl Game<'_> {
}
}
// ---- interactions -----------------------------------------------------
/// `WeatheringCopperGolemStatueBlock.useItemOn`: an axe on an unoxidized
/// statue turns it back into a golem (`CopperGolemStatueBlockEntity.removeStatue`),
/// wearing the axe. Weathered statues are scraped by the axe's own
/// transform instead.
pub(super) fn use_golem_statue(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if !held.is_some_and(|s| s.item.ends_with("_axe")) {
return false;
}
let registry = self.pack.assets.registry;
let Some(state) = registry.state(self.world.block(pos[0], pos[1], pos[2])) else {
return false;
};
if state.name != "minecraft:copper_golem_statue" {
return false;
}
// `Direction.toYRot` of the statue's facing.
let yaw = match state.properties.get("facing").map(String::as_str) {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
};
let waterlogged = state
.properties
.get("waterlogged")
.is_some_and(|w| w == "true");
// initCopperGolem: at the block's feet, facing as the statue does.
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let Some(id) = self.spawn_mob("minecraft:copper_golem", at, true) else {
return false;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.yaw = yaw;
m.previous_yaw = yaw;
m.body_yaw = yaw;
m.previous_body_yaw = yaw;
m.head_yaw = yaw;
m.previous_head_yaw = yaw;
}
self.damage_held(1);
self.audio
.play("minecraft:entity.copper_golem.spawn", Some(at), 1.0, 1.0);
// Level.removeBlock(pos, false): air, or the water the statue stood in.
let replacement = registry.default_state(if waterlogged {
"minecraft:water"
} else {
"minecraft:air"
});
if let Some(replacement) = replacement {
let _ = self.level_set(pos, replacement);
}
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
true
}
/// `CopperGolem.mobInteract`; true when handled.
pub(super) fn interact_copper_golem(&mut self, index: usize, item: &str) -> bool {
if self.mobs[index].species() != Species::CopperGolem || !self.mobs[index].alive() {
+4 -3
View File
@@ -218,8 +218,9 @@ impl Game<'_> {
);
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -242,7 +243,7 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if natural && !self.creative && !self.spectator() {
if by_player && natural && !self.creative && !self.spectator() {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
+500 -66
View File
@@ -18,6 +18,7 @@ use horizon_client::{
spawning::{self, BiomeFacts},
},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
@@ -45,10 +46,22 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
horizon_client::particles::ParticleOptions,
[f64; 3],
[f64; 3],
f64,
u32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -186,6 +199,7 @@ impl Game<'_> {
off_hand: None,
head: self.armor[0].as_ref().map(|s| s.item.clone()),
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
invisible: self.potions.effects.get("invisibility").is_some(),
spectator: self.spectator(),
gold_armor: self.wearing_gold(),
hurt_by: self.player_hurt_by(),
@@ -197,6 +211,7 @@ impl Game<'_> {
uuid: self.level.player_uuid.unwrap_or_default(),
allies: std::sync::Arc::new(self.player_allies()),
remote: false,
passenger: self.riding.is_some(),
})
}
/// Biome facts at a position for finalizeSpawn and variant rules.
@@ -418,6 +433,7 @@ impl Game<'_> {
block_tags: &self.res.items().block_tags,
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
mob_drops: self.ui.rules.bool("mob_drops"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
@@ -511,6 +527,7 @@ impl Game<'_> {
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
let ticking = self.entity_ticking_area();
// With guests on, each mob sees its nearest player (game/lan_mobs.rs).
@@ -532,6 +549,8 @@ impl Game<'_> {
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The mobs whose bodies go this tick: (id, feet, width, height).
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -603,7 +622,17 @@ impl Game<'_> {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
corpses.push(m.clone());
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
gone.push((m.id, m.position, width, height));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
@@ -662,6 +691,12 @@ impl Game<'_> {
}
self.mobs.append(&mut mobs);
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
let at = [at.x, at.y, at.z];
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
@@ -671,6 +706,16 @@ impl Game<'_> {
for m in dead {
self.mob_died(&m);
}
for mut m in corpses {
let names: Vec<&'static str> = m
.extra
.effects
.iter()
.map(|e| e.0.trim_start_matches("minecraft:"))
.collect();
let bounds = m.bounds();
self.effects_on_death(&names, bounds, false, Some(m.id), Some(&mut m.random));
}
self.handle_mob_events(events)?;
self.lan_guest_mob_events(guest_events);
self.tick_projectiles()?;
@@ -688,6 +733,22 @@ impl Game<'_> {
self.handle_mob_events(queued)?;
Ok(())
}
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
let options = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Poof,
);
for _ in 0..20 {
let r = self.particles.level_random();
let xa = r.next_gaussian() * 0.02;
let ya = r.next_gaussian() * 0.02;
let za = r.next_gaussian() * 0.02;
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
let y = at[1] + height * r.next_double() - ya * 10.0;
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
self.particles.add(&options, [x, y, z], [xa, ya, za]);
}
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let killer = self.last_hurt_by_player.remove(&m.id);
@@ -727,6 +788,7 @@ impl Game<'_> {
self.villager_murdered(m, by_player);
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
self.golem_drop_equipment(&mut dying);
@@ -857,6 +919,18 @@ impl Game<'_> {
}
}
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
let at = [
m.position.x,
m.position.y + f64::from(m.height()) / 2.0,
m.position.z,
];
let look = m.view_vector();
self.infested_hurt(at, look, Some(mob));
}
}
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
@@ -969,7 +1043,7 @@ impl Game<'_> {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::Explode(at, radius) => {
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let source_mob = self.combat.explosion_source.or_else(|| {
self.mobs
@@ -985,7 +1059,14 @@ impl Game<'_> {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
let result = self.explode(at, radius);
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind.
let result = if matches!(species, Species::Creeper | Species::Wither) {
self.with_hive_emptying_blast(|g| g.explode(at, radius))
} else {
self.explode(at, radius)
};
self.combat.explosion_source = None;
result?
}
@@ -996,6 +1077,7 @@ impl Game<'_> {
} => {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
hurt = hit;
@@ -1004,20 +1086,27 @@ impl Game<'_> {
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
if let MobEvent::Sound(s, _, volume, pitch) = event {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
} else {
// Left for the end of the tick.
t.outbox.push(event);
match event {
MobEvent::Sound(s, _, volume, pitch) => {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
}
MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
t.outbox.push(other);
}
}
}
}
}
if infested {
self.mob_infested(target);
}
if hurt
&& let Some(Who::Mob(attacker)) = source.attacker
&& let Some(m) = self
@@ -1063,6 +1152,7 @@ impl Game<'_> {
}
MobEvent::BecomeStatue { mob } => self.golem_become_statue(mob),
MobEvent::EnterHive { mob, hive } => self.bee_enter_hive(mob, hive),
MobEvent::Infested { mob } => self.mob_infested(mob),
MobEvent::Gift { target, item } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == target)
&& m.saddle_item().is_none()
@@ -1084,6 +1174,11 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
@@ -1221,12 +1316,17 @@ impl Game<'_> {
}
}
MobEvent::AroundSelf {
mob,
event,
kind,
feet,
width,
height,
burst,
} => self.particles_around(kind, feet, [width, height], burst),
} => {
self.lan_entity_event(mob as i32, event, feet);
self.particles_around(kind, feet, [width, height], burst)
}
MobEvent::ParticleAt { kind, at, velocity } => {
if let Some(ty) = horizon_client::particles::ParticleType::from_name(kind) {
let options = horizon_client::particles::ParticleOptions::simple(ty);
@@ -1337,6 +1437,22 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::ShedScute { mob, at, pitch } => {
// The mob already checked `shouldDropLoot` (a baby, `mob_drops`).
let table = "minecraft:gameplay/armadillo_shed";
let drops = self.sequence_loot(table, table, None);
for d in drops {
self.spawn_at_location(Vec3::new(at[0], at[1], at[2]), 0.0, d);
}
self.audio.play(
"minecraft:entity.armadillo.scute_drop",
Some(at),
1.0,
pitch,
);
let ctx = self.who_context(Some(Who::Mob(mob)), None);
self.post_game_event("minecraft:entity_place", at, ctx);
}
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
@@ -1465,6 +1581,8 @@ impl Game<'_> {
q.extra.in_love = 0;
}
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
self.mob_event_particles(parent, 18);
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
if p.species() == Species::Sniffer {
self.lay_sniffer_egg(&p);
@@ -1646,11 +1764,34 @@ impl Game<'_> {
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
self.mob_hits_player(amount, from, kind);
}
/// Damage whose causing mob is known (`cause`, or none): not guessed
/// from the mob nearest to `from`.
pub(super) fn mob_hits_player_caused(
&mut self,
amount: f32,
from: [f64; 3],
kind: &str,
cause: Option<u32>,
) {
self.hurt_player_from(amount, Some(from), kind, Some(cause));
}
pub(super) fn mob_hits_player(
&mut self,
amount: f32,
from: [f64; 3],
damage_type: &str,
) -> bool {
self.hurt_player_from(amount, Some(from), damage_type, None)
}
/// `hurtServer` on the player. `from` is the source's position (none: no
/// direction), `known`: the causing mob when it is given (`/damage`),
/// else the melee attacker is the mob nearest to `from`.
pub(super) fn hurt_player_from(
&mut self,
amount: f32,
from: Option<[f64; 3]>,
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -1663,23 +1804,26 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(Some(from), damage_type, false);
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = (!projectile && damage_type != "minecraft:player_attack")
.then(|| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
})
.flatten();
let attacker = match known {
Some(known) => known,
None => from
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
.and_then(|from| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
}),
};
// The causing entity for the death message.
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
@@ -1707,7 +1851,14 @@ impl Game<'_> {
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
// No source position: a random direction.
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
(from[0] - player.position.x, from[2] - player.position.z)
});
// dealDefaultKnockback's `indicateDamage`, unless blocked.
if o.blocked <= 0.0 {
player.health.indicate_damage(xd, zd, player.yaw);
}
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
@@ -1720,6 +1871,23 @@ impl Game<'_> {
self.thorns(a);
}
}
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
kind: damage_type.to_owned(),
direct: source.clone().filter(|_| !projectile),
is_direct: !projectile,
source,
original: amount,
actual: amount - outcome.blocked,
blocked: outcome.blocked > 0.0,
});
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
}
@@ -2021,6 +2189,7 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
self.flush_lan_explosions();
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
@@ -2045,6 +2214,33 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
if !matches!(shot.kind, ProjectileKind::Trident) {
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
}
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
return 8.0;
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
damage_type: Some("minecraft:trident"),
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(&trident),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
)
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -2073,6 +2269,8 @@ impl Game<'_> {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
};
self.audio
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
@@ -2222,11 +2420,11 @@ impl Game<'_> {
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
if p.arrow.is_some() {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
@@ -2247,17 +2445,15 @@ impl Game<'_> {
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
ProjectileKind::Arrow { .. },
Hit::Entity {
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
},
) = (&p.kind, hit)
} = hit
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
{
// AbstractArrow.onHitEntity on a minecart.
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
let creative = p.owner == Some(Who::Player) && self.creative;
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
@@ -2268,7 +2464,7 @@ impl Game<'_> {
// Projectile.onHit: the landing is heard.
self.projectile_event("minecraft:projectile_land", p.owner, atp);
let source_kind = match p.kind {
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
DamageKind::Fireball
}
@@ -2280,6 +2476,40 @@ impl Game<'_> {
from: Some([p.previous.x, p.previous.y, p.previous.z]),
};
match (&p.kind, hit) {
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
// dealt whether or not the hurt lands, then it bounces off.
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
let mut trident = p.clone();
let victim = match who {
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone()),
Who::Player => Some("minecraft:player".to_owned()),
};
let amount = self.shot_damage(&mut trident, victim.as_deref());
let hurt = match who {
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:trident",
),
Who::Mob(id) => self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source)),
};
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(trident, outcome);
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
@@ -2311,6 +2541,35 @@ impl Game<'_> {
.is_some_and(|m| m.damage(amount, source));
if p.owner == Some(Who::Player) {
self.last_hurt_by_player.insert(id, self.lan_acting);
if hurt && let Some(m) = self.mobs.iter().find(|m| m.id == id) {
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let spectral = p.arrow.as_ref().is_some_and(|a| {
&*a.pickup_item.item == "minecraft:spectral_arrow"
});
let direct = horizon_client::advancements::EntityView {
kind: if spectral {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
}
.into(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.player_hit(&victim, "minecraft:arrow", Some(direct), amount);
}
// AbstractArrow.onHitEntity: KILLED_BY_ARROW for a kill
// (piercing arrows are not modelled).
if hurt
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
&& !m.alive()
{
let victim = self.mob_entity(m);
let weapon =
p.arrow.as_ref().and_then(|a| a.fired_from_weapon.as_ref());
self.trigger_killed_by_arrow(&[victim], weapon);
}
}
hurt
}
@@ -2354,6 +2613,7 @@ impl Game<'_> {
};
m.damage(amount, source);
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
}
(ProjectileKind::Egg, hit) => {
@@ -2379,6 +2639,7 @@ impl Game<'_> {
}
}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:egg");
}
(ProjectileKind::EnderPearl, hit) => {
@@ -2482,7 +2743,10 @@ impl Game<'_> {
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:explosion", owner);
}
self.explode(atp, *power as f32)?;
self.combat.explosion_owner = self.combat.projectile_owner;
let result = self.explode(atp, *power as f32);
self.combat.explosion_owner = None;
result?;
}
(
ProjectileKind::Potion {
@@ -2501,14 +2765,30 @@ impl Game<'_> {
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:indirect_magic", owner);
}
match *effect {
"minecraft:instant_damage" => self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
),
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
@@ -2612,10 +2892,17 @@ impl Game<'_> {
}
Ok(())
}
/// `Snowball`/`ThrownEgg.onHit`: entity event 3 for the guests, before the
/// projectile is discarded (the clients lose it when the tracker next runs).
fn thrown_break_event(&mut self, id: u32, at: [f64; 3]) {
if id != 0 {
self.lan_entity_event(id as i32, 3, at);
}
}
/// `Snowball`/`ThrownEgg.handleEntityEvent(3)`: eight crumbs of the
/// thrown item where it broke, at rest for a snowball, scattered by the
/// client entity's random (the particle engine's here) for an egg.
fn item_burst(&mut self, at: [f64; 3], item: &str) {
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
let egg = item != "minecraft:snowball";
for _ in 0..8 {
@@ -2651,6 +2938,10 @@ impl Game<'_> {
if self.name_mob(index) {
return Ok(true);
}
if self.spawn_egg_offspring(index) {
self.swing_arm();
return Ok(true);
}
{
let item = self.held().map(|h| h.item.to_string()).unwrap_or_default();
if self.interact_leash(index, &item) {
@@ -2712,6 +3003,14 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.interact_armadillo(index, &item) {
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
}
self.swing_arm();
return Ok(true);
}
if self.interact_copper_golem(index, &item) {
self.swing_arm();
return Ok(true);
@@ -2767,6 +3066,16 @@ impl Game<'_> {
consume = true;
sound = Some("entity.cow.milk");
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
});
}
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
give = Some("minecraft:mushroom_stew");
consume = true;
@@ -2887,14 +3196,8 @@ impl Game<'_> {
m.ai.target = None;
m.extra.sitting = true;
}
// TamableAnimal.spawnTamingParticles.
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -2961,13 +3264,8 @@ impl Game<'_> {
m.extra.sitting = true;
}
eat = eating_sound(m, &mut self.world.random);
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
@@ -3015,18 +3313,42 @@ impl Game<'_> {
m.extra.tame = true;
m.extra.owner = self.level.player_uuid.or(Some([0, 0, 0, 1]));
}
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
if tamed {
let view = self.mob_entity(&self.mobs[index]);
self.trigger_entity("minecraft:tame_animal", "entity", &view);
}
}
// Tadpole.feed: frog food ages it by a tenth of the time left to
// grow into a frog (it grows on its next tick once past 24000).
Species::Tadpole if is_food && !m.extra.age_locked => {
consume = true;
let left = 24_000 - m.extra.age;
m.extra.age += ((left / 20) as f32 * 0.1) as i32 * 20;
// One happy villager particle at rest about the body.
self.particles_around_mob(
self.mobs[index].id,
"happy_villager",
horizon_client::particles::Burst::HORSE_EATING,
);
}
// Bee.mobInteract: a wither rose or an open eyeblossom is eaten for
// its `getBeeInteractionEffect` instead of breeding the bee.
Species::Bee
if is_food
&& matches!(
&*item,
"minecraft:wither_rose" | "minecraft:open_eyeblossom"
) =>
{
if &*item == "minecraft:wither_rose" {
m.add_effect("minecraft:wither", 40, 0);
} else {
m.add_effect("minecraft:poison", 25, 0);
}
consume = true;
}
// HappyGhast.canFallInLove: never, so only a ghastling takes food.
_ if is_food
&& m.kind.flags.animal
@@ -3039,6 +3361,11 @@ impl Game<'_> {
Some(sound) => {
consume = true;
eat = sound;
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
self.mob_event_particles(self.mobs[index].id, 18);
}
}
// Animal.mobInteract still consumes the client click when
// an adult cannot breed, without swinging or taking food.
@@ -3114,6 +3441,50 @@ impl Game<'_> {
}
Ok(true)
}
/// `Armadillo.mobInteract`: a brush brushes a scute off an adult
/// (`brushOffScute`) and loses 16 durability.
fn interact_armadillo(&mut self, index: usize, item: &str) -> bool {
let m = &self.mobs[index];
if item != "minecraft:brush"
|| m.species() != Species::Armadillo
|| !m.alive()
|| m.is_baby()
{
return false;
}
let (id, at) = (m.id, m.position);
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
self.spawn_at_location(at, 0.0, d);
}
self.audio
.play("minecraft:entity.armadillo.brush", Some(feet), 1.0, 1.0);
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:entity_interact", feet, ctx);
// ItemStack.hurtAndBreak(16).
if !self.creative
&& let Some(stack) = tool
{
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
if max > 0 {
let loss = self.durability_loss(&stack, 16);
let slot = self.held_slot();
if let Some(held) = self.stack_in_mut(slot).as_mut() {
held.damage += loss;
if held.damage >= max {
*self.stack_in_mut(slot) = None;
}
}
}
}
true
}
pub(super) fn damage_held_tool(&mut self) {
if self.creative {
return;
@@ -3191,6 +3562,45 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| stack.item.strip_suffix("_spawn_egg") != Some(parent.kind.id.as_str())
|| !horizon_client::mobs::drops::can_be_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
let Some(id) = self.spawn_mob(&kind, at, true) else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::set_baby(child, true);
child.extra.variant = variant;
if species == Species::Sheep {
child.extra.color = color;
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let Some(hit) = self.pick() else {
return Ok(false);
@@ -3417,6 +3827,30 @@ impl Game<'_> {
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
@@ -219,6 +219,7 @@ impl Game<'_> {
if let Some(a) = arrow.arrow.as_mut() {
a.set_item(ammo);
a.set_owner(Some(Who::Player));
a.fired_from_weapon = Some(weapon.clone());
a.pierce = pierce;
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
@@ -235,6 +236,11 @@ impl Game<'_> {
.play("minecraft:item.crossbow.shoot", Some(eye), 1.0, pitch);
self.damage_held(if rocket { 3 } else { 1 });
}
// CrossbowItem.performShooting: SHOT_CROSSBOW with the crossbow, which
// is an empty stack (no match) when the shots broke it.
if let Some(stack) = self.held().cloned() {
self.trigger_item("minecraft:shot_crossbow", &stack);
}
}
}
@@ -654,6 +654,9 @@ impl Game<'_> {
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
}
let mut state = ServerState {
lists,
console,
@@ -120,6 +120,11 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+1 -1
View File
@@ -982,7 +982,7 @@ impl Game<'_> {
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
+68 -15
View File
@@ -27,6 +27,8 @@ pub(super) const SPECIAL: u32 = u32::MAX - 4096;
const CRYSTAL_IDS: u32 = SPECIAL + 16;
const FIGHT_FILE: &str = "data/minecraft/ender_dragon_fight.dat";
const MAX_HEALTH: f32 = 200.0;
/// `Mob.getAmbientSoundInterval`.
const AMBIENT_INTERVAL: i32 = 80;
#[derive(Default)]
pub(super) struct Fight {
@@ -270,6 +272,8 @@ pub(super) struct Dragon {
flame_count: i32,
sitting_damage: f32,
growl: i32,
/// `Mob.ambientSoundTime`.
ambient_time: i32,
ticks: i32,
/// DragonFlightHistory: (y, yRot) samples, newest at `head`.
history: [(f64, f32); 64],
@@ -291,11 +295,11 @@ pub(super) enum FightWho {
}
/// A player in the End as the dragon sees it.
#[derive(Clone, Copy, Debug)]
struct FightPlayer {
who: FightWho,
position: Vec3,
bounds: Aabb,
alive: bool,
pub(super) struct FightPlayer {
pub(super) who: FightWho,
pub(super) position: Vec3,
pub(super) bounds: Aabb,
pub(super) alive: bool,
creative: bool,
spectator: bool,
}
@@ -397,6 +401,7 @@ impl Dragon {
flame_count: 0,
sitting_damage: 0.0,
growl: 100,
ambient_time: 0,
ticks: 0,
history: [(position.y, yaw); 64],
head: 0,
@@ -693,7 +698,7 @@ impl Game<'_> {
}
/// The players in the level the dragon sees (`level.players()`): the
/// window's own, then every guest in the level.
fn fight_players(&self) -> Vec<FightPlayer> {
pub(super) fn fight_players(&self) -> Vec<FightPlayer> {
let mut out = Vec::new();
if let Some(p) = self.player.as_ref() {
out.push(FightPlayer {
@@ -722,7 +727,7 @@ impl Game<'_> {
}
/// Run `f` as the fight's player `who` (a guest's turn, its knockback
/// sent to its client).
fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
pub(super) fn as_fight_player(&mut self, who: FightWho, f: impl FnOnce(&mut Self)) {
match who {
FightWho::Host => f(self),
FightWho::Guest(conn) => {
@@ -987,6 +992,8 @@ impl Game<'_> {
};
let hurt = d.hurt(part, damage, counts, arrow);
if hurt == (Hurt::Landed { full: true }) {
// Mob.playHurtSound: the ambient sound clock starts again.
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
@@ -1566,6 +1573,7 @@ impl Game<'_> {
age: 0,
duration: 200,
from_dragon: true,
potion: None,
});
}
}
@@ -1658,6 +1666,24 @@ impl Game<'_> {
self.tick_dragon_death();
return;
}
// Mob.baseTick: the ambient sound, rarer right after one.
let roll = d.random.next_int_bound(1000).unwrap_or(999);
let waited = d.ambient_time;
d.ambient_time += 1;
if roll < waited {
d.ambient_time = -AMBIENT_INTERVAL;
let at = [d.position.x, d.position.y, d.position.z];
// LivingEntity.getVoicePitch, from the dragon's own random.
let r = &mut d.random;
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
self.audio.play(
"minecraft:entity.ender_dragon.ambient",
Some(at),
5.0,
pitch,
);
}
let d = self.dragon.as_mut().expect("dragon");
// Flapping, growls and the flap sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
@@ -2078,12 +2104,18 @@ impl Game<'_> {
age: 0,
duration: 600,
from_dragon: false,
potion: None,
});
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some([next.x, next.y, next.z]),
1.0,
1.0,
// DragonFireball.onHit: level event 2006 (the burst of dragon
// breath and the explosion sound) at its block position.
self.level_event_at(
2006,
[
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
],
1,
);
return false;
}
@@ -2110,6 +2142,10 @@ impl Game<'_> {
let mut harmed: Vec<(FightWho, f32)> = Vec::new();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| {
// Lingering potions' clouds tick with the potions.
if c.potion.is_some() {
return true;
}
c.age += 1;
if c.age >= c.duration {
return false;
@@ -2157,11 +2193,14 @@ impl Game<'_> {
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
/// death message names it, or the cloud once the dragon is gone.
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
self.note_named_attacker(damage_type, id, name);
} else if damage_type == "minecraft:indirect_magic" {
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
self.note_direct_entity(damage_type, name);
}
self.hurt_player_by(damage_type, amount);
}
@@ -2254,8 +2293,22 @@ pub(super) struct Breath {
pub(super) at: [f64; 3],
pub(super) radius: f32,
pub(super) age: i32,
duration: i32,
from_dragon: bool,
pub(super) duration: i32,
pub(super) from_dragon: bool,
/// A lingering potion's cloud (`ThrownLingeringPotion`): what it applies.
pub(super) potion: Option<super::potions::PotionCloud>,
}
impl Breath {
/// `AreaEffectCloud.isWaiting`: before its wait time (20 ticks, 10 for a
/// lingering potion's).
pub(super) fn waiting(&self) -> bool {
self.age
< if self.potion.is_some() {
super::potions::LINGERING_WAIT
} else {
20
}
}
}
impl Game<'_> {
/// `EnderDragonRenderer` and `EnderDragonModel.setupAnim`.
+57 -10
View File
@@ -307,6 +307,12 @@ impl Game<'_> {
)
{
outcome = o;
// dealDefaultKnockback's `indicateDamage` points at the blast
// (the source position), not the no-direction fallback.
if o.knockback && o.blocked <= 0.0 {
p.health
.indicate_damage(center[0] - feet[0], center[2] - feet[2], p.yaw);
}
}
if !spectator && !(creative && flying) {
p.velocity = Vec3::new(
@@ -394,7 +400,14 @@ impl Game<'_> {
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
attacker: exploding.map(horizon_client::mobs::Who::Mob),
// A projectile's owner (`getIndirectSourceEntity`), else
// the exploding mob itself.
attacker: self
.combat
.explosion_owner
.or(exploding)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
if hurtable && !m.damage(damage(dist, exposure), source) {
@@ -490,6 +503,15 @@ impl Game<'_> {
);
}
}
/// Runs an explosion whose source empties the hives it destroys
/// (`BeehiveBlock.getDrops`: primed TNT, creeper, wither skull, wither,
/// TNT minecart).
pub(super) fn with_hive_emptying_blast<R>(&mut self, blast: impl FnOnce(&mut Self) -> R) -> R {
self.combat.hive_emptying_blast = true;
let result = blast(self);
self.combat.hive_emptying_blast = false;
result
}
/// `Level.explode` on the server: positions, entities, blocks, fire; then
/// the client's explosion sound.
pub(super) fn server_explode(
@@ -572,16 +594,16 @@ impl Game<'_> {
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
let block_count = if interaction == BlockInteraction::Keep {
0
} else {
targets.len()
};
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
let block_count = targets.len();
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion(center, radius, block_count, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, radius, block_count);
self.particles
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -598,9 +620,30 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
let key = (pos[0], pos[1], pos[2]);
let is_hive = matches!(&*name, "minecraft:beehive" | "minecraft:bee_nest");
// BeehiveBlock.getDrops: the bees of a hive blown up by TNT, a
// creeper, a wither skull, a wither or a TNT minecart come out.
if is_hive && self.combat.hive_emptying_blast {
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
self.empty_hive(
pos,
horizon_client::beehive::facing_step(facing.as_deref()),
horizon_client::beehive::Release::Emergency,
);
}
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
let custom = self.world.block_names.get(&key).cloned();
self.pending.record(pos, &entity, &name, custom);
@@ -633,6 +676,10 @@ impl Game<'_> {
}
self.pending.clear(pos);
self.level_set(pos, self.world.air)?;
// BeehiveBlock.onExplosionHit: the bees about turn on the player.
if is_hive {
self.anger_nearby_bees(pos);
}
// TntBlock.wasExploded: new PrimedTnt (its push drawn first), then
// getRandomShortFuse(80); no game event.
if is_tnt && self.ui.rules.bool("tnt_explodes") {
+12 -1
View File
@@ -103,12 +103,23 @@ impl Game<'_> {
let b = horizon_core::geometry::Aabb::new(lo, hi);
// Every player in the box, the server's players too.
let pos = f.pos;
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
// owner lives, plain magic (nobody to name) once it is gone.
let owner = self
.mobs
.iter()
.any(|m| m.id == f.owner && m.alive())
.then_some(f.owner);
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
let kind = match owner {
Some(_) => "minecraft:indirect_magic",
None => "minecraft:magic",
};
g.mob_hits_player_caused(6.0, pos, kind, owner);
}
};
bite(self);
+58 -40
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,
@@ -62,11 +62,56 @@ impl Rocket {
item,
}
}
/// One tick of a server's client: the previous position, then `step`.
pub(super) fn remote_step(&mut self, step: impl FnOnce(Vec3) -> Vec3) {
/// One tick of a server's client: the rocket flies on by itself between
/// the server's updates (a rocket that follows an entity is placed by the
/// caller).
pub(super) fn remote_step(&mut self, scene: &mut SceneCollisions) {
self.previous = self.position;
if self.attached_to.is_none() {
self.free_flight(scene);
}
self.life += 1;
self.position = step(self.position);
}
/// `FireworkRocketEntity.tick` for a rocket attached to no entity: speed
/// up (an angled one keeps its motion) and rise, then move. True when a
/// block stopped it.
fn free_flight(&mut self, scene: &mut SceneCollisions) -> bool {
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
Vec3::new(
self.position.x + h,
self.position.y + 2.0 * h,
self.position.z + h,
),
);
let wanted = if self.angled {
self.velocity
} else {
Vec3::new(
self.velocity.x * 1.15,
self.velocity.y + 0.04,
self.velocity.z * 1.15,
)
};
let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) else {
return false;
};
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
let horizontal = (moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.velocity = if horizontal {
Vec3::new(self.velocity.x, wanted.y, self.velocity.z)
} else {
wanted
};
blocked
}
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
@@ -191,46 +236,14 @@ impl Game<'_> {
r.velocity = p.velocity;
}
} else {
// Not shot at an angle: accelerate (unless blocked sideways) and rise.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let h = 0.125;
let bounds = Aabb::new(
Vec3::new(r.position.x - h, r.position.y, r.position.z - h),
Vec3::new(r.position.x + h, r.position.y + 2.0 * h, r.position.z + h),
);
let wanted = if r.angled {
r.velocity
} else {
Vec3::new(
r.velocity.x * 1.15,
r.velocity.y + 0.04,
r.velocity.z * 1.15,
)
};
if let Ok(moved) = scene.resolve(bounds, wanted, 0.0, false) {
let blocked = (moved.x - wanted.x).abs() > 1e-7
|| (moved.y - wanted.y).abs() > 1e-7
|| (moved.z - wanted.z).abs() > 1e-7;
if r.angled && blocked {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
let horizontal =
(moved.x - wanted.x).abs() > 1e-7 || (moved.z - wanted.z).abs() > 1e-7;
r.position = Vec3::new(
r.position.x + moved.x,
r.position.y + moved.y,
r.position.z + moved.z,
);
r.velocity = if horizontal {
Vec3::new(r.velocity.x, wanted.y, r.velocity.z)
} else {
wanted
};
if r.free_flight(&mut scene) && r.angled {
// hitTargetOrDeflectSelf: explode on the block.
r.life = r.lifetime;
}
}
let at = [r.position.x, r.position.y, r.position.z];
@@ -383,7 +396,12 @@ impl Game<'_> {
/// lit at the rocket's light probe.
pub(super) fn rocket_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let orientation = glam::Mat4::from_quat(self.camera_orientation());
for r in self.rockets.iter().filter(|r| !r.attached) {
// shouldRender: not while attached to an entity (`isAttachedToEntity`).
for r in self
.rockets
.iter()
.filter(|r| !r.attached && r.attached_to.is_none())
{
// FireworkRocketEntity.shouldRenderAtSqrDistance: within 64 blocks;
// EntityRenderer.shouldRender: its 0.25 box in the view.
if !self.standing_entity_in_view(
+25 -2
View File
@@ -2,6 +2,7 @@
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use horizon_client::advancements::EntityView;
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
@@ -77,7 +78,7 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
if self.hook.is_some() {
let damage = self.retrieve_hook();
let damage = self.retrieve_hook(stack);
self.damage_held(damage);
self.audio.play(
"minecraft:entity.fishing_bobber.retrieve",
@@ -143,7 +144,7 @@ impl Game<'_> {
true
}
/// `FishingHook.retrieve`: the rod damage it costs.
fn retrieve_hook(&mut self) -> u32 {
fn retrieve_hook(&mut self, rod: &ItemStack) -> u32 {
let Some(hook) = self.hook.take() else {
return 0;
};
@@ -167,6 +168,15 @@ impl Game<'_> {
item.velocity = pulled(item.velocity);
hooked_item = true;
}
// CriteriaTriggers.FISHING_ROD_HOOKED with no items.
let hooked = self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| self.mob_entity(m));
if let Some(hooked) = hooked {
self.trigger_fishing_rod_hooked(rod, &hooked, &[]);
}
// `hookedIn instanceof ItemEntity ? 3 : 5`.
damage = if hooked_item { 3 } else { 5 };
} else if hook.nibble > 0 {
@@ -191,6 +201,7 @@ impl Game<'_> {
Some(hook.open_water),
);
self.world.random = random;
let mut caught = Vec::new();
for stack in items {
// FishingHook.retrieve: fish_caught for each catch in #minecraft:fishes.
if self
@@ -222,6 +233,7 @@ impl Game<'_> {
if let Some(potion) = &stack.potion {
item = item.with_potion(potion);
}
caught.push(item.clone());
let (dx, dy, dz) = (
owner.x - hook.position.x,
owner.y - hook.position.y,
@@ -241,6 +253,15 @@ impl Game<'_> {
let xp = self.world.random.next_int_bound(6).unwrap_or(0) as u32 + 1;
self.spawn_orbs([owner.x, owner.y + 0.5, owner.z + 0.5], xp);
}
// CriteriaTriggers.FISHING_ROD_HOOKED: the hook itself is the entity.
let bobber = EntityView {
kind: "minecraft:fishing_bobber".into(),
position: [hook.position.x, hook.position.y, hook.position.z],
on_ground: hook.on_ground,
fishing_open_water: Some(hook.open_water),
..Default::default()
};
self.trigger_fishing_rod_hooked(rod, &bobber, &caught);
damage = 1;
}
if hook.on_ground {
@@ -433,6 +454,8 @@ impl Game<'_> {
let options = ParticleOptions::simple(kind);
self.particles
.send_particles(&options, at, spread, speed, count);
// The players a server serves are sent them too.
self.lan_send_particles(&options, at, spread, speed, count);
}
/// `FishingHook.tick`.
pub(super) fn tick_fishing(&mut self) {
+1 -1
View File
@@ -114,7 +114,7 @@ impl Game<'_> {
living: Some(HurtBob {
hurt_time: p.health.hurt_animation_time as f32 - partial,
hurt_duration: hurt.hurt_duration,
hurt_dir: 0.0,
hurt_dir: p.health.hurt_dir,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
@@ -20,7 +20,8 @@ use horizon_core::{
};
use horizon_world::{
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
worldgen::terrain::TerrainBlockStates,
};
/// The loaded world as the spawn searches query it.
@@ -121,6 +122,96 @@ impl Game<'_> {
}
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
}
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
/// each side that can hold one, on the first free column of a shuffled
/// walk over the chunk. Skipped while the chunk is not loaded.
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
use horizon_client::containers::BlockEntity;
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
let registry = &self.pack.assets.registry;
let (Some(chest), Some(torch)) = (
registry.default_state("minecraft:chest"),
registry.default_state("minecraft:torch"),
) else {
return Ok(());
};
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
let random = &mut self.world.random;
let mut shuffled = |min: i32| -> [i32; 16] {
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
for end in (2..=16usize).rev() {
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
values.swap(end - 1, other);
}
values
};
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
for x in xs {
for z in zs {
let y = self
.spawn_columns()
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
continue;
}
self.set_block_clients([x, y, z], chest)?;
let seed = self.world.random.next_long();
self.world
.block_entities
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
self.load_block_entity_nbt(
[x, y, z],
&format!(
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
),
);
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let at = [x + dx, y, z + dz];
let survives = self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
});
if survives {
self.set_block_clients(at, torch)?;
}
}
return Ok(());
}
}
Ok(())
}
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
state,
UPDATE_CLIENTS,
);
});
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape_with_context(
&mut scene,
self.world.block(pos[0], pos[1], pos[2]),
BlockPos::new(pos[0], pos[1], pos[2]),
&CollisionContext::empty(false),
)
.is_ok_and(|shape| shape.is_empty())
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
@@ -52,6 +52,8 @@ struct Flags {
/// server follows while it runs (IntegratedServer.tickServer).
render_distance: AtomicI32,
simulation_distance: AtomicI32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
}
/// Work the window hands its server thread (`MinecraftServer.execute`).
@@ -69,6 +71,10 @@ impl Integrated {
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
/// `MinecraftServer.isCurrentlySaving`.
pub fn is_saving(&self) -> bool {
self.flags.saving.load(Ordering::Relaxed)
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
@@ -148,6 +154,12 @@ pub(super) struct Serving {
/// is out of its place: applied after the tick.
pending: Option<Publish>,
}
impl Serving {
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
pub(super) fn set_saving(&self, saving: bool) {
self.flags.saving.store(saving, Ordering::Relaxed);
}
}
enum Publish {
Open(TcpListener, LanSettings),
Close,
@@ -79,6 +79,7 @@ impl Game<'_> {
if !self.creative {
self.consume_held();
}
self.stats.custom("play_record", 1);
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
@@ -90,6 +91,8 @@ impl Game<'_> {
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// Level event 1010: the song from the jukebox, and the "Now Playing" overlay.
+107 -18
View File
@@ -11,7 +11,7 @@ use horizon_client::{
chunks::ChunkEncoder,
data::SyncData,
entity::{EntityState, States},
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
@@ -191,6 +191,17 @@ fn mob_meta(
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
// swirls of its effects (the ones a mob has are visible, not ambient).
meta.extend(
hub.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, false)),
),
);
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
// weapon).
@@ -236,6 +247,27 @@ fn mob_meta(
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
"minecraft:snow_golem" => {
if e.sheared {
meta.push((16, serializer::BYTE, Value::Byte(0)));
}
defaults.push((16, serializer::BYTE, Value::Byte(16)));
}
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
"minecraft:mooshroom" => {
if e.variant == 1 {
meta.push((17, serializer::INT, Value::Int(1)));
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
"minecraft:piglin" => {
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
"minecraft:sheep" => {
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
@@ -306,6 +338,14 @@ fn mob_meta(
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
}
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER),
// set while `serverStillTimeout > 0` (`syncStayStillFlag`).
"minecraft:happy_ghast" => {
if horizon_client::mobs::happy_ghast::on_still_timeout(m) {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Villager.DATA_VILLAGER_DATA and ZombieVillager's (after Zombie's three).
"minecraft:villager" | "minecraft:zombie_villager" => {
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
@@ -350,6 +390,14 @@ fn mob_meta(
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
// rears up; the client animates it.
"minecraft:polar_bear" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
"minecraft:evoker" | "minecraft:illusioner" => {
if e.variant2 != 0 {
@@ -612,7 +660,8 @@ impl Game<'_> {
let host_uuid = host.unwrap_or(0);
self.next_entity += 1;
let host_entity = self.next_entity as i32;
let hub = Hub::new(lan, sync, encoder, config);
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
let port = hub.lan.port;
self.world.block_log = Some(Vec::new());
self.world.light_log = Some(Default::default());
@@ -845,6 +894,16 @@ impl Game<'_> {
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
bed: self.sleeping,
body: BodyFlags {
gliding: p.fall_flying,
swimming: p.swimming,
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
crouching: p.pose == horizon_client::pose::Pose::Crouching,
spin_attack: p.auto_spin_attack_ticks > 0,
on_fire: p.fire_ticks > 0,
invisible: self.potions.effects.get("invisibility").is_some(),
glowing: self.potions.effects.get("glowing").is_some(),
},
})
}
@@ -860,7 +919,7 @@ impl Game<'_> {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
// host), another mob or a fence knot (game/lan_entities.rs).
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
@@ -869,7 +928,7 @@ impl Game<'_> {
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => player_entity,
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
@@ -878,6 +937,9 @@ impl Game<'_> {
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
// HappyGhast.aiStep: requiresPrecisePosition while on a still timeout.
e.precise = m.species() == horizon_client::mobs::Species::HappyGhast
&& horizon_client::mobs::happy_ghast::on_still_timeout(m);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -954,6 +1016,9 @@ impl Game<'_> {
for m in &mut self.mobs {
m.needs_sync = false;
}
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
@@ -962,6 +1027,16 @@ impl Game<'_> {
host.hub.block_event(pos, b0, b1, state);
}
let entities = states.as_slice();
// An entity that left the level since the last tick (a snowball
// that hit) is dropped by the tracker below: its event goes out
// first, as `Level.broadcastEntityEvent` precedes the discard.
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
.into_iter()
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
self.lan_entity_events = later;
for (id, event, at) in gone {
host.hub.entity_event(id, event, at);
}
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
@@ -1104,8 +1179,11 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
let _ = self.flush_wind_bursts();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
}
fn lan_guest_joined(
@@ -1207,8 +1285,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
@@ -1243,8 +1322,9 @@ impl Game<'_> {
self.lan_entity_events.push((id, event, at));
}
}
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
/// attacker's client and observers once the hub is back in place.
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
/// the hub is back in place.
pub(super) fn critical_hit(
&mut self,
attacker: Option<u32>,
@@ -1252,16 +1332,18 @@ impl Game<'_> {
feet: [f64; 3],
width: f64,
height: f64,
magic: bool,
) {
use horizon_client::particles::{ParticleOptions, ParticleType};
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
let particle = if magic {
ParticleType::EnchantedHit
} else {
ParticleType::Crit
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
if self.audio.capture.is_some() {
self.lan_critical_hits.push((attacker, id));
self.lan_critical_hits.push((attacker, id, magic));
}
}
/// Send the tick's level events, sounds and mob animations.
@@ -1339,13 +1421,13 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
for (attacker, target) in critical_hits {
for (attacker, target, magic) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
}) else {
continue;
};
host.hub.critical_hit(attacker, target);
host.hub.critical_hit(attacker, target, magic);
}
}
@@ -1428,6 +1510,13 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
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);
m.extra.effect_particles = visible()
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
+90 -9
View File
@@ -75,6 +75,8 @@ pub(super) struct GuestPlay {
/// `ServerPlayer.getStats` and the inventory `inventory_changed` last saw.
pub(super) stats: super::stats::Stats,
last_inventory: u64,
/// `ServerPlayer.levitationStartPos` and `levitationStartTime`.
levitation: Option<([f64; 3], u64)>,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
@@ -158,6 +160,7 @@ pub(super) struct GuestSwap {
host_ender: Option<(i32, i32, i32)>,
adv_loaded: bool,
last_inventory: u64,
levitation: Option<([f64; 3], u64)>,
death_cause: Option<String>,
/// The guest's own damage record, in place while they act.
combat: super::combat::CombatTracker,
@@ -265,8 +268,10 @@ impl Game<'_> {
}
let mut play = host.play.remove(&conn).unwrap_or_default();
if fresh {
// A new body (joined, respawned, changed level): nothing on it yet.
// A new body (joined, respawned, changed level): nothing on it yet,
// its effects' modifiers and armor protection included.
play.equipped = None;
play.potions = Default::default();
play.sculk = Default::default();
}
let g = &mut host.hub.guests[i];
@@ -382,6 +387,7 @@ impl Game<'_> {
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let levitation = std::mem::replace(&mut self.adv.levitation, play.levitation);
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
let acting = (
self.lan_acting.replace(conn),
@@ -402,6 +408,12 @@ impl Game<'_> {
self.apply_equipment();
play.equipped = Some(equipped);
}
// The player's effects are on the new body before anything it does
// (the movement that brought it, a landing): vanilla keeps the same
// entity through a level change, with its attributes and effects.
if fresh {
self.apply_effect_status();
}
self.update_totem_ready();
Some(GuestSwap {
conn,
@@ -446,6 +458,7 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
acting,
packets,
@@ -504,6 +517,7 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
combat,
acting,
@@ -614,6 +628,7 @@ impl Game<'_> {
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
play.levitation = std::mem::replace(&mut self.adv.levitation, levitation);
let stat_events = std::mem::take(&mut play.stats.events);
let name = g.name.clone();
self.potions.swap_player(&mut g.effects, &mut play.potions);
@@ -657,6 +672,13 @@ impl Game<'_> {
g.sprinting = false;
}
m.sprinting = false;
// ServerPlayer.indicateDamage: the hit's direction to the player.
if let Some(yaw) = m.health.indicated_dir.take() {
g.send(&C::HurtAnimation(play::HurtAnimation {
id: g.entity_id,
yaw,
}));
}
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
g.motion = Some(Box::new(m));
@@ -858,13 +880,17 @@ impl Game<'_> {
}
/// The guest's open container is closing (`AbstractContainerMenu.removed`):
/// it no longer counts among the openers of the lid.
/// it no longer counts among the openers of the lid, and the lid's close
/// sound is played for everyone around (`ChestBlockEntity.onClose`, a
/// `playSound` with no player), the guest included, once the last opener
/// has left.
pub(super) fn guest_container_closed(&mut self, host: &mut LanHost, conn: u32) {
if let Some(g) = host.hub.guest(conn)
&& let Some(pos) = g.menu.as_ref().and_then(|m| m.pos)
&& !g.spectator()
&& self.container_openers_changed(pos, false)
{
self.container_openers_changed(pos, false);
self.container_edge_sound(pos, false);
}
}
@@ -958,6 +984,20 @@ impl Game<'_> {
// The pause between two blocks is the client's (MultiPlayerGameMode's
// destroyDelay); the server digs from the START it receives.
play.destroy_delay = 0;
// ServerPlayerGameMode.handleBlockBreakAction: a survival START
// attacks the block at once, so a tap released in the same tick
// still plays a note. The dig is marked begun for tick_mining.
let pos = a.pos;
self.as_guest(host, conn, |g| {
if g.may_build() && !g.creative {
let _ = g.attack_block(pos);
g.mining = Some(Mining {
pos,
progress: 0.0,
ticks: 0,
});
}
});
}
1 => {}
3 => {
@@ -1216,6 +1256,12 @@ impl Game<'_> {
{
self.guest_carried_back(host, gi);
}
S::SelectBundleItem(p) => {
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
}
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
let slots = host.hub.guests[gi]
@@ -1518,9 +1564,13 @@ impl Game<'_> {
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
self.as_guest(host, conn, |g| {
g.pvp_exhaustion();
if strike.stops_sprint() {
g.stop_sprint_after_extra_knockback();
}
});
}
return;
}
@@ -1825,10 +1875,6 @@ impl Game<'_> {
};
xp.respawn(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
(g.xp_level, g.xp_progress, g.xp_total) = (xp.level, xp.progress, xp.total);
// A new body: nothing given to the old one carries over.
if let Some(play) = host.play.get_mut(&conn) {
play.potions = Default::default();
}
let clock_rate = self.network_clock_rate();
let ctx = TickCtx {
world: &mut self.world,
@@ -2305,6 +2351,13 @@ impl Game<'_> {
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let velocity = marked.then_some(player.velocity);
// Player.causeFallDamage: a fall of 2 blocks or more counts.
for event in &events {
if let MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) = event
{
game.stats.custom("fall_one_cm", *cm);
}
}
game.record_combat(&events);
game.wear_armor(&events);
(events, velocity)
@@ -2319,7 +2372,35 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
// LivingEntity.checkFallDamage: the crumbs reach every player.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
position,
count,
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
} => {
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
};
if sound {
let name = "minecraft:block.honey_block.slide";
let source = horizon_client::settings::sound_source(name) as i32;
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
}
if particles {
self.honey_slide_particles(at);
host.hub.entity_event(id, 53, at);
}
}
_ => {}
}
}
@@ -127,9 +127,18 @@ impl Game<'_> {
}
let dropped = std::mem::take(&mut menu.dropped);
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
menu.drops.clear();
menu.crafted.clear();
store_player(menu, &mut host.hub.guests[gi]);
if !brewed.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
for stack in &brewed {
g.trigger_brewed_potion(stack);
}
});
}
if !equipped.is_empty() {
let conn = host.hub.guests[gi].conn;
self.as_guest(host, conn, |g| {
+50 -3
View File
@@ -225,6 +225,16 @@ impl Game<'_> {
}
out.push(e);
}
for (p, &id) in &self.knot_ids {
// LeashFenceKnotEntity.getAddEntityPacket: the fence's block.
out.push(state(
id,
"minecraft:leash_knot",
p.map(f64::from),
0.0,
false,
));
}
for p in &self.paintings {
let mut e = state(p.id, "minecraft:painting", p.pos.map(f64::from), 0.0, false);
// Direction.get2DDataValue (south 0, west 1, north 2, east 3) to its
@@ -270,6 +280,24 @@ impl Game<'_> {
/// Gives the entities Horizon keeps without an id one (`Entity.id`, from
/// the same counter as the rest) before they are first tracked.
pub(super) fn lan_assign_ids(&mut self) {
// LeashFenceKnotEntity: one per fence post with a lead on it
// (`notifyLeasheeRemoved` discards it once the last lead is gone).
let knots: Vec<[i32; 3]> = self
.mobs
.iter()
.filter(|m| !m.removed)
.filter_map(|m| match m.extra.leash {
Some(horizon_client::mobs::leash::Holder::Knot(p)) => Some(p),
_ => None,
})
.collect();
self.knot_ids.retain(|p, _| knots.contains(p));
for p in knots {
self.knot_ids.entry(p).or_insert_with(|| {
self.next_entity += 1;
self.next_entity
});
}
let next = &mut self.next_entity;
let mut give = |id: &mut u32| {
if *id == 0 {
@@ -342,6 +370,7 @@ impl Game<'_> {
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
e.pitch = p.pitch;
e.velocity = at(p.velocity);
e.needs_sync = p.needs_sync;
// Projectile.getAddEntityPacket: the owner's id.
e.data = match p.guest.and_then(|conn| hub.guest(conn)) {
Some(g) => g.entity_id,
@@ -357,6 +386,20 @@ impl Game<'_> {
a.is_some_and(|a| a.in_ground) || p.stuck,
));
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
K::Trident => {
let a = p.arrow.as_deref();
e.meta.extend(arrow_meta(
false,
a.is_some_and(|a| a.no_physics),
0,
a.is_some_and(|a| a.in_ground) || p.stuck,
));
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
}
}
// WitherSkull.DATA_DANGEROUS.
K::WitherSkull { dangerous: true } => {
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
@@ -452,7 +495,7 @@ impl Game<'_> {
}
out.push(e);
}
// The dragon's breath clouds (AreaEffectCloud's DATA_RADIUS,
// The dragon's breath and lingering potion clouds (AreaEffectCloud's DATA_RADIUS,
// DATA_WAITING for its 20-tick wait, and DATA_PARTICLE): the
// client draws them from these (`tick_remote_clouds`).
let breath = self.network_particle(
@@ -470,10 +513,14 @@ impl Game<'_> {
for b in self.breath_clouds.iter().filter(|b| b.id != 0) {
let mut e = state(b.id, "minecraft:area_effect_cloud", b.at, 0.0, false);
e.meta.push((8, serializer::FLOAT, Value::Float(b.radius)));
if b.age < 20 {
if b.waiting() {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
if let Some(p) = &breath {
let particle = match &b.potion {
Some(p) => self.network_particle(&p.particle(), None),
None => breath.clone(),
};
if let Some(p) = &particle {
e.meta
.push((10, serializer::PARTICLE, Value::Particle(p.clone())));
}
@@ -363,6 +363,7 @@ mod tests {
off_hand: None,
head: None,
armor_cover: 0.0,
invisible: false,
spectator: false,
gold_armor: false,
hurt_by: None,
@@ -374,6 +375,7 @@ mod tests {
uuid: [0; 4],
allies: Default::default(),
remote: false,
passenger: false,
}
}
+160 -8
View File
@@ -1,7 +1,8 @@
//! Particles a server sends its players and its clients draw:
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
@@ -81,6 +82,66 @@ impl Game<'_> {
}
}
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
/// landing block's crumbs: drawn in this window, when it has one, and
/// sent to each player within 32 blocks. `host` is the hub of a guest's
/// turn, which is out of `self.lan` meanwhile.
pub(super) fn landing_dust(
&mut self,
host: Option<&LanHost>,
state: BlockStateId,
at: Vec3,
count: i32,
speed: f64,
) {
let at = [at.x, at.y, at.z];
if self.serving.is_none() && self.options.dedicated.is_none() {
let block = [
at[0].floor() as i32,
(at[1] - 0.2).floor() as i32,
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
self.particles.send_particles(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
at,
[0.0; 3],
speed,
count as u32,
);
}
}
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
return;
};
let Some(particle) = Self::hub_particle(
hub,
&ParticleOptions::simple(ParticleType::Block),
Some(state),
) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: [speed as f32; 3],
count,
randomization: 0,
});
for g in hub.guests.iter().filter(|g| hub.here(g)) {
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
/// network id and the data it carries (`state` for the block particles).
/// None while not serving, or for a particle whose data isn't sent from
@@ -89,13 +150,19 @@ impl Game<'_> {
&self,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
}
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
/// guest's turn).
fn hub_particle(
hub: &Hub,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
let name = format!("minecraft:{}", options.ty.name());
let id = self
.lan
.as_ref()?
.hub
.registry_id("minecraft:particle_type", &name)?;
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
let c = options.color;
let data = match options.ty {
@@ -251,11 +318,87 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
let emitter = if radius < 2.0 {
pub(super) fn lan_explode(
&mut self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
@@ -268,6 +411,15 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+54 -16
View File
@@ -37,6 +37,8 @@ pub(super) struct Strike {
amount: f32,
scale: f32,
crit: bool,
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
@@ -84,9 +86,11 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
}
self.lan = Some(host);
true
@@ -104,27 +108,33 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset, the exhaustion and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
amount,
scale,
crit,
magic,
from,
sweep,
knockback: (strength, xd, zd),
@@ -257,7 +267,31 @@ impl Game<'_> {
} else {
Default::default()
};
if hurt && s.crit {
// ServerPlayer.awardKillScore: the blow that kills a player.
if hurt {
let died = match victim {
// A totem that fired is applied on the next tick_totem, so the
// victim is still at zero health but is not dying.
Victim::Host => self
.player
.as_ref()
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref())
.is_some_and(|p| p.health.dead_or_dying() && !p.health.totem_used),
};
if died {
match s.attacker {
Some(conn) => {
self.as_guest(host, conn, |g| g.stats.custom("player_kills", 1));
}
None => self.stats.custom("player_kills", 1),
}
}
}
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
@@ -267,13 +301,17 @@ impl Game<'_> {
};
if let Some((id, p)) = target {
let d = p.dimensions;
self.critical_hit(
s.attacker,
id,
let (feet, width, height) = (
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
);
if s.crit {
self.critical_hit(s.attacker, id, feet, width, height, false);
}
if s.magic {
self.critical_hit(s.attacker, id, feet, width, height, true);
}
}
}
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
@@ -317,6 +317,8 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
game.give_levels(-need);
game.stats.custom("enchant_item", 1);
// CriteriaTriggers.ENCHANTED_ITEM, with the levels it cost.
game.trigger_enchanted_item(&enchanted, need);
});
host.hub.guests[gi].xp_seed = self.world.random.next_int();
let env = self.menu_env();
@@ -277,7 +277,12 @@ impl Game<'_> {
g.carried = menu.carried.take();
}
self.register_exploration_maps(made);
gm.merchant.session = self.merchant_apply(session, gm.merchant.mob);
let (session, traded) = self.merchant_apply(session, gm.merchant.mob);
gm.merchant.session = session;
if !traded.is_empty() {
// The guest's own statistic and advancements.
self.as_guest(host, conn, |g| g.merchant_traded(gm.merchant.mob, &traded));
}
self.drop_guest_items(host, gi, dropped);
self.lan_send_offers(host, conn, &mut gm, false);
self.lan_send_merchant_content(host, conn, &mut gm);
+5
View File
@@ -104,6 +104,9 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if let Some(h) = holder {
if h == Holder::Player {
m.extra.leash_player = Some(player_uuid);
}
m.extra.leash = Some(h);
m.extra.leash_pending = None;
} else if m.age > 100 {
@@ -201,7 +204,9 @@ impl Game<'_> {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
self.mobs[index].extra.leash_player = Some(holder);
self.lead_sound("tied", at);
// The lead is always used up.
if let Some(stack) = self.held_mut().as_mut() {
+278 -16
View File
@@ -1,11 +1,14 @@
//! Level events whose client handler (`LevelEventHandler`) spawns particles
//! on a block: bone meal's growth (1505), dragon egg teleportation (2015), a
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
//! off (3004) and scraping (3005), plus the smoke, drips, egg and dragon
//! fireball particles of 1501, 1502, 1504, 2006, 2009, 2012 and 3009.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_core::{geometry::trig, position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
use horizon_world::registry::BlockStateId;
impl Game<'_> {
pub(super) fn dragon_egg_particles(&mut self, pos: [i32; 3], data: i32) {
@@ -33,7 +36,7 @@ impl Game<'_> {
// 3002 with an axis: a lightning rod; otherwise copper cleaned by lightning.
match usize::try_from(data) {
Ok(axis) if id == 3002 && axis < 3 => {
particles::along_axis(random, axis, p, |p, v| spawned.push((p, v)));
particles::along_axis(random, axis, p, (10, 19), |p, v| spawned.push((p, v)));
}
_ => particles::on_block_faces(random, p, |p, v| spawned.push((p, v))),
}
@@ -65,24 +68,42 @@ impl Game<'_> {
} else {
// ParticleUtils.spawnParticleInBlock: up to the
// block's shape (a full block when air).
let inside = self.world.block(at[0], at[1], at[2]);
let height = if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
};
(at, count, 0.5, height, true)
(at, count, 0.5, self.block_shape_top(at), true)
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
None => return,
};
let options = ParticleOptions::simple(ParticleType::HappyVillager);
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
}
/// `BlockState.getShape(..).max(Y)` of the block at `at`, 1.0 for air.
fn block_shape_top(&self, at: [i32; 3]) -> f64 {
let facts = self.res.facts();
let inside = self.world.block(at[0], at[1], at[2]);
if facts.is_air(inside) {
1.0
} else {
facts
.get(inside)
.outline
.iter()
.map(|b| b[4])
.fold(0.0, f64::max)
}
}
/// `ParticleUtils.spawnParticles`: `count` slow particles spread over
/// `xz` blocks around `at` and `height` up, only above solid ground
/// unless floating ones are allowed.
fn spawn_particles(
&mut self,
at: [i32; 3],
count: i32,
xz: f64,
height: f64,
floating: bool,
ty: ParticleType,
) {
let options = ParticleOptions::simple(ty);
for _ in 0..count.max(0) {
let r = self.particles.level_random();
let v = [0.0; 3].map(|_: f64| r.next_gaussian() * 0.02);
@@ -101,6 +122,161 @@ impl Game<'_> {
}
}
}
/// `LevelEventHandler` 2012 (a turtle egg laid on sand):
/// `ParticleUtils.spawnParticleInBlock` with happy villager sparks.
pub(super) fn turtle_egg_particles(&mut self, pos: BlockPos, count: i32) {
let at = [pos.x, pos.y, pos.z];
let height = self.block_shape_top(at);
self.spawn_particles(at, count, 0.5, height, true, ParticleType::HappyVillager);
}
/// `LevelEventHandler` 3009 (a sniffer egg cracking on moss):
/// `spawnParticlesOnBlockFaces(.., EGG_CRACK, UniformInt.of(3, 6))`.
pub(super) fn sniffer_egg_crack_particles(&mut self, pos: BlockPos) {
let mut spawned = Vec::new();
particles::on_block_faces_between(
self.particles.level_random(),
[pos.x, pos.y, pos.z],
3,
6,
|p, v| spawned.push((p, v)),
);
let options = ParticleOptions::simple(ParticleType::EggCrack);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LevelEventHandler` 1501 (lava put out) and 2009 (a wet sponge dried):
/// eight puffs just above the block, drawn after the sound.
pub(super) fn puffs_above_block(&mut self, pos: BlockPos, ty: ParticleType) {
let options = ParticleOptions::simple(ty);
for _ in 0..8 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double();
let z = f64::from(pos.z) + r.next_double();
self.particles
.add(&options, [x, f64::from(pos.y) + 1.2, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 1502 (a redstone torch burning out): five smoke
/// puffs inside the block, drawn after the sound.
pub(super) fn torch_burnout_smoke(&mut self, pos: BlockPos) {
let options = ParticleOptions::simple(ParticleType::Smoke);
for _ in 0..5 {
let r = self.particles.level_random();
let x = f64::from(pos.x) + r.next_double() * 0.6 + 0.2;
let y = f64::from(pos.y) + r.next_double() * 0.6 + 0.2;
let z = f64::from(pos.z) + r.next_double() * 0.6 + 0.2;
self.particles.add(&options, [x, y, z], [0.0; 3]);
}
}
/// `LevelEventHandler` 2006 (`DragonFireball.onHit`): 200 dragon breath
/// particles thrown outwards, then the explosion sound when `data` is 1.
pub(super) fn dragon_fireball_burst(&mut self, pos: BlockPos, data: i32) {
for _ in 0..200 {
let r = self.particles.level_random();
let dist = r.next_float() * 4.0;
let angle = r.next_float() * std::f32::consts::TAU;
let vx = f64::from(trig::cos(f64::from(angle)) * dist);
let vy = 0.01 + r.next_double() * 0.5;
let vz = f64::from(trig::sin(f64::from(angle)) * dist);
let options = ParticleOptions {
scale: dist,
..ParticleOptions::simple(ParticleType::DragonBreath)
};
self.particles.add(
&options,
[
f64::from(pos.x) + vx * 0.1,
f64::from(pos.y) + 0.3,
f64::from(pos.z) + vz * 0.1,
],
[vx, vy, vz],
);
}
if data == 1 {
let at = [pos.x, pos.y, pos.z].map(|v| f64::from(v) + 0.5);
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.dragon_fireball.explode",
Some(at),
1.0,
pitch,
);
}
}
/// `SpeleothemBlock.isStalactite`: a speleothem whose tip points down.
fn is_stalactite(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).has_class("SpeleothemBlock")
&& self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("vertical_direction"))
.is_some_and(|d| d == "down")
}
/// `LevelEventHandler` 1504: `PointedDripstoneBlock.spawnDripParticle` at
/// the tip `pos`. The fluid above the stalactite's root picks the drip;
/// with none, the dimension's default (lava in the Nether).
pub(super) fn dripstone_drip_particle(&mut self, pos: BlockPos) {
let tip_state = self.world.block(pos.x, pos.y, pos.z);
if !self.is_stalactite(tip_state) {
return;
}
// findRootBlock: up through the same stalactite, at most 11 steps.
let tip_name = self.res.facts().get(tip_state).name.clone();
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
break;
}
let state = self.world.block(pos.x, y, pos.z);
if self.res.facts().get(state).name != tip_name {
root = Some(y);
break;
}
if !self.is_stalactite(state) {
break;
}
}
let Some(root_y) = root else {
return;
};
let above = self.world.block(pos.x, root_y + 1, pos.z);
let above_facts = self.res.facts().get(above);
let nether = self.dim == super::Dim::Nether;
let fluid = if &*above_facts.name == "minecraft:mud" && !nether {
horizon_client::blocks::Fluid::Water
} else {
above_facts.fluid
};
let ty = match fluid {
horizon_client::blocks::Fluid::Water => ParticleType::DrippingDripstoneWater,
horizon_client::blocks::Fluid::Lava => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None if nether => ParticleType::DrippingDripstoneLava,
horizon_client::blocks::Fluid::None => ParticleType::DrippingDripstoneWater,
};
let offset = self
.pack
.context
.tables
.offset(tip_state, pos.x, pos.z)
.unwrap_or([0.0; 3]);
// The tip shape starts 5/16 up; the particle sits a pixel below that.
self.particles.add(
&ParticleOptions::simple(ty),
[
f64::from(pos.x) + 0.5 + offset[0],
f64::from(pos.y) + 5.0 / 16.0 - 0.0625,
f64::from(pos.z) + 0.5 + offset[2],
],
[0.0; 3],
);
}
/// `ServerLevel.globalLevelEvent`: heard by every player on the server
/// (an end portal opening, the dragon's death roar): here, and sent to
/// every guest whatever their dimension.
@@ -134,6 +310,92 @@ 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.
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
let ty = if instant {
ParticleType::InstantEffect
} else {
ParticleType::Effect
};
// Vec3.atBottomCenterOf.
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
];
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
let r = self.particles.level_random();
let crumbs: Vec<[f64; 3]> = (0..8)
.map(|_| {
[
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
]
})
.collect();
for v in crumbs {
self.particles.add(&crumb, at, v);
}
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
let color = [channel(16), channel(8), channel(0)];
for _ in 0..100 {
let r = self.particles.level_random();
let dist = r.next_double() * 4.0;
let angle = r.next_double() * std::f64::consts::PI * 2.0;
let velocity = [
angle.cos() * dist,
0.01 + r.next_double() * 0.5,
angle.sin() * dist,
];
let brightness = 0.75 + r.next_float() * 0.25;
let options = ParticleOptions {
color: [
color[0] * brightness,
color[1] * brightness,
color[2] * brightness,
1.0,
],
scale: dist as f32,
..ParticleOptions::simple(ty)
};
self.particles.add(
&options,
[
at[0] + velocity[0] * 0.1,
at[1] + 0.3,
at[2] + velocity[2] * 0.1,
],
velocity,
);
}
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+5 -1
View File
@@ -66,6 +66,8 @@ pub(super) struct Parked {
tnts: Vec<tnt::PrimedTnt>,
hoppers: HashMap<(i32, i32, i32), machines::HopperState>,
mobs: Vec<Mob>,
/// The entity ids of the level's fence knots (`knot_ids`).
knot_ids: HashMap<[i32; 3], u32>,
projectiles: Vec<horizon_client::projectiles::Projectile>,
dropped: Vec<ItemEntity>,
orbs: Vec<ExperienceOrb>,
@@ -75,7 +77,7 @@ pub(super) struct Parked {
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -166,6 +168,7 @@ impl Game<'_> {
tnts: Vec::new(),
hoppers: HashMap::new(),
mobs: Vec::new(),
knot_ids: HashMap::new(),
projectiles: Vec::new(),
dropped: Vec::new(),
orbs: Vec::new(),
@@ -219,6 +222,7 @@ impl Game<'_> {
swap(&mut self.tnts, &mut p.tnts);
swap(&mut self.hoppers, &mut p.hoppers);
swap(&mut self.mobs, &mut p.mobs);
swap(&mut self.knot_ids, &mut p.knot_ids);
swap(&mut self.creatures.projectiles, &mut p.projectiles);
swap(&mut self.dropped, &mut p.dropped);
swap(&mut self.orbs, &mut p.orbs);
+29 -1
View File
@@ -16,7 +16,7 @@ pub(super) struct Bolt {
flashes: i32,
seed: i64,
visual_only: bool,
/// Mooshrooms switch colour once per bolt.
/// Mooshrooms and copper golems change once per bolt.
converted: Vec<u32>,
}
impl Game<'_> {
@@ -278,6 +278,13 @@ impl Game<'_> {
}
self.strike_rod(b.at);
}
// LightningBolt.tick runs clearCopperOnLightningStrike for
// every server bolt, a skeleton trap's visual one included;
// a client's copy of a server bolt (spawn_remote_bolt) only
// flashes.
if self.remote.is_none() {
self.bolt_clear_copper(b.at);
}
}
b.life -= 1;
if b.life < 0 {
@@ -334,6 +341,16 @@ impl Game<'_> {
self.level_event_at(3002, p, axis);
}
}
/// `LightningBolt.clearCopperOnLightningStrike` at the block the bolt struck.
fn bolt_clear_copper(&mut self, at: [f64; 3]) {
let p = BlockPos::new(
at[0].floor() as i32,
(at[1] - 1.0e-6).floor() as i32,
at[2].floor() as i32,
);
self.with_level(|b, level, ticks| b.lightning_clear_copper(level, ticks, p));
let _ = self.process_level_events();
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
let at = b.at;
@@ -393,6 +410,17 @@ impl Game<'_> {
continue;
}
Species::Creeper => m.extra.charged = true,
// CopperGolem.thunderHit (after Entity.thunderHit's fire and
// damage): once per bolt, an oxidized golem goes back one step
// and its weathering timer (or wax, -2) is reset to -1.
Species::CopperGolem if !b.converted.contains(&m.id) => {
b.converted.push(m.id);
if m.extra.variant > 0 {
m.extra.vector[0] = -1.0;
m.extra.variant =
horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
}
}
_ => {}
}
// Entity.thunderHit: burning (8 seconds) and 5 lightning damage.
+6
View File
@@ -25,11 +25,13 @@ impl Game<'_> {
};
let m = &mut self.mobs[index];
let mut used = false;
let mut loved = false;
if item == "minecraft:hay_block" && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0
{
m.extra.in_love = 600;
m.extra.love_player = true;
used = true;
loved = true;
}
if m.health < m.attrs.max_health {
m.health = (m.health + heal).min(m.attrs.max_health);
@@ -57,6 +59,10 @@ impl Game<'_> {
if !used {
return false;
}
// Animal.setInLove: entity event 18.
if loved {
self.mob_event_particles(self.mobs[index].id, 18);
}
self.llama_sound(index, "entity.llama.eat", 1.0);
self.consume_held();
true
+112 -40
View File
@@ -626,46 +626,8 @@ impl Game<'_> {
/// `HopperBlockEntity.suckInItems`: from the container above, or item entities.
fn hopper_suck(&mut self, pos: Pos, hopper: &View) -> bool {
let above = (pos.0, pos.1 + 1, pos.2);
let c = self.containers();
if let Some(source) = c.view(above, false) {
if let Kind::Composter(level) = source.kind {
if level != 8 {
return false;
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return moved;
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return true;
}
Some(rest) => self.put_back(&source, slot, rest),
}
}
return false;
if let Some(took) = self.suck_from_container(above, hopper) {
return took;
}
let above_state = self.world.block(above.0, above.1, above.2);
let blocked = self.res.facts().get(above_state).full_collision
@@ -695,6 +657,56 @@ impl Game<'_> {
{
return false;
}
self.suck_item_entities(hopper, min, max)
}
/// The container half of `HopperBlockEntity.suckInItems`: one item from the
/// container at `above`, through its bottom face. `None` when there is no
/// container there.
fn suck_from_container(&mut self, above: Pos, hopper: &View) -> Option<bool> {
let c = self.containers();
let source = c.view(above, false)?;
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
}
// tryTakeInItemFromSlot on the OutputContainer: removeItem calls
// its setChanged, which empties the composter, and so does the
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
self.world.modified.insert((above.0 >> 4, above.2 >> 4));
return Some(moved);
}
let source_hopper = (source.kind == Kind::Hopper).then_some(above);
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(&source, slot) else {
continue;
};
if !c.can_take(&source, slot, stack, Direction::Down) {
continue;
}
let Some(one) = self.take_one(&source, slot) else {
continue;
};
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(&source);
return Some(true);
}
Some(rest) => self.put_back(&source, slot, rest),
}
}
Some(false)
}
/// The item half of `HopperBlockEntity.suckInItems` (`getItemsAtAndAbove`):
/// adds the item entities that meet the box, until one is taken whole.
fn suck_item_entities(&mut self, hopper: &View, min: [f64; 3], max: [f64; 3]) -> bool {
let mut i = 0;
while i < self.dropped.len() {
let b = self.dropped[i].bounds();
@@ -719,6 +731,66 @@ impl Game<'_> {
}
false
}
/// `MinecartHopper.tick`'s `tryConsumeItems` (`suckInItems`) for an enabled
/// hopper minecart: one item from the container above, else the item
/// entities over it or touching it, each tick without a cooldown.
pub(super) fn hopper_cart_suck(&mut self, cart: &mut super::minecarts::Minecart) {
// The cart's slots are edited as a container under the placeholder key
// its menu uses; when that menu is open the placeholder is the live copy.
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, cart.id as i32);
let open = self.world.block_entities.contains_key(&key);
if !open {
let items = BlockEntity::Chest(std::mem::take(&mut cart.items));
self.world.block_entities.insert(key, items);
}
let size = self
.world
.block_entities
.get(&key)
.map_or(0, |e| e.slots().len());
let view = View {
parts: vec![(key, size)],
kind: Kind::Generic,
};
let p = cart.position;
// getLevelY is the cart's y + 0.5, so the container is the block at
// y + 1.5, and Hopper.SUCK_AABB (moved by getLevel - 0.5) starts 11/16
// above the cart.
let above = (
p.x.floor() as i32,
(p.y + 1.5).floor() as i32,
p.z.floor() as i32,
);
let took = match self.suck_from_container(above, &view) {
Some(took) => took,
None => self.suck_item_entities(
&view,
[p.x - 0.5, p.y + 11.0 / 16.0, p.z - 0.5],
[p.x + 0.5, p.y + 2.0, p.z + 0.5],
),
};
if !took {
// The cart's own box, inflated by 0.25 sideways.
let b = cart.bounds();
let (lo, hi) = (b.min_position(), b.max_position());
self.suck_item_entities(
&view,
[lo.x - 0.25, lo.y, lo.z - 0.25],
[hi.x + 0.25, hi.y, hi.z + 0.25],
);
}
// The cart's own items stay the one source of truth: a menu copy that
// is still around (open, or left by a guest's close) gets the pulled
// items back into `cart.items`, so reopening never restores a stale copy.
let slots = if open {
self.world.block_entities.get(&key).cloned()
} else {
self.world.block_entities.remove(&key)
};
if let Some(BlockEntity::Chest(items)) = slots {
cart.items = items;
}
}
// ---- dispensers and droppers ----
+64 -70
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::ItemStack,
items::{ItemStack, Items},
mesher::Vertex,
mobs::{Part, anim::Poses},
particles::{ParticleOptions, ParticleType},
@@ -198,6 +198,17 @@ impl Minecart {
fn horizontal(&self) -> f64 {
self.velocity.x.hypot(self.velocity.z)
}
/// `AbstractContainerMenu.getRedstoneSignalFromContainer` of `slots`.
fn container_signal(slots: &[Option<ItemStack>], items: &Items) -> i32 {
let mut total = 0.0f32;
for s in slots.iter().flatten() {
let max = items.get(&s.item).map_or(64, |i| i.max_stack).min(64);
total += s.count as f32 / max as f32;
}
total /= slots.len().max(1) as f32;
// Mth.lerpDiscrete(total, 0, 15)
(total * 14.0).floor() as i32 + i32::from(total > 0.0)
}
}
pub(super) struct CartModels {
pub models: HashMap<CartKind, Part>,
@@ -543,7 +554,6 @@ impl Game<'_> {
// applyGravity
c.velocity.y -= if self.cart_in_water(c) { 0.005 } else { 0.04 };
let pos = self.rail_block(c.position);
let fall_speed = c.velocity;
if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
self.move_along_track(c, pos, &rail, intent);
if &*rail.name == "minecraft:activator_rail" {
@@ -585,22 +595,26 @@ impl Game<'_> {
}
}
CartKind::Tnt => {
let speed = fall_speed.x * fall_speed.x + fall_speed.z * fall_speed.z;
// MinecartTNT.tick: the fuse and the collision check are separate.
if c.fuse > 0 {
c.fuse -= 1;
let at = [c.position.x, c.position.y + 0.5, c.position.z];
self.particles
.add(&ParticleOptions::simple(ParticleType::Smoke), at, [0.0; 3]);
} else if c.fuse == 0 || (c.horizontal_collision && speed >= 0.01) {
} else if c.fuse == 0 {
// explode(horizontalDistanceSqr()): a rising cart's
// vertical speed adds nothing.
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
let speed = if c.fuse == 0 && c.velocity.y > 0.0 {
c.velocity.y * c.velocity.y
} else {
speed
};
self.explode_cart(c, speed);
return false;
}
if c.horizontal_collision {
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
if speed >= f64::from(0.01_f32) {
self.explode_cart(c, speed);
return false;
}
}
}
_ => {}
}
@@ -644,51 +658,9 @@ impl Game<'_> {
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
self.carts.retain(|x| x.id != c.id);
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
}
/// `MinecartHopper.suckInItems`: items above, into the five slots.
fn hopper_cart_pickup(&mut self, c: &mut Minecart) {
if !c.enabled {
return;
}
let p = c.position;
let zone = Aabb::new(
Vec3::new(p.x - 0.5, p.y - 0.5 + 0.0, p.z - 0.5),
Vec3::new(p.x + 0.5, p.y + 1.5, p.z + 0.5),
);
let mut i = 0;
while i < self.dropped.len() {
let e = &self.dropped[i];
if e.pickup_delay > 0 || !zone.contains(e.position) {
i += 1;
continue;
}
let mut stack = e.stack.clone();
for slot in c.items.iter_mut() {
match slot {
None => {
*slot = Some(stack.clone());
stack.count = 0;
}
Some(s) if s.same_item_same_components(&stack) => {
let max = self.res.items().get(&s.item).map_or(64, |i| i.max_stack);
let moved = stack.count.min(max.saturating_sub(s.count));
s.count += moved;
stack.count -= moved;
}
_ => {}
}
if stack.count == 0 {
break;
}
}
if stack.count == 0 {
self.dropped.remove(i);
} else {
self.dropped[i].stack = stack;
i += 1;
}
}
let _ = self.with_hive_emptying_blast(|g| {
g.server_explode(at, power, BlockInteraction::Destroy, false)
});
}
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
@@ -827,8 +799,8 @@ impl Game<'_> {
}
let mut c = self.carts[i].clone();
let alive = self.tick_cart(&mut c, [0.0; 2]);
if c.kind == CartKind::Hopper {
self.hopper_cart_pickup(&mut c);
if c.kind == CartKind::Hopper && c.enabled {
self.hopper_cart_suck(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
@@ -946,7 +918,7 @@ impl Game<'_> {
}
true
}
/// `getDismountLocationForPassenger`: beside the cart (across its motion), else on top.
/// `getDismountLocationForPassenger`: beside the cart (across its facing), else on top.
pub(super) fn dismount_cart(&mut self, i: usize) {
self.riding = None;
let c = &self.carts[i];
@@ -956,17 +928,25 @@ impl Game<'_> {
c.position.z.floor() as i32,
);
let mut target = None;
// DismountHelper.offsetsForDirection of the cart's facing
// (Direction.fromYRot of its yaw): right, left, back right, back left,
// forward right, forward left, back, forward.
let step = |q: i32| [(0, 1), (-1, 0), (0, -1), (1, 0)][(q & 3) as usize];
let facing = (f64::from(c.yaw) / 90.0 + 0.5).floor() as i32;
let (fx, fz) = step(facing);
let (rx, rz) = (-fz, fx);
let offsets = [
(rx, rz),
(-rx, -rz),
(rx - fx, rz - fz),
(-rx - fx, -rz - fz),
(fx + rx, fz + rz),
(fx - rx, fz - rz),
(-fx, -fz),
(fx, fz),
];
'search: for dy in [0, 1, -1] {
for (dx, dz) in [
(1, 0),
(-1, 0),
(0, 1),
(0, -1),
(1, 1),
(-1, -1),
(1, -1),
(-1, 1),
] {
for (dx, dz) in offsets {
let (x, y, z) = (bx + dx, by + dy, bz + dz);
let below = self.res.facts().get(self.world.block(x, y - 1, z));
let here = self.res.facts().get(self.world.block(x, y, z));
@@ -1050,6 +1030,22 @@ impl Game<'_> {
}
best
}
/// Each cart's kind and comparator signal, for the detector rail
/// (`DetectorRailBlock.getAnalogOutputSignal`). A cart whose menu is open
/// counts the items in that menu.
pub(super) fn cart_signals(&self) -> Vec<(CartKind, i32)> {
self.carts
.iter()
.map(|c| {
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
let slots = match self.world.block_entities.get(&key) {
Some(BlockEntity::Chest(open)) => open,
_ => &c.items,
};
(c.kind, Minecart::container_signal(slots, self.res.items()))
})
.collect()
}
/// `interact`: ride, open, fuel or ignite the cart under the crosshair.
pub(super) fn interact_cart(&mut self) -> bool {
let Some((i, d)) = self.cart_hit() else {
@@ -1067,10 +1063,8 @@ impl Game<'_> {
}
self.riding = Some(self.carts[i].id);
}
// AbstractMinecartContainer.interact: no sneak check.
CartKind::Chest | CartKind::Hopper => {
if sneaking {
return false;
}
let c = self.carts[i].clone();
let key = (i32::MAX - 15, CART_KEY_Y, c.id as i32);
self.world
+73 -1
View File
@@ -1,6 +1,7 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items.
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
items::ItemStack,
@@ -218,6 +219,7 @@ impl Game<'_> {
) {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
@@ -244,6 +246,76 @@ impl Game<'_> {
);
}
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
fn worn_block_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
let species = mob.species();
let on_head = match species {
Species::SnowGolem => !mob.extra.sheared,
Species::Mooshroom => !mob.is_baby(),
_ => false,
};
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
return;
}
let registry = &self.pack.assets.registry;
let block = match species {
Species::SnowGolem => "minecraft:carved_pumpkin",
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
_ => "minecraft:red_mushroom",
};
let Some(state) = registry.default_state(block) else {
return;
};
let alpha = alpha as f32;
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
else {
return;
};
let unit = Mat4::from_translation(Vec3::splat(-0.5));
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
let transforms = if species == Species::SnowGolem {
vec![
head * at(0.0, -0.34375, 0.0)
* spin(180.0)
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
* unit,
]
} else {
let base = render::base_transform(mob, alpha, camera);
vec![
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
base * at(0.2, -0.35, 0.5)
* spin(42.0)
* at(0.1, 0.0, -0.6)
* spin(-48.0)
* flip
* unit,
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
]
};
// The hurt tint reaches these blocks as it does the body.
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
for t in transforms {
let start = out.len();
self.block_model_vertices(state, 0, t, light, true, out);
for vertex in &mut out[start..] {
vertex.layer = overlay;
}
}
}
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
+15 -6
View File
@@ -215,10 +215,12 @@ impl Game<'_> {
return false;
}
let mut used = false;
let mut loved = false;
if love && m.extra.tame && m.extra.age == 0 && m.extra.in_love <= 0 {
m.extra.in_love = 600;
m.extra.love_player = true;
used = true;
loved = true;
}
if m.health < m.attrs.max_health && heal > 0.0 {
m.health = (m.health + heal).min(m.attrs.max_health);
@@ -238,6 +240,7 @@ impl Game<'_> {
// eating(): the mouth opens and the horse eats.
m.anim.v[2] = 1.0;
let at = m.center();
let id = m.id;
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
@@ -247,6 +250,10 @@ impl Game<'_> {
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
// Animal.setInLove: entity event 18.
if loved {
self.mob_event_particles(id, 18);
}
self.consume_held();
true
}
@@ -452,14 +459,16 @@ impl Game<'_> {
m.extra.owner = owner;
m.persistent = true;
// AbstractHorse.tameWithName: entity event 7, spawnTamingParticles(true).
self.particles_around_mob(
mob,
"heart",
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(mob, 7);
}
}
MobEvent::Buck { mob } if self.riding == Some(mob) => self.dismount_mount(),
MobEvent::Buck { mob } => {
if self.riding == Some(mob) {
self.dismount_mount();
}
// RunAroundLikeCrazyGoal.tick: entity event 6, smoke.
self.mob_event_particles(mob, 6);
}
_ => {}
}
}
+3 -6
View File
@@ -85,13 +85,8 @@ impl Game<'_> {
m.ai.target = None;
}
let sound = nautilus::eat_sound(m);
let kind = if tamed { "heart" } else { "smoke" };
// TamableAnimal.spawnTamingParticles (entity event 7 or 6).
self.particles_around_mob(
self.mobs[index].id,
kind,
horizon_client::particles::Burst::TAMING,
);
self.mob_event_particles(self.mobs[index].id, if tamed { 7 } else { 6 });
self.nautilus_sound(index, sound);
if tamed {
let view = self.mob_entity(&self.mobs[index]);
@@ -145,6 +140,8 @@ impl Game<'_> {
let sound = nautilus::eat_sound(m);
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
// Animal.setInLove: entity event 18.
self.mob_event_particles(self.mobs[index].id, 18);
return true;
}
false
+11 -2
View File
@@ -218,11 +218,20 @@ impl Game<'_> {
_ => None,
})
});
// A living mount at the default scale and camera distance.
// Camera.setup: the living mount's scale and camera distance
// attributes (the happy ghast's is 8, the default 4).
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|_| (1.0, 4.0));
.map(|m| {
let attribute = |name: &str, default: f64| {
m.kind.attributes.get(name).copied().unwrap_or(default)
};
(
attribute("minecraft:scale", 1.0) as f32,
attribute("minecraft:camera_distance", 4.0),
)
});
// AbstractBoat.positionRider turns a passenger by the boat's
// deltaRotation after its yRotO was saved, and LocalPlayer.getViewYRot
// interpolates a passenger's yaw: the boat's turn is spread over the
+524 -86
View File
@@ -2,7 +2,12 @@
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
//! filling glass bottles (`BottleItem`), the per-tick effect application,
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
use super::{Game, Screen, item_draw::owner_at};
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -12,8 +17,13 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -26,6 +36,38 @@ pub(super) struct ThrownPotion {
position: Vec3,
velocity: Vec3,
age: u32,
/// `Projectile.getOwner`'s display name: the player who threw it (none
/// for a dispenser's).
owner: Option<Text>,
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
enum Victim {
Mob(u32),
Player(FightWho),
}
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
/// (at the lingering item's 0.25 duration scale) and who it has affected.
pub(super) struct PotionCloud {
effects: &'static [(&'static str, i32, i32)],
/// The potion's colour (`PotionContents.getColor`).
color: u32,
/// Each victim and the tick it can be affected again.
victims: Vec<(Victim, i32)>,
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
pub(super) fn particle(&self) -> ParticleOptions {
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
@@ -44,10 +86,212 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
.get(id)
.copied()
}
/// `LivingEntity.canBeAffected` with the mob overrides (`WitherBoss` and
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
/// decide first, then the tag of mobs that ignore poison and regeneration.
fn mob_can_be_affected(
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
kind: &str,
effect: &str,
) -> bool {
let effect = effect.trim_start_matches("minecraft:");
let kind = kind.trim_start_matches("minecraft:");
let overridden = match kind {
"wither" | "wither_skeleton" => effect == "wither",
"spider" | "cave_spider" | "nautilus" | "zombie_nautilus" => effect == "poison",
"parched" => effect == "weakness",
_ => false,
};
if overridden {
return false;
}
let in_tag = |tag: &str| {
tags.get(tag)
.is_some_and(|t| t.contains(&format!("minecraft:{kind}")))
};
if in_tag("minecraft:immune_to_infested") {
effect != "infested"
} else if in_tag("minecraft:immune_to_oozing") {
effect != "oozing"
} else {
!in_tag("minecraft:ignores_poison_and_regen")
|| (effect != "regeneration" && effect != "poison")
}
}
/// `ThrownSplashPotion.onHitAsPotion`: the squared distance from the potion's
/// box at the hit point to an entity's box grown by the hit margin
/// (`ProjectileUtil.computeMargin`), for entities that meet the potion's box
/// grown by (4, 2, 4); none when it is 16 or more.
fn splash_distance_sqr(at: Vec3, age: u32, bounds: Aabb) -> Option<f64> {
let potion = Aabb::new(
Vec3::new(at.x - 0.125, at.y, at.z - 0.125),
Vec3::new(at.x + 0.125, at.y + 0.25, at.z + 0.125),
);
if !potion.inflate(Vec3::new(4.0, 2.0, 4.0)).intersects(bounds) {
return None;
}
let margin = ((f64::from(age) - 2.0) / 20.0).clamp(0.0, 0.3);
let d2 = potion.distance_squared_to_box(bounds.inflate(Vec3::new(margin, margin, margin)));
(d2 < 16.0).then_some(d2)
}
impl Game<'_> {
/// `LivingEntity.triggerOnDeathMobEffects` (`onMobRemoved` with the reason
/// KILLED) for what Oozing, Weaving and Wind Charged do when their bearer
/// dies. `bearer` is the dying mob's id, `player` whether it is a player,
/// `random` the dying mob's own (`mob.getRandom()`; the world's for the
/// player).
pub(super) fn effects_on_death(
&mut self,
effects: &[&str],
bounds: Aabb,
player: bool,
bearer: Option<u32>,
mut random: Option<&mut LegacyRandom>,
) {
let feet = bounds.bottom_center();
if effects.contains(&"oozing") {
// OozingMobEffect.numberOfSlimesToSpawn: two, fewer when the
// slimes close by already fill the cramming limit.
let limit = self.ui.rules.int("max_entity_cramming");
let mut count = 2;
if limit >= 1 {
let near = bounds.inflate(Vec3::new(2.0, 2.0, 2.0));
let nearby = self
.mobs
.iter()
.filter(|m| {
m.species() == horizon_client::mobs::Species::Slime
&& Some(m.id) != bearer
&& m.bounds().intersects(near)
})
.count()
.min(limit as usize) as i32;
count = (limit - nearby).clamp(0, count);
}
for _ in 0..count {
// spawnSlimeOffspring: a size 2 slime half a block up.
if let Some(id) = self.spawn_mob_for(
"minecraft:slime",
[feet.x, feet.y + 0.5, feet.z],
false,
true,
) && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
horizon_client::mobs::cube::set_size(m, 2);
}
}
}
if effects.contains(&"weaving") && (player || self.ui.rules.bool("mob_griefing")) {
self.spawn_cobwebs_around(
[
feet.x.floor() as i32,
feet.y.floor() as i32,
feet.z.floor() as i32,
],
random.as_deref_mut(),
);
}
if effects.contains(&"wind_charged") {
// A burst centred at half the height, radius 3 to 5.
let center = [feet.x, feet.y + bounds.size().y / 2.0, feet.z];
let r = random.unwrap_or(&mut self.world.random);
let radius = 3.0 + r.next_float() * 2.0;
let _ = self.wind_burst(center, radius, 1.0, false, false);
}
}
/// `WeavingMobEffect.spawnCobwebsRandomlyAround`: two or three cobwebs in
/// free spaces with a solid floor, picked among 15 random blocks within one
/// block of `pos`.
fn spawn_cobwebs_around(&mut self, pos: [i32; 3], mut random: Option<&mut LegacyRandom>) {
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
let wanted = 2 + r.next_int_bound(2).unwrap_or(0) as usize;
let mut chosen: Vec<[i32; 3]> = Vec::new();
for _ in 0..15 {
let r = random.as_deref_mut().unwrap_or(&mut self.world.random);
let at = [
pos[0] - 1 + r.next_int_bound(3).unwrap_or(0),
pos[1] - 1 + r.next_int_bound(3).unwrap_or(0),
pos[2] - 1 + r.next_int_bound(3).unwrap_or(0),
];
let facts = self.res.facts();
let here = facts.get(self.world.block(at[0], at[1], at[2]));
let below = facts.get(self.world.block(at[0], at[1] - 1, at[2]));
if !chosen.contains(&at)
&& here.replaceable
&& below.sturdy_full & (1 << Direction::Up as u8) != 0
{
chosen.push(at);
if chosen.len() >= wanted {
break;
}
}
}
for at in chosen {
if self.set_state_named(at, "minecraft:cobweb", &[]).is_ok() {
self.level_event_at(3018, at, 0);
}
}
}
/// `InfestedMobEffect.onMobHurt` for the player: from half its height up,
/// along where it looks.
pub(super) fn player_infested(&mut self) {
if let Some(p) = self.player.as_ref() {
let at = [
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
];
let look = super::view_vector(self.yaw, self.pitch);
self.infested_hurt(at, look, None);
}
}
/// A living mob's own random (`Entity.random`), else the world's.
fn bearer_random(&mut self, bearer: Option<u32>) -> &mut LegacyRandom {
match bearer.and_then(|id| self.mobs.iter().position(|m| m.id == id)) {
Some(i) => &mut self.mobs[i].random,
None => &mut self.world.random,
}
}
/// `InfestedMobEffect.onMobHurt`: a 10% chance of one or two silverfish at
/// `at` (half the bearer's height up), thrown along its look direction.
/// The draws are the bearer's (`mob.getRandom()`): `bearer` is the mob,
/// none for the player (the world's random).
pub(super) fn infested_hurt(&mut self, at: [f64; 3], look: [f64; 3], bearer: Option<u32>) {
if self.bearer_random(bearer).next_float() > 0.1 {
return;
}
let count = 1 + self.bearer_random(bearer).next_int_bound(2).unwrap_or(0);
for _ in 0..count {
// Mth.randomBetween(-PI / 2, PI / 2), then the look direction
// scaled by (0.3, 0.45, 0.3) and turned about the y axis.
let half = std::f32::consts::FRAC_PI_2;
let angle = self.bearer_random(bearer).next_float() * (2.0 * half) - half;
let (sin, cos) = angle.sin_cos();
let (x, z) = (look[0] * 0.3, look[2] * 0.3);
let velocity = Vec3::new(
f64::from(x as f32 * cos + z as f32 * sin),
look[1] * 0.3 * 1.5,
f64::from(-x as f32 * sin + z as f32 * cos),
);
if let Some(id) = self.spawn_mob_for("minecraft:silverfish", at, false, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.velocity = velocity;
}
self.audio
.play("minecraft:entity.silverfish.hurt", Some(at), 1.0, 1.0);
}
}
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
pub(super) fn launch_thrown(
&mut self,
stack: ItemStack,
position: Vec3,
velocity: Vec3,
owner: Option<Text>,
) {
self.potions.thrown.push(ThrownPotion {
id: 0,
stack,
@@ -55,6 +299,7 @@ impl Game<'_> {
position,
velocity,
age: 0,
owner,
});
}
}
@@ -135,6 +380,7 @@ impl PotionState {
position: at,
velocity,
age: 0,
owner: None,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
@@ -237,6 +483,7 @@ impl Game<'_> {
},
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
v,
Some(self.player_display_text()),
);
self.swing_arm();
self.consume_held();
@@ -377,6 +624,7 @@ impl Game<'_> {
self.tick_mob_effects();
if running {
self.tick_thrown_potions();
self.tick_potion_clouds();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
@@ -387,13 +635,37 @@ impl Game<'_> {
return;
};
if player.health.dead_or_dying() {
// LivingEntity.die → removeAllEffects.
// LivingEntity.die → removeAllEffects. Vanilla runs the effects
// that act on death (`onMobRemoved`) when the dead player is
// removed, up to 20 ticks later; here it is as the effects go.
let w = f64::from(player.dimensions.width) / 2.0;
let bounds = Aabb::new(
Vec3::new(
player.position.x - w,
player.position.y,
player.position.z - w,
),
Vec3::new(
player.position.x + w,
player.position.y + f64::from(player.dimensions.height),
player.position.z + w,
),
);
let ids: Vec<&'static str> = self.potions.effects.list.iter().map(|e| e.id).collect();
self.potions.effects.clear();
if !ids.is_empty() {
self.effects_on_death(&ids, bounds, true, None, None);
}
}
// The omens act from inside the effect tick (BadOmenMobEffect,
// RaidOmenMobEffect).
let absorption_empty = self
.player
.as_ref()
.is_some_and(|p| p.health.absorption() <= 0.0);
let mut effects = std::mem::take(&mut self.potions.effects);
let (actions, raid_omen_added) = effects.tick_with(&mut |e| self.omen_tick(e));
let (actions, raid_omen_added) =
effects.tick_with(&mut |e| self.omen_tick(e), absorption_empty);
self.potions.effects = effects;
if raid_omen_added {
self.raid_omen_added();
@@ -534,29 +806,176 @@ impl Game<'_> {
let state =
self.world
.block(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
if self.res.facts().get(state).full_collision
|| self.mobs.iter().any(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
})
{
hit = Some(p);
let mob = self.mobs.iter().find(|m| {
m.alive()
&& (m.position.x - p.x).abs() < m.kind.width / 2.0 + 0.125
&& (m.position.z - p.z).abs() < m.kind.width / 2.0 + 0.125
&& p.y >= m.position.y
&& p.y <= m.position.y + m.kind.height
});
if self.res.facts().get(state).full_collision || mob.is_some() {
hit = Some((p, mob.map(|m| m.position)));
break;
}
}
if let Some(p) = hit {
landed.push((t.stack.clone(), p));
if let Some((p, mob)) = hit {
landed.push((t.stack.clone(), p, mob, t.age, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at) in landed {
self.splash(&stack, at);
for (stack, at, mob, age, owner) in landed {
self.splash(&stack, at, mob, age, owner);
}
}
/// `AreaEffectCloud.tick` for the lingering potions' clouds: the client
/// tick's particles, then the server tick.
fn tick_potion_clouds(&mut self) {
if self.breath_clouds.iter().all(|c| c.potion.is_none()) {
return;
}
let players: Vec<FightPlayer> = self
.fight_players()
.into_iter()
.filter(|p| p.alive)
.collect();
let mut clouds = std::mem::take(&mut self.breath_clouds);
clouds.retain_mut(|c| c.potion.is_none() || self.tick_potion_cloud(c, &players));
self.breath_clouds.append(&mut clouds);
}
/// `AreaEffectCloud.serverTick` for a lingering potion's cloud: after its
/// wait it shrinks, and every 5 ticks it affects the living entities in
/// its box and radius that were not affected in the last 20 ticks, each
/// taking some of its radius. False once it is gone.
fn tick_potion_cloud(&mut self, c: &mut Breath, players: &[FightPlayer]) -> bool {
c.age += 1;
let Some(potion) = c.potion.as_mut() else {
return true;
};
if c.age - LINGERING_WAIT >= c.duration {
return false;
}
let waiting = c.age < LINGERING_WAIT;
cloud_client_tick(
&mut self.particles,
c.at,
c.radius,
waiting,
&potion.particle(),
);
if waiting {
return true;
}
// setRadiusPerTick(-radius / duration).
c.radius += -3.0 / 600.0;
if c.radius < 0.5 {
return false;
}
if c.age % 5 != 0 {
return true;
}
potion.victims.retain(|(_, until)| c.age < *until);
let r = f64::from(c.radius);
let area = Aabb::new(
Vec3::new(c.at[0] - r, c.at[1], c.at[2] - r),
Vec3::new(c.at[0] + r, c.at[1] + 0.5, c.at[2] + r),
);
let mut inside = Vec::new();
for p in players.iter().filter(|p| p.bounds.intersects(area)) {
inside.push((Victim::Player(p.who), p.position.x, p.position.z));
}
let effects = potion.effects;
let tags = &self.res.stations().enchantments.entity_tags;
// Only mobs that at least one of the cloud's effects can affect are
// taken (`allEffects.stream().anyMatch(entity::canBeAffected)`), so
// the others neither become victims nor use up its radius.
for m in self.mobs.iter().filter(|m| {
m.alive()
&& m.bounds().intersects(area)
&& effects
.iter()
.any(|(id, ..)| mob_can_be_affected(tags, &m.kind.id, id))
}) {
inside.push((Victim::Mob(m.id), m.position.x, m.position.z));
}
for (victim, x, z) in inside {
let (dx, dz) = (x - c.at[0], z - c.at[2]);
if potion.victims.iter().any(|(v, _)| *v == victim)
|| dx * dx + dz * dz > f64::from(c.radius * c.radius)
{
continue;
}
potion.victims.push((victim, c.age + REAPPLICATION_DELAY));
self.cloud_affect(victim, effects, c.at, &potion.source);
// setRadiusOnUse(-0.5).
c.radius -= 0.5;
if c.radius < 0.5 {
return false;
}
}
true
}
/// What a cloud puts on one entity: instant effects at half strength
/// (`applyInstantaneousEffect` with 0.5), the others for their duration
/// at the lingering item's 0.25 scale (`PotionContents.forEachEffect`).
fn cloud_affect(
&mut self,
victim: Victim,
effects: &'static [(&'static str, i32, i32)],
at: [f64; 3],
source: &Text,
) {
let scaled = |duration: i32| ((f64::from(duration) * 0.25) as i32).max(1);
match victim {
Victim::Player(who) => self.as_fight_player(who, |g| {
for (id, duration, amp) in effects {
let Some(e) = brewing::effect(id) else {
continue;
};
if e.instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, 0.5) {
g.apply_indirect_effect_action(a, source);
}
} else {
g.add_player_effect(EffectInstance::new(e.id, scaled(*duration), *amp));
}
}
}),
Victim::Mob(mob) => {
let Some(i) = self.mobs.iter().position(|m| m.id == mob) else {
return;
};
// Undead invert healing and harming.
let tags = &self.res.stations().enchantments.entity_tags;
let inverted = tags
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&self.mobs[i].kind.id));
for (id, duration, amp) in effects {
let m = &mut self.mobs[i];
match *id {
"instant_damage" | "instant_health" => {
let amount =
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
if (*id == "instant_damage") != inverted {
m.hurt(amount(6), Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
}
}
_ => {
// `LivingEntity.addEffect` refuses what the mob
// cannot be affected by.
if mob_can_be_affected(tags, &m.kind.id, id)
&& let Some(name) = mob_effect_id(id)
{
m.add_effect(name, scaled(*duration), *amp);
}
}
}
}
}
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -658,31 +1077,46 @@ impl Game<'_> {
self.submit_item_world(&state, pose, vertex_light(light), out);
}
}
/// `HealOrHarmMobEffect.applyInstantaneousEffect` with a source (a
/// potion or a cloud): indirectMagic, not plain magic.
fn apply_indirect_effect_action(&mut self, a: EffectAction, source: &Text) {
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity("minecraft:indirect_magic", source.clone());
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
/// A splash potion's effects on the player in place, by their distance.
fn splash_on_player(
&mut self,
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
age: u32,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
let center = Vec3::new(
p.position.x,
p.position.y + f64::from(p.dimensions.height) / 2.0,
p.position.z,
let w = f64::from(p.dimensions.width) / 2.0;
let bounds = Aabb::new(
Vec3::new(p.position.x - w, p.position.y, p.position.z - w),
Vec3::new(
p.position.x + w,
p.position.y + f64::from(p.dimensions.height),
p.position.z + w,
),
);
let d2 =
(center.x - at.x).powi(2) + (center.y - at.y).powi(2) + (center.z - at.z).powi(2);
if d2 < 16.0 && (center.y - at.y).abs() <= 2.0 + f64::from(p.dimensions.height) {
if let Some(d2) = splash_distance_sqr(at, age, bounds) {
let intensity = 1.0 - d2.sqrt() / 4.0;
for (id, duration, amp) in effects {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
self.apply_effect_action(a);
self.apply_indirect_effect_action(a, source);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -693,77 +1127,81 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob, `age` its age.
fn splash(
&mut self,
stack: &ItemStack,
at: Vec3,
mob: Option<Vec3>,
age: u32,
owner: Option<Text>,
) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
// event 2002) and 3 to 11 experience.
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.splash_potion.break",
Some([at.x, at.y, at.z]),
1.0,
pitch,
);
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
0x38_5DC6,
);
self.particles.push(p);
}
// event 2002, its sound 1053) and 3 to 11 experience.
self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32);
self.level_event_at(1053, block, 0);
let r = &mut self.world.random;
let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0);
self.spawn_orbs([at.x, at.y, at.z], value as u32);
return;
}
let lingering = &*stack.item == "minecraft:lingering_potion";
let event = if lingering {
"minecraft:entity.lingering_potion.break"
} else {
"minecraft:entity.splash_potion.break"
};
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch);
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
let potion = stack.potion().and_then(brewing::potion);
let instant = potion.is_some_and(|d| {
d.effects
.iter()
.any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant))
});
let color = brewing::potion_color(stack.potion());
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
color,
);
self.particles.push(p);
}
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = stack.potion().and_then(brewing::potion) else {
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
let Some(def) = potion else {
return;
};
if lingering {
// The cloud sits at the mob it hit (its feet), else the impact;
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
self.breath_clouds.push(Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
duration: 600,
from_dragon: false,
potion: Some(PotionCloud {
effects: def.effects,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
});
}
return;
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, age, &source));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
for (i, m) in self.mobs.iter().enumerate() {
let d2 = (m.position.x - at.x).powi(2)
+ (m.position.y + m.kind.height / 2.0 - at.y).powi(2)
+ (m.position.z - at.z).powi(2);
if m.alive() && d2 < 16.0 {
if m.alive()
&& let Some(d2) = splash_distance_sqr(at, age, m.bounds())
{
let inverted = undead
.get("minecraft:inverted_healing_and_harm")
.is_some_and(|t| t.contains(&m.kind.id));
@@ -778,7 +1216,7 @@ impl Game<'_> {
_ => {
// `Potion.applySplash`: timed effects last in
// proportion to how close the mob was.
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(name) = mob_effect_id(id)
{
@@ -790,12 +1228,12 @@ impl Game<'_> {
let m = &mut self.mobs[i];
if harm {
m.hurt(
(intensity * f64::from(6 << amp)) as i32 as f32,
(intensity * f64::from(6 << amp) + 0.5) as i32 as f32,
Some([at.x, at.y, at.z]),
&mut self.world.random,
);
} else {
m.health = (m.health + (intensity * f64::from(4 << amp)) as i32 as f32)
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
.min(m.kind.max_health);
}
}
+179 -48
View File
@@ -117,7 +117,7 @@ pub(super) struct Remote {
joined: bool,
status: String,
pub(super) entity_id: i32,
dimension: DimensionType,
pub(super) dimension: DimensionType,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
@@ -156,6 +156,9 @@ pub(super) struct Remote {
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
@@ -191,6 +194,8 @@ pub(super) struct Remote {
input: horizon_client::interaction_input::InteractionInput,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
pub(super) flat: bool,
death_message: Option<String>,
player_type: Option<Arc<MobType>>,
/// Inventory as last synchronised, for creative-mode slot updates.
@@ -454,6 +459,7 @@ impl Game<'_> {
lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
@@ -475,6 +481,7 @@ impl Game<'_> {
input: Default::default(),
stop_mining: false,
placing: None,
flat: false,
death_message: None,
player_type,
synced: vec![None; 36],
@@ -989,6 +996,7 @@ impl Game<'_> {
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.dimension = dimension;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
// A replacement LocalPlayer has a fresh sendPosition baseline.
r.position_sender = Default::default();
@@ -1408,6 +1416,13 @@ impl Game<'_> {
{
return;
}
// So does a firework rocket: its own tick flies it on.
if path.is_some()
&& let Some(f) = self.rockets.iter_mut().find(|f| f.id == id as u32)
{
f.position = Vec3::new(position[0], position[1], position[2]);
return;
}
// A dragon fireball keeps Entity's NO_OP InterpolationHandler too:
// the server's position replaces its own at once.
if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
@@ -1560,7 +1575,7 @@ impl Game<'_> {
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
@@ -1741,7 +1756,17 @@ impl Game<'_> {
f64::from(p.y) / 8.0,
f64::from(p.z) / 8.0,
];
self.audio.play(&name, Some(at), p.volume, p.pitch);
// ClientPacketListener.handleSoundEvent plays with packet.getSource().
match usize::try_from(p.source)
.ok()
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
{
Some(source) => {
self.audio
.play_on(source, &name, Some(at), p.volume, p.pitch)
}
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
}
}
}
C::StopSound(p) => self
@@ -1858,7 +1883,8 @@ impl Game<'_> {
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
// lead (this player, another player or a mob), for the rope.
// lead (this player, another player, a mob or a fence knot), for
// the rope.
C::SetEntityLink(p) => {
let me = self.remote.as_ref().map(|r| r.entity_id);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
@@ -1866,7 +1892,12 @@ impl Game<'_> {
m.extra.leash = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
d => Some(Holder::Mob(d as u32)),
d => match self.remote_knots.get(&(d as u32)) {
Some(&block) => Some(Holder::Knot(block)),
// Not a known knot yet: kept by id until the knot
// arrives (game/remote_objects.rs).
None => Some(Holder::Mob(d as u32)),
},
};
}
}
@@ -1975,9 +2006,9 @@ impl Game<'_> {
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
// player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag).
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
// another player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag), and the dust a runner raises.
if (mannequin
|| self
.remote
@@ -1988,6 +2019,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
m.extra.sprinting = flags & 8 != 0;
}
}
}
@@ -2098,8 +2130,9 @@ impl Game<'_> {
}
C::HurtAnimation(p) => {
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
// Player.animateHurt: the tilt turns around the yaw.
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt();
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
@@ -2136,9 +2169,64 @@ impl Game<'_> {
{
m.health = 0.0;
}
// 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)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
}
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
// Mob.handleEntityEvent 20: the spawn poof.
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
self.poof_particles(p.id as u32);
}
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
// 7, Animal and Allay 18: the particles about the mob.
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18) {
self.entity_event_particles(p.id as u32, p.event);
}
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
// the projectile is (the server removes it right after).
if p.event == 3 {
let crumbs = self
.creatures
.projectiles
.iter()
.find(|pr| pr.id == p.id as u32)
.and_then(|pr| {
let item = match pr.kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
"minecraft:snowball"
}
horizon_client::projectiles::ProjectileKind::Egg => "minecraft:egg",
_ => return None,
};
Some(([pr.position.x, pr.position.y, pr.position.z], item))
});
if let Some((at, item)) = crumbs {
self.item_burst(at, item);
}
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.player
.as_ref()
.map(|q| [q.position.x, q.position.y, q.position.z])
} else {
self.mobs
.iter()
.find(|m| m.id == p.id as u32)
.map(|m| [m.position.x, m.position.y, m.position.z])
};
if let Some(at) = at {
self.honey_slide_particles(at);
}
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
@@ -3022,6 +3110,8 @@ impl Game<'_> {
p.velocity = v;
} else if let Some(b) = self.dragon_fireballs.iter_mut().find(|b| b.id == id as u32) {
b.velocity = v;
} else if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id as u32) {
r.velocity = v;
} else if let Some(p) = self
.remote
.as_mut()
@@ -3044,6 +3134,37 @@ impl Game<'_> {
p.health.food.saturation = saturation;
}
}
/// `ServerPlayer.dismountTo` lands a rider at the ghast's own (x, maxY, z)
/// (`HappyGhast.getDismountLocationForPassenger`). A vanilla client
/// simulates the ghast it rides, so its copy is where the server has it;
/// this window's copy trails the server's by the stepped interpolation and
/// would rise into the rider's feet, which lets the rider fall through.
/// Put the copy under the landing spot.
fn place_ghast_under(&mut self, id: u32, spot: Vec3) {
let Some(m) = self
.mobs
.iter_mut()
.find(|m| m.id == id && m.species() == horizon_client::mobs::Species::HappyGhast)
else {
return;
};
let at = Vec3::new(spot.x, spot.y - f64::from(m.height()), spot.z);
// Only a teleport that is that dismount: right above the ghast.
if (at.x - m.position.x).powi(2) + (at.z - m.position.z).powi(2) > 25.0
|| (at.y - m.position.y).abs() > 5.0
{
return;
}
m.position = at;
m.previous = at;
if let Some(handler) = self
.remote
.as_mut()
.and_then(|r| r.stepped.get_mut(&(id as i32)))
{
handler.cancel();
}
}
/// ClientPacketListener.handleMovePlayer.
fn remote_teleport(&mut self, p: &play::PlayerPosition) -> Result<()> {
use play::relative as rel;
@@ -3103,6 +3224,9 @@ impl Game<'_> {
self.previous_position = position;
self.yaw = yaw;
self.pitch = pitch;
if let Some(id) = self.remote.as_mut().and_then(|r| r.ghast_dismount.take()) {
self.place_ghast_under(id, position);
}
if let Some(r) = self.remote.as_mut() {
// handleMovePlayer does not change LocalPlayer.sendPosition's
// baseline: the next tick must still report the corrected state.
@@ -3142,6 +3266,8 @@ impl Game<'_> {
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
@@ -3296,6 +3422,11 @@ impl Game<'_> {
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
// at 10, the hurt tilt restarted every frame and shook the camera.
player.health.advance_clock();
}
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
@@ -3310,12 +3441,12 @@ impl Game<'_> {
let feet = [player.position.x, player.position.y, player.position.z];
for event in events {
match event {
horizon_client::motion::MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
horizon_client::motion::MotionEvent::Emission { state, muffled, .. }
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 {
let (volume, pitch) = if muffled {
(sounds.volume * 0.05, sounds.pitch * 0.8)
} else {
(sounds.volume * 0.15, sounds.pitch)
@@ -3332,6 +3463,13 @@ impl Game<'_> {
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
// HoneyBlock.maybeDoSlideEffects on the client's own level:
// the slide sound, heard here (LocalPlayer.playSound); the
// particles come from the server's event 53.
horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
_ => {}
}
}
@@ -3508,6 +3646,16 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::base_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Horse
| horizon_client::mobs::Species::Donkey
| horizon_client::mobs::Species::Mule
| horizon_client::mobs::Species::SkeletonHorse
| horizon_client::mobs::Species::ZombieHorse
) {
horizon_client::mobs::equine::tail_tick(m);
}
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
@@ -3518,6 +3666,10 @@ impl Game<'_> {
) {
horizon_client::mobs::guardian::client_tick(m);
}
// PolarBear.tick: the stand animation follows the synched flag.
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3872,6 +4024,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,
@@ -3884,20 +4039,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);
@@ -4187,6 +4328,16 @@ impl Game<'_> {
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
// no UseItem follows (the server does the harvest, see `use_beehive`).
if held.is_some_and(|s| matches!(&*s.item, "minecraft:shears" | "minecraft:glass_bottle"))
&& self
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (true, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
[0, 1, 0] => Direction::Up,
@@ -4703,7 +4854,7 @@ impl Game<'_> {
}
/// The server's open menu: its window id and menu type (0, "inventory"
/// when none is open).
fn remote_menu(&self) -> (i32, String) {
pub(super) fn remote_menu(&self) -> (i32, String) {
match self.remote.as_ref().and_then(|r| r.menu.clone()) {
Some((id, name)) => (id, name),
None => (0, "inventory".to_owned()),
@@ -4793,26 +4944,6 @@ impl Game<'_> {
}));
}
}
/// A `ContainerClick` on game slot `internal` of the server's open menu
/// (`input` is the ContainerInput ordinal). False when the menu has no
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot: slot as i16,
button,
input,
changed: Vec::new(),
carried,
}));
true
}
/// The server's open menu is the inventory or a chest-like container
/// (chests, barrels, ender chests: `generic_9xN`; shulker boxes).
pub(super) fn remote_storage_open(&self) -> bool {
@@ -5,7 +5,8 @@
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_protocol::entity_data::Value;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
@@ -34,6 +35,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
// animation follow it.
(9, Value::Float(health)) => m.health = *health,
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID:
// the swirls of its effects (`tickEffects` draws them).
(10, Value::Particles(list)) => m.extra.effect_particles = swirls(list, builtin),
(11, Value::Bool(ambient)) => m.extra.effect_ambient = *ambient,
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
// Mob.isAggressive draws the illagers' and skeletons' attack
// poses and the vindicator's axe.
@@ -59,6 +64,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
Who::Mob(*target as u32)
});
}
// 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),
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
@@ -66,6 +73,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.color = (*wool & 15) as u8;
m.extra.sheared = wool & 16 != 0;
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
(Species::Mooshroom, 17, Value::Int(variant)) => {
m.extra.variant = (*variant == 1) as u8
}
// Piglin.DATA_IS_DANCING: the dance pose.
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
m.extra.sitting = flags & 1 != 0;
@@ -82,6 +97,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// Pillager.IS_CHARGING_CROSSBOW: the charge pose (Raider's
// IS_CELEBRATING takes 16).
(Species::Pillager, 17, Value::Bool(charging)) => m.extra.flag = *charging,
// HappyGhast.STAYS_STILL (after AgeableMob's two and IS_LEASH_HOLDER):
// `isOnStillTimeout` on a client, which keeps the ghast solid to
// the player (`canBeCollidedWith`).
(Species::HappyGhast, 19, Value::Bool(still)) => m.extra.counter = i32::from(*still),
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
(Species::Evoker | Species::Illusioner, 17, Value::Byte(spell)) => {
m.extra.variant2 = (*spell).max(0) as u8;
@@ -100,6 +119,30 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
}
}
/// The options of a synched particle list: an entity effect carries its ARGB
/// colour, the omens none.
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
let channel = |argb: i32, shift: u32| ((argb >> shift) & 255) as f32 / 255.0;
list.iter()
.filter_map(|p| {
let name = builtin.get("minecraft:particle_type")?.name(p.id)?;
let ty = ParticleType::from_name(name)?;
Some(match p.data {
Some(ParticleData::Color(argb)) => ParticleOptions::colored(
ty,
[
channel(argb, 16),
channel(argb, 8),
channel(argb, 0),
channel(argb, 24),
],
),
_ => ParticleOptions::simple(ty),
})
})
.collect()
}
/// VillagerData as the network sends it: the type and the profession by
/// their built-in registry ids, then the level.
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
@@ -66,8 +66,11 @@ impl Game<'_> {
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Axolotl
| Species::Tadpole => item == "minecraft:water_bucket",
| Species::Axolotl => item == "minecraft:water_bucket",
// Tadpole.mobInteract: frog food, else the bucket.
Species::Tadpole => {
item == "minecraft:water_bucket" || m.kind.food.iter().any(|f| f.as_str() == item)
}
Species::Armadillo => item == "minecraft:brush",
// ZombieVillager.mobInteract: CONSUME for a golden apple.
Species::ZombieVillager => item == "minecraft:golden_apple",
@@ -1,9 +1,10 @@
//! Boats, minecarts, armor stands, item frames and paintings on a server's
//! Boats, minecarts, armor stands, item frames, paintings and fence knots on a server's
//! client (`ClientPacketListener.handleAddEntity`, `handleSetEntityData`,
//! `handleSetEquipment`): the server simulates them, the client places them
//! where it says and interpolates their moves like a mob's.
use super::Game;
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
use horizon_client::mobs::leash::Holder;
use horizon_core::geometry::Vec3;
use horizon_protocol::entity_data::Value;
use std::sync::Arc;
@@ -24,7 +25,17 @@ impl Game<'_> {
let at = Vec3::new(position[0], position[1], position[2]);
let block = position.map(|v| v.floor() as i32);
self.remove_remote_object(id);
if kind == "minecraft:text_display" {
if kind == "minecraft:leash_knot" {
// A lead that came before its knot waits as a mob holder with the
// knot's id (handleEntityLinkPacket's delayed holder); now it can
// be tied to the fence.
self.remote_knots.insert(id, block);
for m in &mut self.mobs {
if m.extra.leash == Some(Holder::Mob(id)) {
m.extra.leash = Some(Holder::Knot(block));
}
}
} else if kind == "minecraft:text_display" {
self.text_displays.insert(
id as i32,
super::text_displays::RemoteTextDisplay::new(position, yaw),
@@ -304,6 +315,7 @@ impl Game<'_> {
/// Every object a server sent (a new connection, level or dimension).
pub(super) fn clear_remote_objects(&mut self) {
self.text_displays.clear();
self.remote_knots.clear();
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
@@ -314,6 +326,7 @@ impl Game<'_> {
pub(super) fn remove_remote_object(&mut self, id: u32) {
self.text_displays.remove(&(id as i32));
self.remote_knots.remove(&id);
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
@@ -3,8 +3,11 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_client::{
collisions::SceneCollisions,
particles::{ParticleOptions, ParticleType},
};
use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
@@ -23,6 +26,58 @@ fn default_cloud_particle() -> ParticleOptions {
ParticleOptions::colored(ParticleType::EntityEffect, [1.0; 4])
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two particles
/// near the middle; then about one per square block of the disc, drifting
/// (an entity effect stays still).
pub(super) fn cloud_client_tick(
particles: &mut horizon_client::particles::ParticleEngine,
at: [f64; 3],
radius: f32,
waiting: bool,
particle: &ParticleOptions,
) {
if waiting && particles.random().next_boolean() {
return;
}
let (count, spread) = if waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * radius * radius).ceil() as i32,
radius,
)
};
for _ in 0..count {
let r = particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
at[0] + f64::from(angle.cos() * distance),
at[1],
at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if particle.ty == ParticleType::EntityEffect {
let default = waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
particle.clone()
};
(options, [0.0; 3])
} else if waiting {
(particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(particle.clone(), v)
};
particles.add(&options, at, velocity);
}
}
/// The projectile a type id names, as Horizon models it.
fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
Some(match kind {
@@ -162,6 +217,10 @@ impl Game<'_> {
let Some(k) = projectile_kind(kind) else {
return false;
};
if k == ProjectileKind::LlamaSpit {
self.particles
.llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]);
}
let mut p = Projectile::new(k, at, None);
p.id = id;
p.velocity = velocity;
@@ -356,31 +415,58 @@ impl Game<'_> {
let id = t.id;
t.remote_step(|at| step(id, at));
}
// FireworkRocketEntity.tick: a rocket attached to the gliding local
// player boosts it client-side too (attachedToEntity.isFallFlying).
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
// and boosts it client-side too (attachedToEntity.isFallFlying), and
// every rocket leaves its spark trail on a client.
let me = self
.remote
.as_ref()
.and_then(|r| u32::try_from(r.entity_id).ok());
for r in &mut self.rockets {
let id = r.id;
r.remote_step(|at| step(id, at));
if me.is_some()
&& r.attached_to == me
&& let Some(p) = self.player.as_mut()
&& p.fall_flying
{
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
r.position = p.position;
r.velocity = p.velocity;
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
r.remote_step(&mut SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
});
if let Some(target) = r.attached_to {
if me == Some(target) {
if let Some(p) = self.player.as_mut() {
if p.fall_flying {
let look = view_vector(self.yaw, self.pitch);
let m = p.velocity;
p.velocity = Vec3::new(
m.x + look[0] * 0.1 + (look[0] * 1.5 - m.x) * 0.5,
m.y + look[1] * 0.1 + (look[1] * 1.5 - m.y) * 0.5,
m.z + look[2] * 0.1 + (look[2] * 1.5 - m.z) * 0.5,
);
}
r.position = p.position;
r.velocity = p.velocity;
}
} else if let Some(m) = self.mobs.iter().find(|m| m.id == target) {
// Another player's motion is not known here: its step.
r.velocity = Vec3::new(
m.position.x - r.previous.x,
m.position.y - r.previous.y,
m.position.z - r.previous.z,
);
r.position = m.position;
}
}
let g = |g: &mut horizon_core::random::LegacyRandom| g.next_gaussian() * 0.05;
let v = [
g(&mut self.world.random),
-r.velocity.y * 0.5,
g(&mut self.world.random),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::Firework),
[r.position.x, r.position.y, r.position.z],
v,
);
}
self.rockets = rockets;
for c in &mut self.crystals {
c.time += 1;
c.position = step(c.id, c.position);
@@ -410,52 +496,10 @@ impl Game<'_> {
}
}
/// `AreaEffectCloud.clientTick`: while waiting, now and then two
/// particles near the middle; then about one per square block of the
/// disc, drifting (an entity effect stays still).
/// `AreaEffectCloud.clientTick` for each cloud a server tracks.
fn tick_remote_clouds(&mut self) {
for c in &self.potions.clouds {
let r = self.particles.random();
if c.waiting && r.next_boolean() {
continue;
}
let (count, spread) = if c.waiting {
(2, 0.2)
} else {
(
(std::f32::consts::PI * c.radius * c.radius).ceil() as i32,
c.radius,
)
};
for _ in 0..count {
let r = self.particles.random();
let angle = r.next_float() * std::f32::consts::TAU;
let distance = r.next_float().sqrt() * spread;
let at = [
c.at[0] + f64::from(angle.cos() * distance),
c.at[1],
c.at[2] + f64::from(angle.sin() * distance),
];
let (options, velocity) = if c.particle.ty == ParticleType::EntityEffect {
let default = c.waiting && r.next_boolean();
let options = if default {
default_cloud_particle()
} else {
c.particle.clone()
};
(options, [0.0; 3])
} else if c.waiting {
(c.particle.clone(), [0.0; 3])
} else {
let v = [
(0.5 - r.next_double()) * 0.15,
f64::from(0.01f32),
(0.5 - r.next_double()) * 0.15,
];
(c.particle.clone(), v)
};
self.particles.add(&options, at, velocity);
}
cloud_client_tick(&mut self.particles, c.at, c.radius, c.waiting, &c.particle);
}
}
@@ -128,6 +128,17 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
// HappyGhast.removePassenger: a still timeout of 10 (the server's
// STAYS_STILL confirms it), and the rider lands on top of the ghast
// (`place_ghast_under`).
if let Some(m) = self.mobs.iter_mut().find(|m| {
m.id == vehicle_id && m.species() == horizon_client::mobs::Species::HappyGhast
}) {
m.extra.counter = 10;
if let Some(r) = self.remote.as_mut() {
r.ghast_dismount = Some(vehicle_id);
}
}
}
}
+68 -6
View File
@@ -7,6 +7,7 @@ use horizon_client::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
chunk::{HEIGHT, MIN_Y},
containers::BlockEntity,
particles::{ParticleLevel, ParticleOptions, ParticleType},
world::World,
};
@@ -91,9 +92,8 @@ impl Game<'_> {
distant_terrain: self.lod.is_some(),
cloud_range_chunks: self.settings.cloud_range,
camera_y: camera.y,
// level.getMinY(): the Nether and the End start at y 0.
min_y: if self.dim == Dim::Overworld { MIN_Y } else { 0 },
flat: false,
min_y: self.level_min_y(),
flat: self.level_flat(),
forward: forward.map(|c| c as f32),
rain,
thunder,
@@ -107,9 +107,30 @@ impl Game<'_> {
},
)
}
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
/// `ClientLevelData.isFlat`: on the server thread, the world-wide flag.
fn level_flat(&self) -> bool {
match self.remote.as_ref() {
Some(r) => r.flat,
None => self.world_flat,
}
}
/// `Level.getMinY`: the current dimension type's minimum y.
fn level_min_y(&self) -> i32 {
match self.remote.as_ref() {
Some(r) => r.dimension.min_y,
None => self.dim.min_y(),
}
}
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon and not under
/// water. `ClientLevelData.getHorizonHeight`: the bottom of a flat world,
/// otherwise y 63.
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
camera.y < 63.0
let horizon = if self.level_flat() {
f64::from(self.level_min_y())
} else {
63.0
};
camera.y < horizon
&& fog_type(
&self.world,
self.res.facts(),
@@ -271,6 +292,16 @@ impl Game<'_> {
&& motion_blocking(world, facts, p[0], p[2], p[1] + 64) <= p[1]
&& rains_at(world, biomes, p[0], p[1], p[2])
};
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
let gateway_particles = |state, p| {
self.res
.block_entity_render()
.gateway_faces(world, state, p)
.len()
};
// Level.isThundering: a thunder level above 0.9 where the sky is open.
let thundering = self.dim == Dim::Overworld && world.weather.levels(1.0).1 > 0.9;
let ctx = AnimateContext {
world,
facts,
@@ -279,6 +310,10 @@ impl Game<'_> {
registry: self.pack.assets.registry,
raining_at: &raining_at,
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
model_offset: &model_offset,
gateway_particles: &gateway_particles,
thundering,
ultrawarm: self.dim == Dim::Nether,
};
if let Some(e) = eye {
let block = [
@@ -288,11 +323,13 @@ impl Game<'_> {
];
animate_tick(self.res.animate(), &mut self.particles, &ctx, block);
if rain > 0.0 {
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10).
// ClientLevel.tickWeatherEffects: rain splashes (weather radius 10)
// and the rain sound.
let mut random = LegacyRandom::new(self.world.game_time.wrapping_mul(312_987_231));
let radius = 10;
let diameter = 2 * radius + 1;
let count = (0.225 * (diameter * diameter) as f32 * rain * rain) as i32;
let mut rain_position = None;
for _ in 0..count {
let x = block[0] + random.next_int_bound(diameter).unwrap_or(0) - radius;
let z = block[2] + random.next_int_bound(diameter).unwrap_or(0) - radius;
@@ -305,6 +342,7 @@ impl Game<'_> {
continue;
}
let below = top - 1;
rain_position = Some([x, below, z]);
let (bx, bz) = (random.next_double(), random.next_double());
let state = world.block(x, below, z);
let f = facts.get(state);
@@ -336,6 +374,26 @@ impl Game<'_> {
self.particles
.add(&ParticleOptions::simple(ty), p, [0.0; 3]);
}
// The last rain column of the tick makes the sound, more often the
// longer it has been quiet; indoors it is the quieter one.
if let Some(at) = rain_position {
let waited = self.rain_sound_time;
self.rain_sound_time += 1;
if random.next_int_bound(3).unwrap_or(0) < waited {
self.rain_sound_time = 0;
let roof =
motion_blocking(world, facts, block[0], block[2], MIN_Y + HEIGHT);
let (name, volume, pitch) = if at[1] > block[1] + 1 && roof > block[1] {
("minecraft:weather.rain.above", 0.1, 0.5)
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
let p = at.map(|c| f64::from(c) + 0.5);
// playLocalSound: guests make their own rain, so a host
// must not capture it for them.
self.audio.play_unshared(name, Some(p), volume, pitch);
}
}
}
}
if let Some(r) = &self.renderer {
@@ -344,6 +402,10 @@ impl Game<'_> {
&mut self.particles,
&ctx,
r.block_entities(),
&|pos| match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Campfire(c)) => c.items.each_ref().map(Option::is_some),
_ => [false; 4],
},
);
}
let level = ParticleLevel {
+10 -2
View File
@@ -132,8 +132,16 @@ impl Game<'_> {
match t {
Target::Player => Some(self.acting_player_name().to_owned()),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.name.clone()),
Target::Mob(id) | Target::Item(id) => Some(uuid_text(self.uuid_for(id))),
_ => None,
Target::Mob(id)
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles are addressed by index and have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
}
}
fn holder_for(&mut self, t: Target) -> Option<Holder> {
+153 -1
View File
@@ -5,7 +5,8 @@ use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
items::ItemStack,
signs::{DYE_TEXT, wood_type},
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
@@ -31,6 +32,157 @@ fn sign_box() -> EditBox {
}
impl Game<'_> {
/// `SignBlockEntity.getSlotPlayerIsFacing`: whether the player stands in
/// front of the sign's text (within 90 degrees of its facing).
fn facing_sign_front(&self, pos: (i32, i32, i32)) -> bool {
let Some(player) = self.player.as_ref() else {
return true;
};
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(state) = self.pack.assets.registry.state(state) else {
return true;
};
let prop = |key: &str| state.properties.get(key).map(String::as_str);
// getYRotationDegrees: a wall sign's `Direction.toYRot`, a standing or
// ceiling sign's rotation segment.
let y_rot = if state.name.contains("_wall_") {
match prop("facing") {
Some("west") => 90.0,
Some("north") => 180.0,
Some("east") => 270.0,
_ => 0.0,
}
} else {
prop("rotation")
.and_then(|r| r.parse::<f32>().ok())
.unwrap_or(0.0)
* 22.5
};
// getSignHitboxCenterPosition: only a wall sign's board is off-centre,
// against the wall it hangs on.
let centre = if state.name.ends_with("_wall_sign") {
match y_rot as i32 {
90 => (0.9375, 0.5),
180 => (0.5, 0.9375),
270 => (0.0625, 0.5),
_ => (0.5, 0.0625),
}
} else {
(0.5, 0.5)
};
let xd = player.position.x - (f64::from(pos.0) + centre.0);
let zd = player.position.z - (f64::from(pos.2) + centre.1);
let player_y_rot = (zd.atan2(xd).to_degrees() - 90.0) as f32;
// Mth.degreesDifferenceAbs.
let diff = (player_y_rot - y_rot).rem_euclid(360.0);
let diff = if diff >= 180.0 { 360.0 - diff } else { diff };
diff <= 90.0
}
/// `SignBlock.useWithoutItem` (the block's `OpenMenu` effect): a waxed
/// sign plays the failed-interaction sound; otherwise a player who may
/// build edits the side they face.
pub(super) fn use_sign_empty_hand(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return;
};
if sign.waxed {
// `getSignInteractionFailedSoundEvent`: a hanging sign has its own.
let sound = if sign.hanging {
"minecraft:block.hanging_sign.waxed_interact_fail"
} else {
"minecraft:block.sign.waxed_interact_fail"
};
self.audio
.play(sound, Some(pos.map(|v| f64::from(v) + 0.5)), 1.0, 1.0);
} else if self.may_build() {
let front = self.facing_sign_front(key);
self.open_sign_editor(key, front);
}
}
/// `SignBlock.useItemOn` with a `SignApplicator` in hand (dyes, ink sacs,
/// honeycomb): the item changes the side the player faces and is used up.
/// Whether it did; a sign it does not apply to goes on to the empty hand.
pub(super) fn use_sign_applicator(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
let Some(stack) = held else {
return false;
};
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&key) else {
return false;
};
if sign.waxed || !self.may_build() {
return false;
}
let item = &*stack.item;
let dye = if item.ends_with("_dye") {
self.res
.items()
.get(&stack.item)
.and_then(|i| i.dye.clone())
} else {
None
};
let colour = dye.and_then(|d| DYE_TEXT.iter().position(|(n, _)| *n == d));
if !matches!(
item,
"minecraft:honeycomb" | "minecraft:glow_ink_sac" | "minecraft:ink_sac"
) && colour.is_none()
{
return false;
}
let front = self.facing_sign_front(key);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
return false;
};
let centre = pos.map(|v| f64::from(v) + 0.5);
let sound = if item == "minecraft:honeycomb" {
// HoneycombItem.tryApplyToSign: waxes any sign, empty or not.
sign.waxed = true;
"minecraft:item.honeycomb.wax_on"
} else {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
// SignApplicator.canApplyToSign: the side must have text.
if side.is_empty() {
return false;
}
match colour {
Some(c) if side.color != c as u8 => {
side.color = c as u8;
"minecraft:item.dye.use"
}
None if item == "minecraft:glow_ink_sac" && !side.glowing => {
side.glowing = true;
"minecraft:item.glow_ink_sac.use"
}
None if item == "minecraft:ink_sac" && side.glowing => {
side.glowing = false;
"minecraft:item.ink_sac.use"
}
_ => return false,
}
};
if item == "minecraft:honeycomb" {
self.level_event_at(3003, pos, 0);
}
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.mark_block_entity_updated(key);
let state = self.world.block(pos[0], pos[1], pos[2]);
self.post_block_game_event(
"minecraft:block_change",
pos,
self.sculk_actor(),
Some(state),
);
self.stats.add("used", &stack.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", pos, stack);
true
}
/// `SignBlock.openTextEdit` for the front side after placement.
pub(super) fn open_sign_editor(&mut self, pos: (i32, i32, i32), front: bool) {
self.sign_edit = Some(SignEditor {
+11 -2
View File
@@ -253,12 +253,21 @@ impl Game<'_> {
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
/// `LivingEntity.makePoofParticles` (entity event 20) for a mob.
fn mob_poof(&mut self, id: u32) {
pub(super) fn mob_poof(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = m.position;
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
self.poof_particles(id);
}
/// `Mob.handleEntityEvent` 20 (`spawnAnim` on a client): the poof about
/// mob `id`.
pub(super) fn poof_particles(&mut self, id: u32) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.lan_entity_event(id as i32, 20, [at.x, at.y, at.z]);
let options = ParticleOptions::simple(ParticleType::Poof);
for _ in 0..20 {
let r = self.particles.level_random();
+222 -55
View File
@@ -277,6 +277,10 @@ fn argb(c: i32) -> u32 {
)
}
/// A text style's `0xRRGGBB` colour from a HUD colour (`rgba`).
fn rgb_of(rgba: u32) -> u32 {
(rgba & 0xFF) << 16 | (rgba >> 8 & 0xFF) << 8 | (rgba >> 16 & 0xFF)
}
impl Game<'_> {
/// `Component.translatable`: the selected language (which `lang` layers
/// over `en_us`), the caller's English text last.
@@ -775,6 +779,7 @@ impl Game<'_> {
return;
};
let pos = s.pos;
let enchanted_item = enchanted.clone();
// Player.onEnchantmentPerformed
self.give_levels(-(lapis_needed as i32));
self.level.xp_seed = self.world.random.next_int();
@@ -791,6 +796,7 @@ impl Game<'_> {
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.stats.custom("enchant_item", 1);
self.trigger_enchanted_item(&enchanted_item, lapis_needed as i32);
self.play_at("minecraft:block.enchantment_table.use", pos, 1.0, pitch);
self.station_changed();
}
@@ -1690,20 +1696,34 @@ impl Game<'_> {
&self,
stack: &ItemStack,
) -> Vec<(horizon_client::text::Text, u32)> {
let e: &Enchantments = &self.res.stations().enchantments;
let props = e.props(&stack.item);
self.stack_tooltip_lines(stack, false)
}
/// `ItemStack.getRarity`'s colour for the name line and a `[name]` link.
fn rarity_color(&self, stack: &ItemStack) -> u32 {
let props = self.res.stations().enchantments.props(&stack.item);
let rarity = horizon_client::item_tooltip::rarity(
stack,
props.map_or("common", |p| p.rarity.as_str()),
);
let color = match rarity {
match rarity {
"uncommon" => rgba(255, 255, 85, 255),
"rare" => rgba(85, 255, 255, 255),
"epic" => rgba(255, 85, 255, 255),
_ => u32::MAX,
};
let name = if let Some(n) = stack.custom_name() {
}
}
/// `ItemStack.getHoverName`: the custom name, a written book's title, the
/// item name component, else the item's or potion's name.
fn stack_hover_name(&self, stack: &ItemStack) -> String {
if let Some(n) = stack.custom_name() {
n.to_owned()
} else if let Some(title) = stack
.data()
.and_then(|d| d.written.as_ref())
.map(|w| w.title.as_str())
.filter(|t| !t.trim().is_empty())
{
title.to_owned()
} else if let Some(n) = stack.item_name() {
n
} else if stack.data().is_some_and(|d| d.ominous_banner) {
@@ -1719,16 +1739,47 @@ impl Game<'_> {
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
} else {
self.pack.names.display_item(&stack.item)
};
}
}
/// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's
/// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details
/// without the name, the player and the advanced flag.
fn stack_tooltip_lines(
&self,
stack: &ItemStack,
nested: bool,
) -> Vec<(horizon_client::text::Text, u32)> {
let e: &Enchantments = &self.res.stations().enchantments;
let color = self.rarity_color(stack);
let advanced = !nested && self.options_advanced_tooltips();
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
let mut lines = Vec::new();
if display.name_visible(self.screen == Screen::Creative) {
lines.push((horizon_client::text::Text::legacy(&name), color));
if !nested && display.name_visible(self.screen == Screen::Creative) {
// `getStyledHoverName`: a custom name is italic.
let name = self.stack_hover_name(stack);
let name = if stack.custom_name().is_some() {
horizon_client::text::Text::literal(name).italic()
} else {
horizon_client::text::Text::legacy(&name)
};
lines.push((name, color));
}
let translate = |k: &str| self.pack.names.display_get(k);
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
lines.push((horizon_client::text::Text::legacy(&s), c));
};
let gray = rgba(170, 170, 170, 255);
let item_info = self.res.items().get(&stack.item);
// InstrumentComponent.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
{
push(&mut lines, text, gray);
}
// Bees.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
@@ -1743,6 +1794,70 @@ impl Game<'_> {
push(&mut lines, text, u32::MAX);
}
}
// BannerPatternLayers.addToTooltip: up to six layer names.
if display.shows("minecraft:banner_patterns") {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations().banners.layer_key(layer);
push(
&mut lines,
self.translate(&key, &key),
rgba(170, 170, 170, 255),
);
}
}
// WrittenBookContent.addToTooltip.
for text in horizon_client::item_tooltip::written_book_lines(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// ChargedProjectiles.addToTooltip: each run of equal projectiles, then
// the projectile's own details indented under it.
if display.shows("minecraft:charged_projectiles") {
let charged = stack.data().map_or(&[][..], |d| &d.charged[..]);
for run in charged.chunk_by(|a, b| a.same_item_same_components(b)) {
let projectile = &run[0];
let name = self.stack_hover_name(projectile);
let line = if run.len() == 1 {
// `getDisplayName`: `[name]` in the rarity colour.
let mut shown = horizon_client::text::Text::literal(format!("[{name}]"))
.with_color(rgb_of(self.rarity_color(projectile)));
if projectile.custom_name().is_some() {
shown = shown.italic();
}
horizon_client::text::format_translation(
&self.translate(
"item.minecraft.crossbow.projectile.single",
"Projectile: %s",
),
&[shown],
)
} else {
horizon_client::text::format_translation(
&self.translate(
"item.minecraft.crossbow.projectile.multiple",
"Projectile: %s x %s",
),
&[
horizon_client::text::Text::literal(run.len().to_string()),
horizon_client::text::Text::literal(name),
],
)
};
lines.push((line, u32::MAX));
for (text, c) in self.stack_tooltip_lines(projectile, true) {
// `literal(" ").append(line).withStyle(GRAY)`: the line's
// own colours win over the gray.
let rgb = if c == u32::MAX {
horizon_client::text::GRAY
} else {
rgb_of(c)
};
let indented = horizon_client::text::Text::literal(" ")
.append(text)
.with_color(rgb);
lines.push((indented, u32::MAX));
}
}
}
// Fireworks / FireworkExplosion.addToTooltip.
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
push(&mut lines, text, rgba(170, 170, 170, 255));
@@ -1765,6 +1880,17 @@ impl Game<'_> {
};
push(&mut lines, text, c);
}
// The attribute modifiers of the effects (`When Applied:`).
for (text, c) in horizon_client::item_tooltip::potion_modifier_lines(stack, &translate)
{
push(&mut lines, text, c);
}
}
// JukeboxPlayable.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::jukebox_line(stack, item_info, &display, &translate)
{
push(&mut lines, text, gray);
}
if display.shows("minecraft:trim")
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
@@ -1812,16 +1938,12 @@ impl Game<'_> {
);
}
}
// BannerPatternLayers.addToTooltip: up to six layer names.
if display.shows("minecraft:banner_patterns") {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations().banners.layer_key(layer);
push(
&mut lines,
self.translate(&key, &key),
rgba(170, 170, 170, 255),
);
}
// DyedItemColor.addToTooltip.
if let Some((text, italic)) =
horizon_client::item_tooltip::dyed_line(stack, &display, advanced, &translate)
{
let text = horizon_client::text::Text::legacy(&text);
lines.push((if italic { text.italic() } else { text }, gray));
}
// ItemLore.addToTooltip: after the enchantments, before the attributes
// (ItemStack.addDetailsToTooltip).
@@ -1830,6 +1952,50 @@ impl Game<'_> {
lines.push((text, u32::MAX));
}
}
if display.shows("minecraft:attribute_modifiers") {
// The item's own modifiers, then its enchantments' for each slot
// group (`ItemStack.forEachModifier`).
let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default();
modifiers.extend(e.modifier_rows(stack));
let base = |name: &str, default| {
self.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key(name)?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(default)
};
let player_base = (!nested).then(|| {
[
base("minecraft:attack_damage", 1.0),
base(
"minecraft:attack_speed",
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
]
});
for (text, c) in
horizon_client::item_tooltip::attribute_lines(&modifiers, player_base, &translate)
{
push(&mut lines, text, c);
}
}
// ItemStack.UNBREAKABLE_TOOLTIP.
if let Some(text) =
horizon_client::item_tooltip::unbreakable_line(stack, &display, &translate)
{
push(&mut lines, text, rgba(85, 85, 255, 255));
}
// BlockItemStateProperties.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
if display.shows("minecraft:block_entity_data")
&& matches!(
@@ -1853,43 +2019,11 @@ impl Game<'_> {
}
}
}
if display.shows("minecraft:attribute_modifiers")
&& let Some(info) = self.res.items().get(&stack.item)
{
let base = |name: &str, default| {
self.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key(name)?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(default)
};
let player_base = [
base("minecraft:attack_damage", 1.0),
base(
"minecraft:attack_speed",
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
];
for (text, c) in horizon_client::item_tooltip::attribute_lines(
&info.modifiers,
Some(player_base),
&translate,
) {
push(&mut lines, text, c);
}
}
if display.shows("minecraft:damage")
&& let Some(info) = self.res.items().get(&stack.item)
&& let Some(info) = item_info
&& info.max_damage > 0
&& stack.damage > 0
&& self.options_advanced_tooltips()
&& advanced
{
push(
&mut lines,
@@ -1901,8 +2035,23 @@ impl Game<'_> {
u32::MAX,
);
}
// ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id
// and how many components the stack has.
if advanced {
let dark_gray = rgba(85, 85, 85, 255);
push(&mut lines, stack.item.to_string(), dark_gray);
let count = horizon_client::item_tooltip::component_count(stack, item_info);
if count > 0 {
let text = self.translate("item.components", "%s component(s)");
push(
&mut lines,
text.replacen("%s", &count.to_string(), 1),
dark_gray,
);
}
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
for (key, fallback) in [
("item.op_warning.line1", "Warning:"),
(
@@ -2105,12 +2254,18 @@ impl Game<'_> {
batch: &mut HudBatch,
layout: &horizon_client::bundle::TooltipLayout,
) {
for (at, stack) in &layout.slots {
for (i, (at, stack)) in layout.slots.iter().enumerate() {
let selected = layout.selected == Some(i);
let sprite = |name: &str| format!("gui/sprites/container/bundle/{name}");
// Each draw takes the atlas on its own: the item's needs `&mut self`.
horizon_client::ui::nine_slice(
batch,
&self.gui.atlas,
"gui/sprites/container/bundle/slot_background",
&sprite(if selected {
"slot_highlight_back"
} else {
"slot_background"
}),
at[0],
at[1],
24.0,
@@ -2120,6 +2275,18 @@ impl Game<'_> {
let stack = stack.clone();
let [x, y] = *at;
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
if selected {
horizon_client::ui::nine_slice(
batch,
&self.gui.atlas,
&sprite("slot_highlight_front"),
x,
y,
24.0,
24.0,
u32::MAX,
);
}
}
let atlas = &self.gui.atlas;
if let Some((at, hidden)) = layout.overflow {
+22 -10
View File
@@ -256,9 +256,8 @@ impl Game<'_> {
.fluid
== horizon_client::blocks::Fluid::Water;
let on_ground = p.ground.on_ground;
let (sprinting, flying, gliding, swimming) = (
let (sprinting, gliding, swimming) = (
p.sprinting,
p.flying,
p.fall_flying,
p.pose == horizon_client::pose::Pose::Swimming,
);
@@ -272,15 +271,27 @@ impl Game<'_> {
});
let s = &mut self.stats;
match riding {
Some(Ok(horizon_client::mobs::Species::Pig)) => s.custom("pig_one_cm", total),
Some(Ok(horizon_client::mobs::Species::Strider)) => s.custom("strider_one_cm", total),
Some(Ok(sp)) if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", horizontal)
Some(Ok(sp)) => {
use horizon_client::mobs::Species;
// `checkRidingStatistics`: only these mounts count, the horse
// family by the full 3D distance; any other mount nothing.
match sp {
Species::Pig => s.custom("pig_one_cm", total),
Species::Strider => s.custom("strider_one_cm", total),
Species::HappyGhast => s.custom("happy_ghast_one_cm", total),
Species::Nautilus | Species::ZombieNautilus => {
s.custom("nautilus_one_cm", total)
}
sp if horizon_client::mobs::equine::is_equine(sp) => {
s.custom("horse_one_cm", total)
}
_ => {}
}
}
Some(_) => {
Some(Err(())) => {
if self.boats.iter().any(|b| Some(b.id) == self.riding) {
s.custom("boat_one_cm", total)
} else {
} else if self.carts.iter().any(|c| Some(c.id) == self.riding) {
s.custom("minecart_one_cm", total)
}
}
@@ -300,8 +311,9 @@ impl Game<'_> {
}
}
None if gliding => s.custom("aviate_one_cm", total),
None if flying => s.custom("fly_one_cm", horizontal),
None if d.y < 0.0 => s.custom("fall_one_cm", (-d.y * 100.0).round() as i32),
// Airborne and not gliding: more than 25 cm sideways per move
// (creative flight, or a long jump), not only flying.
None if horizontal > 25 => s.custom("fly_one_cm", horizontal),
None => {}
}
}
+74
View File
@@ -3,6 +3,7 @@
//! player's `PlayerEnderChestContainer`, saved as `EnderItems`).
use super::Game;
use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text};
use horizon_core::random::RandomSource;
pub(super) fn is_shulker_box(name: &str) -> bool {
name.ends_with("shulker_box")
@@ -156,6 +157,79 @@ impl Game<'_> {
);
paired.then_some((other, kind == "right"))
}
/// The open or close sound of a container block (`ChestBlockEntity.playSound`,
/// `BarrelBlockEntity.playSound`, `ShulkerBoxBlockEntity.startOpen`): volume
/// 0.5 and pitch 0.9 to 1.0. A double chest sounds once from the middle of
/// both halves, a barrel from the face it opens on.
pub(super) fn container_sound(&mut self, sound: &str, key: (i32, i32, i32), name: &str) {
let mut at = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
if let Some((other, _)) = self.chest_partner(key) {
at[0] = f64::from(key.0 + other.0) / 2.0 + 0.5;
at[2] = f64::from(key.2 + other.2) / 2.0 + 0.5;
} else if name == "minecraft:barrel"
&& let Some(state) = self
.pack
.assets
.registry
.state(self.world.block(key.0, key.1, key.2))
&& let Some(facing) = state.properties.get("facing")
{
let (dx, dy, dz) = match facing.as_str() {
"up" => (0.0, 0.5, 0.0),
"down" => (0.0, -0.5, 0.0),
f => {
let (x, z) = facing_step(f);
(f64::from(x) / 2.0, 0.0, f64::from(z) / 2.0)
}
};
at = [at[0] + dx, at[1] + dy, at[2] + dz];
}
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(sound, Some(at), 0.5, pitch);
}
/// The open or close sound of the container block at `key`, if it has one.
/// Callers play it only when the opener count crosses zero
/// (`ContainerOpenersCounter.onOpen` / `onClose`).
pub(super) fn container_edge_sound(&mut self, key: (i32, i32, i32), open: bool) {
let name = self
.res
.facts()
.get(self.world.block(key.0, key.1, key.2))
.name
.clone();
let sound = if is_shulker_box(&name) {
if open {
"minecraft:block.shulker_box.open"
} else {
"minecraft:block.shulker_box.close"
}
} else if &*name == "minecraft:ender_chest" {
if open {
"minecraft:block.ender_chest.open"
} else {
"minecraft:block.ender_chest.close"
}
} else if name.contains("chest") {
if open {
"minecraft:block.chest.open"
} else {
"minecraft:block.chest.close"
}
} else if &*name == "minecraft:barrel" {
if open {
"minecraft:block.barrel.open"
} else {
"minecraft:block.barrel.close"
}
} else {
return;
};
self.container_sound(sound, key, &name);
}
/// The other half of a double chest at `key`, for neighbour updates.
pub(super) fn chest_other_half(&self, key: (i32, i32, i32)) -> Option<(i32, i32, i32)> {
self.chest_partner(key).map(|p| p.0)
+22
View File
@@ -21,6 +21,22 @@ impl Game<'_> {
draws.extend(self.titles.title(w, h, partial, font, background));
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// `Hud.extractSavingIndicator`, under the screens: the option, then the
/// fading "Saving world" text.
pub(super) fn draw_saving_indicator(&self, batch: &mut HudBatch) {
if !self.settings.autosave_indicator {
return;
}
let draws = self.saving_indicator.draw(
batch.width as i32,
batch.height as i32,
self.clock.fraction() as f32,
&self.gui.atlas.font,
Text::literal(self.translate("menu.savingLevel", "Saving world")),
self.settings.text_background(0.0),
);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// The selected stack as `Hud.tick` compares it for the name highlight.
fn held_name(&self) -> Option<HeldName> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
@@ -44,6 +60,12 @@ impl Game<'_> {
/// The title, action bar and selected-item parts of `Hud.tick`.
pub(super) fn tick_hud(&mut self) {
self.titles.tick();
// `MinecraftServer.isCurrentlySaving` of single player's server.
let saving = self
.integrated
.as_ref()
.map_or(self.autosaving, |server| server.is_saving());
self.saving_indicator.tick(saving);
let held = self.held_name();
self.tool_highlight
.tick(held, f64::from(self.settings.notification_display_time));
+3 -1
View File
@@ -178,7 +178,9 @@ impl Game<'_> {
];
self.tnts.remove(i);
count -= 1;
self.server_explode(center, 4.0, BlockInteraction::Destroy, false)?;
self.with_hive_emptying_blast(|g| {
g.server_explode(center, 4.0, BlockInteraction::Destroy, false)
})?;
} else {
i += 1;
}
+24 -6
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talk_to_villager", 1);
self.stats.custom("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,18 +398,21 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let session = self.merchant_apply(session, mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session = session;
}
}
/// What a menu click's session did, applied to the world and its mob.
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> Box<merchant::Session> {
) -> (Box<merchant::Session>, Vec<ItemStack>) {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
@@ -438,11 +441,26 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::TradeSound => {}
}
}
session
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
}
}
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+37
View File
@@ -32,6 +32,8 @@ pub(super) struct ThrownTrident {
pub(super) no_physics: bool,
returning: u32,
creative_only: bool,
/// The guest that threw it (`Projectile.getOwner`); `None` for the host.
guest: Option<u32>,
}
impl ThrownTrident {
/// A trident a server sent (`handleAddEntity`): placed and moved by it.
@@ -51,6 +53,7 @@ impl ThrownTrident {
no_physics: false,
returning: 0,
creative_only: false,
guest: None,
}
}
/// One tick of a server's client: the previous position, then `step`.
@@ -302,11 +305,33 @@ impl Game<'_> {
no_physics: false,
returning: 0,
creative_only: self.creative,
guest: self.lan_acting,
});
self.player_projectile_shot();
self.audio
.play("minecraft:item.trident.throw", at, 1.0, 1.0);
}
/// The thrower's `PLAYER_HURT_ENTITY` for a trident hit: run as the
/// guest that threw it, and not at all once they have left.
fn thrower_hit(
&mut self,
guest: Option<u32>,
victim: &horizon_client::advancements::EntityView,
direct: horizon_client::advancements::EntityView,
damage: f32,
) {
let Some(conn) = guest.filter(|c| self.lan_acting != Some(*c)) else {
self.player_hit(victim, "minecraft:trident", Some(direct), damage);
return;
};
let Some(mut host) = self.lan.take() else {
return;
};
self.as_guest(&mut host, conn, |g| {
g.player_hit(victim, "minecraft:trident", Some(direct), damage)
});
self.lan = Some(host);
}
pub(super) fn tick_tridents(&mut self) {
let Some(eye) = self.eye_position() else {
return;
@@ -394,6 +419,18 @@ impl Game<'_> {
{
self.channel_lightning(at);
}
if hurt_at.is_some()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: "minecraft:trident".into(),
position: [t.position.x, t.position.y, t.position.z],
..Default::default()
};
self.thrower_hit(t.guest, &victim, direct, damage);
}
self.audio.play(
"minecraft:item.trident.hit",
Some([t.position.x, t.position.y, t.position.z]),
+1 -2
View File
@@ -70,8 +70,7 @@ impl Game<'_> {
/// One ordinary click on a game slot. False when nothing took it.
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
if self.remote.is_some() {
let ordinal = super::remote::input_ordinal(input);
return self.remote_slot_click(slot, button as i8, ordinal);
return self.remote_menu_ops(&[(Some(slot), button, input)]);
}
if !self.menu_click(Some(slot), button, input) {
return false;
+2
View File
@@ -169,6 +169,8 @@ impl Game<'_> {
self.consume_held();
}
}
// ServerPlayerGameMode.useItemOn: the action was consumed.
self.trigger_block("minecraft:item_used_on_block", pos, &stack);
Ok(true)
}
fn vault_events(&mut self, key: Pos, events: Vec<VaultEvent>) -> Result<()> {
+27 -8
View File
@@ -148,7 +148,7 @@ impl Game<'_> {
// VillagerMakeLove: entity event 13 for the partner, then the parent.
for i in [ib, ia] {
let id = self.mobs[i].id;
self.villager_particles(id, "angry_villager");
self.mob_event_particles(id, 13);
}
return;
};
@@ -173,12 +173,31 @@ impl Game<'_> {
child.extra.bed = Some(bed);
}
// VillagerMakeLove.giveBirth: entity event 12 for the child.
self.villager_particles(id, "heart");
self.mob_event_particles(id, 12);
}
/// `Villager.handleEntityEvent` 12, 13, 14 and 42:
/// `AbstractVillager.addParticlesAroundSelf`.
pub(super) fn villager_particles(&mut self, id: u32, kind: &'static str) {
self.particles_around_mob(id, kind, horizon_client::particles::Burst::VILLAGER);
/// `Level.broadcastEntityEvent` of an event whose `handleEntityEvent`
/// draws particles about the mob (villager 12, 13 and 14, taming 6 and 7,
/// love 18): the guests get the byte, and this game draws it as its own
/// client would.
pub(super) fn mob_event_particles(&mut self, id: u32, event: i8) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
self.lan_entity_event(id as i32, event, at);
self.entity_event_particles(id, event);
}
/// `handleEntityEvent(event)`'s particles about mob `id`
/// (`Villager`, `TamableAnimal`, `AbstractHorse`, `Animal`, `Allay`).
pub(super) fn entity_event_particles(&mut self, id: u32, event: i8) {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
if let Some((kind, burst)) =
horizon_client::particles::entity_event_burst(&m.kind.id, event as u8)
{
self.particles_around_mob(id, kind, burst);
}
}
/// `particles::around_self` about mob `id`.
pub(super) fn particles_around_mob(
@@ -315,7 +334,7 @@ impl Game<'_> {
}
// Villager.setLastHurtByMob: entity event 13.
for id in angry {
self.villager_particles(id, "angry_villager");
self.mob_event_particles(id, 13);
}
}
/// `Villager.tellWitnessesThatIWasMurdered`: the villagers that saw a
@@ -370,7 +389,7 @@ impl Game<'_> {
gossip::REPUTATION_CHANGE_PER_TRADE,
);
// Villager.customServerAiStep after a trade: entity event 14.
self.villager_particles(id, "happy_villager");
self.mob_event_particles(id, 14);
}
/// `Villager.getPlayerReputation` of the local player.
pub(super) fn villager_reputation(&self, id: u32) -> i32 {
+46 -1
View File
@@ -2,11 +2,53 @@
//! its boss bar, and its skulls hitting and exploding.
use super::Game;
use horizon_client::{
items::ItemStack,
mobs::{Damage, DamageKind, Species, Who},
projectiles::{Projectile, ProjectileKind},
};
use horizon_core::position::BlockPos;
impl Game<'_> {
/// `LivingEntity.createWitherRose`: a mob a wither killed leaves a wither
/// rose where it stood (with mobGriefing, on air that can hold it), else
/// drops one.
pub(super) fn create_wither_rose(&mut self, m: &horizon_client::mobs::Mob) {
// getKillCredit: the player who last hurt it, else the last mob (kept
// for 100 ticks).
let by_wither = m.ai.last_hurt_by_player <= 0
&& m.age as i32 - m.ai.last_hurt_by_time <= 100
&& matches!(m.ai.last_hurt_by, Some(Who::Mob(id))
if self.mobs.iter().any(|o| o.id == id && o.alive() && o.species() == Species::Wither));
if !by_wither {
return;
}
let pos = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let registry = self.pack.assets.registry;
let rose = registry.default_state("minecraft:wither_rose");
if self.ui.rules.bool("mob_griefing")
&& let Some(rose) = rose
&& self
.res
.facts()
.is_air(self.world.block(pos[0], pos[1], pos[2]))
&& self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]), rose)
})
{
let _ = self.level_set(pos, rose);
return;
}
self.spawn_item(
ItemStack::new("minecraft:wither_rose", 1),
[m.position.x, m.position.y, m.position.z],
super::dispenser::new_item_motion(),
0,
);
}
fn is_wither_skull(&self, p: [i32; 3]) -> bool {
let name = &*self
.res
@@ -130,7 +172,10 @@ impl Game<'_> {
if let Some(id) = owner {
self.note_attacker("minecraft:explosion", id);
}
self.explode(at, 1.0)
self.combat.explosion_owner = owner;
let result = self.with_hive_emptying_blast(|g| g.explode(at, 1.0));
self.combat.explosion_owner = None;
result
}
}
/// Wither skulls drawn as the skull model, blue when dangerous.
+44 -2
View File
@@ -29,12 +29,22 @@ pub(crate) type State = HurtState<DamageTypeId>;
pub struct HealthRules {
pub difficulty: Difficulty,
pub natural_regeneration: bool,
/// The `drowning_damage`, `fall_damage`, `fire_damage` and
/// `freeze_damage` rules `Player.isInvulnerableTo` reads.
pub drowning_damage: bool,
pub fall_damage: bool,
pub fire_damage: bool,
pub freeze_damage: bool,
}
impl Default for HealthRules {
fn default() -> Self {
Self {
difficulty: Difficulty::Normal,
natural_regeneration: true,
drowning_damage: true,
fall_damage: true,
fire_damage: true,
freeze_damage: true,
}
}
}
@@ -170,6 +180,12 @@ pub struct PlayerHealth {
pub hurt: State,
/// Client hurt animation; fully blocked hits only refresh the server's hurt state.
pub hurt_animation_time: i32,
/// `Player.hurtDir` on a client: the yaw `ClientboundHurtAnimationPacket`
/// gave, which `GameRenderer.bobHurt` tilts the camera around.
pub hurt_dir: f32,
/// `ServerPlayer.hurtDir` of a hit the player's client hasn't been told
/// of yet (`indicateDamage`).
pub indicated_dir: Option<f32>,
pub dead: bool,
pub sync_velocity: bool,
pub game_time: i64,
@@ -220,6 +236,8 @@ impl PlayerHealth {
last_stamp: 0,
},
hurt_animation_time: 0,
hurt_dir: 0.0,
indicated_dir: None,
dead: false,
sync_velocity: false,
game_time: 0,
@@ -267,6 +285,18 @@ impl PlayerHealth {
self.hurt.hurt_time = 10;
self.hurt_animation_time = 10;
}
/// `LivingEntity.animateHurt(yaw)` on a player: the tilt restarts around
/// `yaw` (`Player.animateHurt` keeps it as `hurtDir`).
pub fn animate_hurt_dir(&mut self, yaw: f32) {
self.animate_hurt();
self.hurt_dir = yaw;
}
/// `ServerPlayer.indicateDamage`: the hit's direction relative to where
/// the player looks, kept for the packet to its client.
pub fn indicate_damage(&mut self, xd: f64, zd: f64, yaw: f32) {
use horizon_core::geometry::trig::{RAD_TO_DEG, atan2};
self.indicated_dir = Some((atan2(zd, xd) * f64::from(RAD_TO_DEG) - f64::from(yaw)) as f32);
}
/// `LocalPlayer.hurtTo` with `flashOnSetHealth`: a drop below the current
/// health starts the hurt animation; `damageCooldownTime` (20 on a drop,
/// 10 on a gain) is what lets `Hud.extractPlayerHealth` blink the hearts.
@@ -370,6 +400,13 @@ impl PlayerMotion {
if outcome.hurt {
h.hurt_animation_time = h.hurt.hurt_time;
}
// `dealDefaultKnockback` ends in `indicateDamage` unless blocked: a
// hit with no direction of its own (`xd` and `zd` zero) is sent as
// the opposite of the player's yaw. A hit that has a direction
// replaces this (`mob_hits_player`).
if outcome.knockback && outcome.blocked <= 0.0 {
h.indicate_damage(0.0, 0.0, self.yaw);
}
let after = h.health() + h.absorption();
if self.hurt_log.len() >= HURT_LOG {
self.hurt_log.remove(0);
@@ -633,8 +670,13 @@ impl ImmunityWorld for Environment<'_> {
fn enchantment_immunity(&mut self) -> io::Result<bool> {
Ok(false)
}
fn damage_rule(&mut self, _: DamageRule) -> io::Result<bool> {
Ok(true)
fn damage_rule(&mut self, rule: DamageRule) -> io::Result<bool> {
Ok(match rule {
DamageRule::Drowning => self.rules.drowning_damage,
DamageRule::Fall => self.rules.fall_damage,
DamageRule::Fire => self.rules.fire_damage,
DamageRule::Freeze => self.rules.freeze_damage,
})
}
fn changing_dimension(&mut self) -> io::Result<bool> {
Ok(false)
+47
View File
@@ -191,6 +191,53 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
out
}
/// The saving indicator: `Hud.tickAutosaveIndicator` fades a value towards 1
/// while the server is saving and `extractSavingIndicator` draws the text.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SavingIndicator {
last: f32,
value: f32,
}
impl SavingIndicator {
/// `Hud.tickAutosaveIndicator`: `Mth.lerp(0.2F, value, saving ? 1 : 0)`.
pub fn tick(&mut self, saving: bool) {
self.last = self.value;
self.value += (if saving { 1.0 } else { 0.0 } - self.value) * 0.2;
}
/// `Hud.extractSavingIndicator` without the option check: the text in the
/// bottom right corner.
pub fn draw(
&self,
w: i32,
h: i32,
partial: f32,
font: &Font,
label: Text,
background: u32,
) -> Vec<GuiDraw> {
let mut out = Vec::new();
if self.value <= 0.0 && self.last <= 0.0 {
return out;
}
let fade = (self.last + (self.value - self.last) * partial).clamp(0.0, 1.0);
let alpha = (255.0 * fade).floor() as u32;
if alpha > 0 {
let label_width = width(font, &label);
let color = crate::hud_layout::argb(alpha, 255, 255, 255);
text_with_backdrop(
&mut out,
label,
w - label_width - 5,
h - 9 - 5,
label_width,
color,
background,
);
}
out
}
}
/// Titles and the action bar: `Hud.title`, `subtitle`, `titleTime`, the
/// fade times and `overlayMessageString` / `overlayMessageTime`.
#[derive(Clone, Debug, PartialEq)]
+17
View File
@@ -52,6 +52,8 @@ pub struct StackData {
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
/// `minecraft:bundle_contents`.
pub bundle_contents: Vec<ItemStack>,
/// `BundleContents.selectedItem`: runtime only, never saved or sent.
pub bundle_selected: BundleSelected,
/// `minecraft:suspicious_stew_effects`: (effect id, duration).
pub stew_effects: Vec<(Arc<str>, i32)>,
/// `minecraft:map_post_processing`: 0 lock, 1 scale.
@@ -60,6 +62,20 @@ pub struct StackData {
/// max stack size...), kept as their saved values in key order.
pub other: Vec<(String, Tag)>,
}
/// A bundle's selected item index (-1: none). Like vanilla's
/// `BundleContents.equals` it is left out when stacks are compared.
#[derive(Clone, Copy, Debug)]
pub struct BundleSelected(pub i32);
impl Default for BundleSelected {
fn default() -> Self {
Self(-1)
}
}
impl PartialEq for BundleSelected {
fn eq(&self, _: &Self) -> bool {
true
}
}
/// The components `components_tag` writes from modelled fields.
const MODELLED: &[&str] = &[
"minecraft:banner_patterns",
@@ -791,6 +807,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
Arc::from(t.get("pattern").and_then(string)?.as_str()),
))
}),
bundle_selected: BundleSelected::default(),
other: Vec::new(),
};
let mut data = data;
+6 -2
View File
@@ -12,7 +12,7 @@ mod special;
use crate::{
block_entity_render::{ModelPart, diffuse},
hud::Rect,
mesher::Vertex,
mesher::{CULL_BACK, Vertex},
};
use glam::{Mat3, Mat4, Vec3};
use horizon_assets::{
@@ -46,6 +46,8 @@ pub struct ItemQuad {
pub foil: Foil,
/// `MaterialInfo.lightEmission` (block light floor).
pub light_emission: u8,
/// The render type culls back faces (`entitySolid`): world drawing discards them.
pub cull_back: bool,
}
/// Which glint render type a quad uses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -246,6 +248,7 @@ fn submit_quads(
color: argb(color),
foil,
light_emission: q.light_emission,
cull_back: false,
});
}
}
@@ -653,6 +656,7 @@ pub fn tag_json(tag: &Tag) -> Value {
/// The resolver's view of a client stack: its item, count and component patch.
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
let mut view = StackView::new(&stack.item, stack.count as i32);
view.bundle_selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) {
for (k, v) in &c {
let key = k.to_string_lossy();
@@ -696,7 +700,7 @@ impl ItemSink for WorldSink<'_> {
let overlay = if q.foil == Foil::None { 0 } else { 7 };
for (p, uv) in q.pos.iter().zip(q.uv) {
self.out.push(Vertex {
normal: [0; 4],
normal: if q.cull_back { CULL_BACK } else { [0; 4] },
pos: p.to_array(),
uv,
layer: overlay,
@@ -358,6 +358,7 @@ mod tests {
color,
foil: Foil::None,
light_emission: 0,
cull_back: false,
}
}
@@ -65,6 +65,17 @@ pub(super) fn load_parts(root: &Path) -> HashMap<String, ModelPart> {
.collect()
}
/// Marks every quad passed on as drawn with a back-face-culling render type (`entitySolid`).
struct CullBack<'a>(&'a mut dyn ItemSink);
impl ItemSink for CullBack<'_> {
fn quad(&mut self, quad: ItemQuad) {
self.0.quad(ItemQuad {
cull_back: true,
..quad
});
}
}
/// A pose override for a named part (`setupAnim`).
type Adjust<'a> = &'a dyn Fn(&str, &mut [f32; 9]);
fn none(_: &str, _: &mut [f32; 9]) {}
@@ -119,6 +130,7 @@ fn draw(
color,
foil,
light_emission: 0,
cull_back: false,
});
}
for (child_name, child) in &part.children {
@@ -569,6 +581,8 @@ pub(super) fn submit(
}
SpecialKind::DecoratedPot => {
let sides = pot_sides(argument);
// `DecoratedPotRenderer` draws every part with `entitySolid`.
let sink = &mut CullBack(sink);
if let Some(base) = renderer.part("decorated_pot_base")
&& let Some(r) = sprites
.get("entity/decorated_pot/decorated_pot_base")
+266 -45
View File
@@ -108,6 +108,32 @@ fn modifier_line(
)
}
/// `Attribute.getStyle`: the colour of a line for an attribute whose value
/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning
/// time and fall damage are negative, every other attribute is positive.
fn attribute_color(attribute: &str, rises: bool) -> u32 {
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
match attribute {
"burning_time" | "fall_damage_multiplier" => {
if rises {
red
} else {
blue
}
}
"gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => {
rgba(170, 170, 170, 255)
}
_ => {
if rises {
blue
} else {
red
}
}
}
}
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
/// attack damage/speed bases. Recipe search has no player context.
pub fn attribute_lines(
@@ -150,10 +176,8 @@ pub fn attribute_lines(
) {
let (line, color) = if base.is_some() {
(format!(" {line}"), rgba(0, 170, 0, 255))
} else if *amount > 0.0 {
(line, rgba(85, 85, 255, 255))
} else {
(line, rgba(255, 85, 85, 255))
(line, attribute_color(path, *amount > 0.0))
};
lines.push((line, color));
}
@@ -231,6 +255,235 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
}
}
fn is_beehive(path: &str) -> bool {
matches!(path, "beehive" | "bee_nest")
}
/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat
/// horn without the component has the item's default, the ponder horn.
pub fn instrument_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:instrument") {
return None;
}
let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) {
Some(Tag::String(id)) => id.to_string_lossy(),
// An inline instrument carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(),
None => return None,
};
let key = format!("instrument.{}", id.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without
/// the component has the item's default, none).
pub fn bees_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:bees") {
return None;
}
let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) {
Some(Tag::List(bees)) => bees.len(),
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into());
Some(
text.replacen("%s", &bees.to_string(), 1)
.replacen("%s", "3", 1),
)
}
/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive
/// item without the component has the item's default, 0).
pub fn honey_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:block_state") {
return None;
}
let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) {
Some(Tag::Compound(state)) => {
match state.get(&horizon_world::nbt::JavaString::from("honey_level")) {
Some(Tag::String(level)) => level.to_string_lossy().parse::<u32>().ok()?,
_ => return None,
}
}
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into());
Some(
text.replacen("%s", &level.to_string(), 1)
.replacen("%s", "5", 1),
)
}
/// `WrittenBookContent.addToTooltip`: "by <author>" and the generation.
pub fn written_book_lines(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let Some(book) = stack
.data()
.and_then(|d| d.written.as_ref())
.filter(|_| display.shows("minecraft:written_book_content"))
else {
return Vec::new();
};
let mut lines = Vec::new();
if !book.author.trim().is_empty() {
lines.push(
crate::text::format_translation(
&text("book.byAuthor"),
&[crate::text::Text::literal(&book.author)],
)
.plain(),
);
}
lines.push(text(&format!("book.generation.{}", book.generation)));
lines
}
/// `JukeboxPlayable.addToTooltip`: the song's description.
pub fn jukebox_line(
stack: &ItemStack,
info: Option<&ItemInfo>,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:jukebox_playable") {
return None;
}
let song = match stack
.data()
.and_then(|d| d.other("minecraft:jukebox_playable"))
{
Some(Tag::String(song)) => song.to_string_lossy(),
// An inline song carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None => info?.jukebox_song.clone()?,
};
let key = format!("jukebox_song.{}", song.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced
/// tooltips the colour's hex code.
pub fn dyed_line(
stack: &ItemStack,
display: &TooltipDisplay,
advanced: bool,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<(String, bool)> {
let rgb = stack.data()?.dyed_color?;
if !display.shows("minecraft:dyed_color") {
return None;
}
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
Some(if advanced {
let hex = format!("#{:06X}", rgb & 0xFF_FFFF);
(text("item.color").replacen("%s", &hex, 1), false)
} else {
(text("item.dyed"), true)
})
}
/// `ItemStack.UNBREAKABLE_TOOLTIP`.
pub fn unbreakable_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
(stack.data()?.other("minecraft:unbreakable").is_some()
&& display.shows("minecraft:unbreakable"))
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
}
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
/// for a loss); nothing for a potion whose effects add none.
pub fn potion_modifier_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
if !is_potion_item(&stack.item) {
return Vec::new();
}
let applied: Vec<(String, u32)> = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
let amount = amount * (*amplifier + 1) as f64;
let line = modifier_line(translate, attribute, amount, operation, false)?;
let color = if amount > 0.0 {
rgba(85, 85, 255, 255)
} else {
rgba(255, 85, 85, 255)
};
Some((line, color))
})
.collect();
if applied.is_empty() {
return applied;
}
let mut lines = vec![
(String::new(), u32::MAX),
(
translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()),
rgba(170, 0, 170, 255),
),
];
lines.extend(applied);
lines
}
/// `ItemStack.components.size()`: the item's default components with the
/// stack's own changes applied.
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
let mut keys: std::collections::HashSet<String> = info
.map(|i| i.components.iter().cloned().collect())
.unwrap_or_default();
if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data())
{
for key in patch.keys() {
let key = key.to_string_lossy();
match key.strip_prefix('!') {
Some(removed) => keys.remove(removed),
None => keys.insert(key),
};
}
}
keys.len()
}
/// The lines of `stack` below its name that its components give, in
/// `ItemStack.addDetailsToTooltip` order.
pub fn detail_lines(
@@ -265,19 +518,9 @@ pub fn detail_lines(
))
));
}
// BeehiveBlock: the block state components of the item.
if path == "beehive" {
lines.push(
text("container.beehive.bees")
.replacen("%s", "0", 1)
.replacen("%s", "3", 1),
);
lines.push(
text("container.beehive.honey")
.replacen("%s", "0", 1)
.replacen("%s", "5", 1),
);
}
let display = TooltipDisplay::for_stack(stack);
lines.extend(bees_line(stack, &display, translate));
lines.extend(honey_line(stack, &display, translate));
let data = stack.data();
// PotDecorations.addToTooltip.
if let Some(sherds) = data
@@ -303,32 +546,13 @@ pub fn detail_lines(
.into_iter()
.map(|l| l.0),
);
let applied: Vec<String> = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
modifier_line(
translate,
attribute,
amount * (*amplifier + 1) as f64,
operation,
false,
)
})
.collect();
if !applied.is_empty() {
lines.push(String::new());
lines.push(text("potion.whenDrank"));
lines.extend(applied);
}
}
// JukeboxPlayable.addToTooltip: the song's description.
if path.starts_with("music_disc_") {
lines.push(text(&format!("item.minecraft.{path}.desc")));
lines.extend(
potion_modifier_lines(stack, translate)
.into_iter()
.map(|l| l.0),
);
}
lines.extend(jukebox_line(stack, info, &display, translate));
// Trim.addToTooltip.
if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) {
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
@@ -342,10 +566,7 @@ pub fn detail_lines(
text(&format!("trim_material.minecraft.{}", short(material)))
));
}
// DyedItemColor.addToTooltip (without advanced tooltips).
if data.is_some_and(|d| d.dyed_color.is_some()) {
lines.push(text("item.dyed"));
}
lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0));
if let Some(info) = info {
lines.extend(
attribute_lines(&info.modifiers, None, translate)
+6
View File
@@ -131,6 +131,8 @@ pub struct ItemInfo {
pub block_transformer: Option<String>,
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
pub bundle: bool,
/// The ids of the item's default components (its prototype's keys).
pub components: Vec<String>,
}
/// `Item.Properties.craftRemainder` (not a data component, so not in the
/// default-component report): what an item leaves in the grid when crafted with.
@@ -650,6 +652,10 @@ impl Items {
cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned),
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
bundle: c.get("minecraft:bundle_contents").is_some(),
components: c
.as_object()
.map(|c| c.keys().cloned().collect())
.unwrap_or_default(),
crafting_remainder: CRAFT_REMAINDERS
.iter()
.find(|(item, _)| *item == name)
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more