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 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 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 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 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
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
Kacpherek 6120c8c0ad Attack the block when a guest's START_DESTROY_BLOCK arrives 2026-10-26 22:03:17 +01:00
Kacpherek 53ea3e7224 Play the rain sound for this client only 2026-10-26 22:00:04 +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
Kacpherek da736d56d6 Lingering clouds skip mobs none of their effects can affect 2026-10-26 21:30:42 +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 484cc945aa Add the rockets list to the remote motion test fixture 2026-10-26 19:23:52 +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 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 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
223 changed files with 4934 additions and 8800 deletions

No files matched your search

-3
View File
@@ -72,9 +72,6 @@ jobs:
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
-1
View File
@@ -66,7 +66,6 @@ reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` an
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client --features horizon-client/mods --lib mod_content::tests:: -- --ignored # the 5 slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
Generated
+2 -122
View File
@@ -477,13 +477,6 @@ dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "coords-hud-lua"
version = "0.1.0"
dependencies = [
"horizon-mod-lua",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -937,13 +930,6 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "far-view"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1107,30 +1093,6 @@ dependencies = [
"slab",
]
[[package]]
name = "gc-arena"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cd70cf88a32937834aae9614ff2569b5d9467fa0c42c5d7762fd94a8de88266"
dependencies = [
"allocator-api2",
"gc-arena-derive",
"hashbrown 0.14.5",
"sptr",
]
[[package]]
name = "gc-arena-derive"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c612a69f5557a11046b77a7408d2836fe77077f842171cd211c5ef504bd3cddd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
"synstructure 0.13.2",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -1295,16 +1257,6 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -1509,16 +1461,6 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-mod-lua"
version = "0.1.0"
dependencies = [
"getrandom 0.2.17",
"getrandom 0.3.4",
"horizon-mod-sdk",
"piccolo",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
@@ -2795,21 +2737,6 @@ dependencies = [
"indexmap",
]
[[package]]
name = "piccolo"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "003bf52de285e1ff1adcbc6572588db3849988ea660a2d55af3a2ffbc81f597f"
dependencies = [
"ahash",
"allocator-api2",
"anyhow",
"gc-arena",
"hashbrown 0.14.5",
"rand",
"thiserror 1.0.69",
]
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2937,15 +2864,6 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "predator-missile"
version = "0.1.0"
@@ -3046,27 +2964,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3546,12 +3443,6 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "sptr"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b9b39299b249ad65f3b7e96443bad61c02ca5cd3589f46cb6d610a0fd6c0d6a"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3669,17 +3560,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "synstructure"
version = "0.14.0"
@@ -5341,7 +5221,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure 0.14.0",
"synstructure",
]
[[package]]
@@ -5382,7 +5262,7 @@ dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure 0.14.0",
"synstructure",
]
[[package]]
-3
View File
@@ -30,18 +30,15 @@ members = [
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/coords-hud-lua",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/far-view",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
"crates/horizon-mod-lua",
]
resolver = "2"
+8 -54
View File
@@ -1,7 +1,7 @@
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
//!
//! - `new <path> [--template code|content|lua] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, a content mod (no code), or a Lua mod.
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
//! a mod from a template: a Rust crate, or a content mod (no code).
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
@@ -26,8 +26,6 @@ use std::{
const TARGET: &str = "wasm32-unknown-unknown";
const SDK_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\" }";
/// The Lua runtime from the same repository, with the template's capability.
const LUA_GIT: &str = "{ git = \"https://github.com/ludovic111/horizon\", features = [\"chat\"] }";
fn main() -> ExitCode {
// Cargo runs `cargo-horizon-mod horizon-mod <args>`.
@@ -44,7 +42,7 @@ fn main() -> ExitCode {
Some("test") => test(&args[1..]),
Some("check") => check(&args[1..]),
Some("registry") => registry::main(&args[1..]),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content|lua] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | test [--debug] [--hmod <file>] [<name>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
};
match result {
Ok(()) => ExitCode::SUCCESS,
@@ -85,26 +83,18 @@ fn new(args: &[String]) -> Result<(), String> {
})
.collect();
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_string);
let (sdk, lua) = match flag(args, "--sdk-path") {
let sdk = match flag(args, "--sdk-path") {
Some(p) => {
let p = std::fs::canonicalize(p).map_err(|e| format!("--sdk-path {p}: {e}"))?;
let lua = p.parent().unwrap_or(&p).join("horizon-mod-lua");
(
format!("{{ path = {:?} }}", p.display().to_string()),
format!(
"{{ path = {:?}, features = [\"chat\"] }}",
lua.display().to_string()
),
)
format!("{{ path = {:?} }}", p.display().to_string())
}
None => (SDK_GIT.to_string(), LUA_GIT.to_string()),
None => SDK_GIT.to_string(),
};
let fill = |t: &str| {
t.replace("{crate}", &id.replace('_', "-"))
.replace("{id}", &id)
.replace("{name}", &name)
.replace("{sdk}", &sdk)
.replace("{lua}", &lua)
};
let code = [
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
@@ -156,19 +146,10 @@ fn new(args: &[String]) -> Result<(), String> {
),
(".gitignore", include_str!("../template/gitignore")),
];
// The code template's mod, written in Lua.
let lua_files = [
("Cargo.toml", include_str!("../template/lua/Cargo.toml.in")),
(MANIFEST, include_str!("../template/lua/horizon-mod.toml")),
("src/lib.rs", include_str!("../template/lua/src/lib.rs")),
("main.lua", include_str!("../template/lua/main.lua")),
(".gitignore", include_str!("../template/gitignore")),
];
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
"code" => &code,
"content" => &content,
"lua" => &lua_files,
other => return Err(format!("no template {other}: code, content or lua")),
other => return Err(format!("no template {other}: code or content")),
};
for &(file, text) in files {
let file = fill(file);
@@ -234,13 +215,6 @@ fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut cmd = Command::new(cargo);
// The Lua runtime's seeds (crates/horizon-mod-lua): getrandom 0.3
// takes a custom source only with this cfg.
let flags = std::env::var("CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS").unwrap_or_default();
cmd.env(
"CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS",
format!("{flags} --cfg getrandom_backend=\"custom\"").trim(),
);
cmd.current_dir(root).args([
"build",
"--lib",
@@ -412,26 +386,6 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_lua_template_is_a_valid_mod() {
let dir = std::env::temp_dir().join(format!("horizon-mod-lua-{}", std::process::id()));
let path = dir.join("hello-lua");
new(&[
path.display().to_string(),
"--template".into(),
"lua".into(),
])
.unwrap();
let manifest =
Manifest::parse(&std::fs::read_to_string(path.join(MANIFEST)).unwrap()).unwrap();
assert_eq!(manifest.capabilities, [horizon_mods::Capability::Chat]);
let cargo = std::fs::read_to_string(path.join("Cargo.toml")).unwrap();
assert!(cargo.contains("horizon-mod-lua = ") && cargo.contains(LUA_GIT));
let script = std::fs::read_to_string(path.join("main.lua")).unwrap();
assert!(script.contains("horizon.command(\"hello\")"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_content_template_builds_and_checks_with_no_code() {
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
@@ -470,7 +424,7 @@ mod tests {
new(&[
dir.join("x").display().to_string(),
"--template".into(),
"python".into()
"lua".into()
])
.is_err()
);
@@ -1,17 +0,0 @@
[package]
name = "{crate}"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
# The Lua interpreter, built into the mod with main.lua. Its features are the
# capabilities horizon-mod.toml lists (game-read, chat, hud, storage): keep
# the two lists the same.
[dependencies]
horizon-mod-lua = {lua}
[profile.release]
opt-level = "s"
lto = true
@@ -1,12 +0,0 @@
id = "{id}"
name = "{name}"
version = "0.1.0"
description = ""
authors = []
# The Horizon mod API the Lua runtime targets.
api = "0.1"
# client: runs in the player's game.
side = "client"
# What the mod may do, shown to players before they turn it on:
# game-read (position, health, time), chat (show messages), hud (draw text).
capabilities = ["chat"]
@@ -1,12 +0,0 @@
-- {name}: a Horizon mod in Lua (https://playhorizon.dev/docs/mod-code).
-- Commands come from horizon.command; hooks are global functions:
-- on_tick(tick), on_frame(seconds), on_command(name, args), on_chat(text).
local greeted = 0
horizon.command("hello")
function on_command(name, args)
greeted = greeted + 1
horizon.chat.show(string.format("Hello %s! (%d so far)", args, greeted))
end
@@ -1,2 +0,0 @@
// The mod is main.lua: this builds it into the mod with its interpreter.
horizon_mod_lua::lua_mod!("main.lua");
@@ -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))
});
}
@@ -154,11 +154,11 @@ const MEMORIES: [&str; 7] = [
fn variant(m: &Mob, regs: &SpawnRegistries, v: &mut Vec<String>) {
use Species as S;
let s = m.species();
let named = |reg: &finalize::VariantRegistry, i: Option<u8>| {
let name = i.map_or("classic", |i| {
let named = |reg: &finalize::VariantRegistry, i: u8| {
format!(
"minecraft:{}",
reg.names.get(i as usize).map_or("?", |n| n.as_str())
});
format!("minecraft:{name}")
)
};
let var = format!("minecraft:{}", finalize::variant_name(s, m.extra.variant));
match s {
@@ -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,
@@ -5,10 +5,6 @@
//!
//! usage: recipe_book_fixture RESOURCES CASES OUTPUT
use horizon_client::recipe_book::{BookType, RecipeIndex, Settings};
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::recipes::RecipeBookChangeSettings,
};
use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{collections::BTreeSet, fs, path::Path};
@@ -97,23 +93,8 @@ pub fn main() -> horizon_client::Result<()> {
_ => BookType::Smoker,
}
.index();
let packet = RecipeBookChangeSettings {
book_type: i as i32,
open: op["open"].as_bool().unwrap_or(false),
filtering: op["filtering"].as_bool().unwrap_or(false),
};
let mut wire = Writer::new();
RecipeBookChangeSettings::write(&packet, &mut wire);
extra = format!(
" packet={}",
wire.data
.iter()
.map(|b| format!("{b:02x}"))
.collect::<String>()
);
let decoded = RecipeBookChangeSettings::read(&mut Reader::new(&wire.data))?;
book.open[decoded.book_type as usize] = decoded.open;
book.filtering[decoded.book_type as usize] = decoded.filtering;
book.open[i] = op["open"].as_bool().unwrap_or(false);
book.filtering[i] = op["filtering"].as_bool().unwrap_or(false);
}
"save" => {
let tag = book.write();
@@ -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>,
+235 -1
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::{
@@ -73,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 {
@@ -87,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),
@@ -167,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") =>
{
@@ -237,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> {
@@ -302,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"),
@@ -335,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.
@@ -383,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
@@ -1027,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();
@@ -1282,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();
+3
View File
@@ -159,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,
@@ -208,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(),
@@ -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 => {
+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,
+8 -11
View File
@@ -76,11 +76,14 @@ impl<'a> IntoIterator for &'a ChunkMap {
}
}
impl crate::light::LightWorld for ChunkMap {
fn section(&self, cx: i32, s: usize, cz: i32) -> Option<&Arc<crate::chunk::Section>> {
self.columns.section(cx, s, cz)
fn state(&self, x: i32, y: i32, z: i32) -> Option<u32> {
self.columns.state(x, y, z)
}
fn set_section_light(&mut self, cx: i32, s: usize, cz: i32, light: crate::chunk::Light) {
self.columns.set_section_light(cx, s, cz, light);
fn light(&self, x: i32, y: i32, z: i32) -> Option<u8> {
self.columns.light(x, y, z)
}
fn set_light(&mut self, x: i32, y: i32, z: i32, packed: u8) {
self.columns.set_light(x, y, z, packed);
}
}
@@ -103,13 +106,7 @@ mod tests {
for column in map.values_mut() {
Arc::make_mut(column).light_loaded = true;
}
crate::light::LightWorld::set_section_light(
&mut map,
0,
0,
0,
crate::chunk::Light::Uniform(0),
);
crate::light::LightWorld::set_light(&mut map, 0, 0, 0, 0);
assert_eq!(inserted, map.membership_revision());
map.remove(&(0, 0));
map.insert((1, 0), chunk);
+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]);
}
+9 -17
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);
@@ -219,22 +224,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
fn celestial_color(in: Out) -> vec4<f32> {
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
return celestial_color(in);
}
// The sky's celestial pass (sun, moon, the End sky): fs_celestial's light,
// premultiplied for an additive (one, one) blend, and as alpha how much of it
// the texel adds, which the blend takes off the frame's alpha. HDR output
// (hdr.wgsl) lifts only where that left it low: the sun's and moon's discs, not
// the dark rest of their quads or the dim End sky.
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
let color = textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
return color;
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
+34 -204
View File
@@ -244,51 +244,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
let species = m.species();
if let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
map.insert("variant".into(), string(&variant));
}
if species == crate::mobs::Species::Chicken {
map.insert("EggLayTime".into(), Tag::Int(m.extra.egg_time));
}
let sounds = crate::mobs::finalize::sound_variants(species);
if !sounds.is_empty() {
let sound = m.extra.sound_variant.and_then(|i| sounds.get(i as usize));
let sound = sound.copied().unwrap_or("classic");
map.insert(
"sound_variant".into(),
string(&format!("minecraft:{sound}")),
);
}
match id {
// Fox.addAdditionalSaveData; Horizon models only the type.
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
for (key, value) in [
("Trusted", Tag::List(Vec::new())),
("Sleeping", Tag::Byte(0)),
("Sitting", Tag::Byte(0)),
("Crouching", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
}
"minecraft:goat" => {
map.insert(
"IsScreamingGoat".into(),
Tag::Byte(i8::from(m.extra.screaming)),
);
let [left, right] = m.extra.lost_horns;
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
}
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
map.entry(key.into()).or_insert(value);
@@ -304,6 +260,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::CAT_VARIANTS[(m.extra.variant as usize).min(10)];
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:wolf" => {
if let Some(owner) = m.extra.owner {
@@ -313,6 +273,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
map.insert("Sitting".into(), Tag::Byte(i8::from(m.extra.sitting)));
map.insert("CollarColor".into(), Tag::Byte(m.extra.color as i8));
let variant = crate::mobs::tame::WOLF_VARIANTS[(m.extra.variant as usize).min(8)].0;
map.insert("variant".into(), string(&format!("minecraft:{variant}")));
map.entry("sound_variant".into())
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
@@ -347,10 +311,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
| "minecraft:trader_llama" => {
map.insert("Tame".into(), Tag::Byte(i8::from(m.extra.tame)));
map.insert("Temper".into(), Tag::Int(i32::from(m.extra.variant3)));
// AbstractHorse.addAdditionalSaveData; Horizon models neither.
for key in ["EatingHaystack", "Bred"] {
map.entry(key.into()).or_insert(Tag::Byte(0));
}
if let Some(owner) = m.extra.owner {
map.insert("Owner".into(), Tag::IntArray(owner.to_vec()));
}
@@ -527,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));
@@ -840,12 +804,6 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
if crate::mobs::equine::is_equine(m.species()) {
m.health = m.health.min(m.attrs.max_health);
}
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
if let Some(eq) = tag.get("equipment") {
@@ -981,45 +939,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
}
if m.species() == crate::mobs::Species::Chicken
&& let Some(t) = number(tag.get("EggLayTime"))
{
m.extra.egg_time = t as i32;
}
if let Some(v) = text(tag.get("sound_variant")) {
let v = v.trim_start_matches("minecraft:");
let sounds = crate::mobs::finalize::sound_variants(m.species());
m.extra.sound_variant = sounds.iter().position(|n| *n == v).map(|i| i as u8);
}
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
m.extra.variant = match number(tag.get("RabbitType")).unwrap_or(0) {
v @ (0..=5 | 99) => v as u8,
_ => 0,
};
}
crate::mobs::Species::Goat => {
m.extra.screaming = flag(tag, "IsScreamingGoat");
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
}
_ => {}
}
if m.species() == crate::mobs::Species::Cat {
m.extra.owner = crate::save::uuid_from_tag(tag.get("Owner"));
m.extra.tame = m.extra.owner.is_some();
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::CAT_VARIANTS
.iter()
.position(|n| *n == v)
.unwrap_or(0) as u8;
}
}
if m.species() == crate::mobs::Species::Wolf {
let health = m.health;
@@ -1030,6 +963,14 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.sitting = flag(tag, "Sitting");
m.extra.color =
number(tag.get("CollarColor")).map_or(crate::mobs::tame::RED, |c| (c as u8) & 15);
if let Some(Tag::String(v)) = tag.get("variant") {
let v = v.to_string().unwrap_or_default();
let v = v.trim_start_matches("minecraft:");
m.extra.variant = crate::mobs::tame::WOLF_VARIANTS
.iter()
.position(|(n, _)| *n == v)
.unwrap_or(0) as u8;
}
}
if matches!(
m.species(),
@@ -1128,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;
@@ -2630,123 +2577,6 @@ mod sheep_nbt_tests {
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
#[test]
fn farm_animals_keep_their_variant_and_sound_variant() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
types["minecraft:pig"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
);
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:classic")
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
types["minecraft:chicken"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
let registries = finalize::SpawnRegistries::load(&root);
for (species, registry) in [
(Species::Pig, &registries.pig_sound),
(Species::Cow, &registries.cow_sound),
(Species::Chicken, &registries.chicken_sound),
(Species::Cat, &registries.cat_sound),
(Species::Wolf, &registries.wolf_sound),
] {
assert_eq!(
finalize::sound_variants(species),
registry.names,
"{species:?}"
);
}
}
#[test]
fn foxes_rabbits_and_goats_keep_their_kind() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
};
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
assert_eq!(text(tag.get("Type")).as_deref(), Some("snow"));
assert_eq!(number(tag.get("Sleeping")), Some(0));
assert_eq!(back.extra.variant, 1);
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
assert_eq!(number(tag.get("RabbitType")), Some(4));
assert_eq!(back.extra.variant, 4);
let mut goat = new("minecraft:goat");
(goat.extra.screaming, goat.extra.lost_horns) = (true, [false, true]);
let (back, tag) = round_trip(&goat);
assert_eq!(number(tag.get("HasRightHorn")), Some(0));
assert_eq!(
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
[false; 2]
);
}
}
#[cfg(test)]
+284 -188
View File
@@ -157,7 +157,6 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
@@ -509,12 +508,6 @@ enum Screen {
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// ServerLinksDialogScreen: a server's links (game/server_links.rs).
ServerLinks,
/// ConfirmLinkScreen for a server link (game/server_links.rs).
ConfirmLink,
/// DialogScreen.WarningScreen: the note about custom screens.
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
@@ -571,6 +564,8 @@ struct GameLevel<'w> {
player_collision: Option<PlayerCollision>,
/// Minecart boxes (detector rails).
cart_boxes: Vec<([f64; 3], [f64; 3])>,
/// Each minecart's kind and comparator signal, in `cart_boxes` order.
cart_signals: Vec<(minecarts::CartKind, i32)>,
/// Arrow and thrown trident boxes (wooden buttons).
arrow_boxes: Vec<([f64; 3], [f64; 3])>,
/// The other entities that keep blocks from being placed into them.
@@ -649,6 +644,37 @@ impl EntityObstructionWorld for BuildingObstruction {
}
}
impl GameLevel<'_> {
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
/// minecart on it, a command block cart first (its success count, which
/// is 0 here), else the first container cart's fullness.
fn detector_rail_signal(&self, pos: BlockPos, powered: bool) -> i32 {
if !powered {
return 0;
}
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let area = Aabb::new(
Vec3::new(x + 0.2, y, z + 0.2),
Vec3::new(x + 0.8, y + 0.8, z + 0.8),
);
let mut container = None;
for ((lo, hi), &(kind, signal)) in self.cart_boxes.iter().zip(&self.cart_signals) {
let cart = Aabb::new(
Vec3::new(lo[0], lo[1], lo[2]),
Vec3::new(hi[0], hi[1], hi[2]),
);
if !area.intersects(cart) {
continue;
}
match kind {
minecarts::CartKind::CommandBlock => return 0,
minecarts::CartKind::Chest | minecarts::CartKind::Hopper => {
container = container.or(Some(signal));
}
_ => {}
}
}
container.unwrap_or(0)
}
/// Every entity box that blocks building: the players who aren't
/// spectators, the minecarts and the `Blockers` (a removed mob or a marker
/// armor stand, whose box is empty, doesn't).
@@ -709,6 +735,14 @@ impl LevelAccess for GameLevel<'_> {
state: BlockStateId,
_direction: Direction,
) -> i32 {
if let Some(s) = self.registry.state(state)
&& s.name == "minecraft:detector_rail"
{
return self.detector_rail_signal(
pos,
s.properties.get("powered").is_some_and(|v| v == "true"),
);
}
if let Some(signal) = command_blocks::analog_signal(self.world, (pos.x, pos.y, pos.z)) {
return signal;
}
@@ -1483,6 +1517,8 @@ struct Game<'a> {
/// `ClientAvatarState`: walked distance and view bob.
avatar: horizon_client::camera::AvatarBob,
flash: u32,
/// `ClientLevel.rainSoundTime`: ticks of rain since the last rain sound.
rain_sound_time: i32,
/// Bolts that struck here and sky flashes they made, in total (`status`).
lightning_seen: [u32; 2],
/// The head of the bed while sleeping; the visual timer also survives waking.
@@ -1490,6 +1526,8 @@ struct Game<'a> {
sleep_timer: u32,
dim: Dim,
world_root: PathBuf,
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
world_flat: bool,
/// A player carried through a portal, re-placed once the destination loads.
travelling: Option<(PlayerMotion, Vec3)>,
/// A server's players on their way to another level, each waiting for
@@ -1565,6 +1603,12 @@ struct Game<'a> {
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
/// The fence knots a server tracks to this client, by entity id, at their
/// block (`LeashFenceKnotEntity`).
remote_knots: HashMap<u32, [i32; 3]>,
/// The entity ids a hosted server gives the knots its mobs hang on, by
/// block, while a lead is tied there.
knot_ids: HashMap<[i32; 3], u32>,
text_display_vertices: usize,
/// Player skin downloads, and which skin each atlas skin slot holds
/// (least recently shown first).
@@ -1682,7 +1726,6 @@ struct Game<'a> {
guest_use: Option<GuestUse>,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_cushion_breaks: Vec<(horizon_world::registry::BlockStateId, [f64; 3])>,
/// Block events (position, b0, b1, state) and entity events (id, event,
/// position) for the guests.
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
@@ -1706,9 +1749,6 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
@@ -1743,8 +1783,6 @@ struct Game<'a> {
restore_player_state: bool,
stats_ui: stats_screen::StatsUi,
adv_ui: advancements_screen::AdvUi,
/// The Server Links screens' state (game/server_links.rs).
links_ui: server_links::LinksUi,
camera_type: perspective::CameraType,
fangs: fangs::FangState,
/// The local player as a model (third person, the inventory).
@@ -2575,8 +2613,14 @@ impl Game<'_> {
pickups::Taken::Item(Box::new(item.clone())),
pickups::Collector::Player,
);
// handleTakeItemEntity: the random pitch of a full pickup.
let pitch = ((self.world.random.next_float()
- self.world.random.next_float())
* 0.7
+ 1.0)
* 2.0;
self.audio
.play("minecraft:entity.item.pickup", Some(feet), 0.2, 1.0);
.play("minecraft:entity.item.pickup", Some(feet), 0.2, pitch);
}
item.stack = rest;
}
@@ -2633,6 +2677,7 @@ impl Game<'_> {
)
})
.collect();
let cart_signals = self.cart_signals();
let arrow_boxes = self
.creatures
.projectiles
@@ -2675,6 +2720,7 @@ impl Game<'_> {
};
let mut level = GameLevel {
cart_boxes,
cart_signals,
arrow_boxes,
blockers,
world: &mut self.world,
@@ -2729,9 +2775,13 @@ impl Game<'_> {
self.process_level_events()
}
fn attack_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]));
let note = self.with_level(|b, level, ticks| {
b.attack_block(level, ticks, BlockPos::new(pos[0], pos[1], pos[2]))
});
// NoteBlock.attack awards the statistic.
if note {
self.stats.custom("play_noteblock", 1);
}
self.process_level_events()
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
@@ -3032,11 +3082,6 @@ impl Game<'_> {
pos,
data,
} => self.dragon_egg_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2016,
pos,
data,
} => self.shulker_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent {
id: 2017,
pos,
@@ -3071,6 +3116,34 @@ impl Game<'_> {
pos,
data,
} => self.composter_fill_event(pos, data == 1),
// LevelEventHandler 1504: the stalactite's drip particle.
LevelEvent::LevelEvent { id: 1504, pos, .. } => self.dripstone_drip_particle(pos),
// LevelEventHandler 2006: the dragon fireball's burst.
LevelEvent::LevelEvent {
id: 2006,
pos,
data,
} => self.dragon_fireball_burst(pos, data),
// LevelEventHandler 2009: a wet sponge drying.
LevelEvent::LevelEvent { id: 2009, pos, .. } => {
self.puffs_above_block(pos, horizon_client::particles::ParticleType::Cloud)
}
// LevelEventHandler 2012: a turtle egg laid.
LevelEvent::LevelEvent {
id: 2012,
pos,
data,
} => self.turtle_egg_particles(pos, data),
// LevelEventHandler 2014: a straw bed destroyed, with no sound.
LevelEvent::LevelEvent {
id: 2014,
pos,
data,
} => self.destroy_particles([pos.x, pos.y, pos.z], BlockStateId(data as u32)),
// LevelEventHandler 3009: a sniffer egg cracking.
LevelEvent::LevelEvent { id: 3009, pos, .. } => {
self.sniffer_egg_crack_particles(pos)
}
LevelEvent::LevelEvent {
id: id @ 3011..=3021,
pos,
@@ -3086,6 +3159,16 @@ impl Game<'_> {
1501 => Some(("minecraft:block.lava.extinguish", 0.5)),
1009 => Some(("minecraft:block.fire.extinguish", 0.5)),
1502 => Some(("minecraft:block.redstone_torch.burnout", 0.5)),
1033 => Some(("minecraft:block.chorus_flower.grow", 1.0)),
1034 => Some(("minecraft:block.chorus_flower.death", 1.0)),
1046 => Some((
"minecraft:block.pointed_dripstone.drip_lava_into_cauldron",
2.0,
)),
1047 => Some((
"minecraft:block.pointed_dripstone.drip_water_into_cauldron",
2.0,
)),
// LevelEvent.SOUND_ZOMBIE_WOODEN_DOOR (attack, break).
1019 => Some(("minecraft:entity.zombie.attack_wooden_door", 2.0)),
1021 => Some(("minecraft:entity.zombie.break_wooden_door", 2.0)),
@@ -3107,6 +3190,13 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
// LevelEventHandler draws these from the client
// level's random, before 1501's and 1502's smoke.
1501 | 1502 => {
let r = self.particles.level_random();
2.6 + (r.next_float() - r.next_float()) * 0.8
}
1046 | 1047 => self.particles.level_random().next_float() * 0.1 + 0.9,
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
@@ -3125,6 +3215,15 @@ impl Game<'_> {
self.audio.play(name, c, volume, pitch);
}
}
// LevelEventHandler 1501 and 1502 draw their smoke after the sound.
match id {
1501 => self.puffs_above_block(
pos,
horizon_client::particles::ParticleType::LargeSmoke,
),
1502 => self.torch_burnout_smoke(pos),
_ => {}
}
}
LevelEvent::Sound {
pos,
@@ -3191,6 +3290,8 @@ impl Game<'_> {
// Popped as the behaviour emits it (GameLevel::emit).
PlayerEffect::DropItem(_) => {}
PlayerEffect::EatCake => {
// CakeBlock.eat
self.stats.custom("eat_cake_slice", 1);
if let Some(p) = self.player.as_mut() {
let food = &mut p.health.food;
food.food = (food.food + 2).min(20);
@@ -3324,20 +3425,21 @@ impl Game<'_> {
self.open_command_block(key);
return;
}
// SignBlock.useWithoutItem: the text editor, not a container.
if horizon_client::signs::sign_kind(&name).is_some() {
self.use_sign_empty_hand(pos);
return;
}
if &*name == "minecraft:crafting_table" {
self.craft_size = 3;
self.craft_table = Some(pos);
} else if &*name == "minecraft:ender_chest" {
self.open_ender_chest(key);
self.container_opener_event(key, true);
let at = [
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
];
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio
.play("minecraft:block.ender_chest.open", Some(at), 0.5, pitch);
// EnderChestBlock.useWithoutItem
self.stats.custom("open_enderchest", 1);
if self.container_opener_event(key, true) {
self.container_edge_sound(key, true);
}
} else if &*name == "minecraft:beacon" && self.open_station(pos, &name) {
self.world.block_entities.get_or_insert_with(key, || {
BlockEntity::for_block(&name).unwrap_or(BlockEntity::Chest(Vec::new()))
@@ -3353,7 +3455,7 @@ impl Game<'_> {
];
self.resolve_container_maps(key, centre);
self.container = Some(key);
self.container_opener_event(key, true);
let first = self.container_opener_event(key, true);
self.post_block_game_event(
"minecraft:container_open",
[key.0, key.1, key.2],
@@ -3377,26 +3479,8 @@ impl Game<'_> {
if self.guarded_by_piglins(&name) {
self.anger_piglins(true);
}
let sound = if storage::is_shulker_box(&name) {
Some("minecraft:block.shulker_box.open")
} else if name.contains("chest") {
Some("minecraft:block.chest.open")
} else if &*name == "minecraft:barrel" {
Some("minecraft:block.barrel.open")
} else {
None
};
if let Some(sound) = sound {
self.audio.play(
sound,
Some([
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
]),
0.5,
1.0,
);
if first {
self.container_edge_sound(key, true);
}
} else if self.open_station(pos, &name) {
// Workstation menus: game/stations.rs.
@@ -3720,6 +3804,21 @@ impl Game<'_> {
{
self.hive_broken_by_player(pos, hive, honey, facing);
}
// InfestedBlock.spawnAfterBreak: its silverfish comes out unless the
// tool has silk touch (#prevents_infested_spawns) or blocks don't drop.
if facts.name.starts_with("minecraft:infested_")
&& !silk
&& self.ui.rules.bool("block_drops")
{
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
if let Some(id) = self.spawn_mob("minecraft:silverfish", at, false) {
self.mob_poof(id);
}
}
// SpawnerBlock.spawnAfterBreak pops its experience whatever the tool.
let xp = if silk && &*facts.name != "minecraft:spawner" {
0
@@ -3841,6 +3940,14 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_sign_applicator(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_golem_statue(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
}
if self.use_jukebox(hit.pos, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -3861,6 +3968,10 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if self.use_beehive(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
}
if self.use_campfire_douse(hit.pos, held.as_ref())? {
self.swing_arm();
return Ok(true);
@@ -3900,6 +4011,20 @@ impl Game<'_> {
)
});
self.process_level_events()?;
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
let name = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]))
.name
.clone();
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
_ => {}
}
}
if result.consumes_action() {
self.swing_arm();
return Ok(true);
@@ -4203,6 +4328,8 @@ impl Game<'_> {
self.release_used_item(&stack, ticks, hand_changed);
return;
}
// ServerPlayer.updateUsingItem: USING_ITEM every tick of the use.
self.trigger_using_item(&stack);
let Some(info) = self.res.items().get(&stack.item).cloned() else {
self.using = None;
return;
@@ -4230,7 +4357,6 @@ impl Game<'_> {
if &*stack.item == "minecraft:spyglass" {
// SpyglassItem.USE_DURATION, then finishUsingItem.
if ticks + 1 >= 1200 {
self.send_use_item_complete();
self.stop_spyglass();
self.using = None;
} else {
@@ -4252,7 +4378,6 @@ impl Game<'_> {
}
if ticks >= duration {
self.using = None;
self.send_use_item_complete();
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
@@ -4722,13 +4847,11 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, feet),
MotionEvent::Emission {
state, in_water, ..
} if !crouching => {
MotionEvent::Emission { state, muffled, .. } if !crouching => {
let sounds = self.res.facts().get(state).sounds.clone();
// Entity.playStepSound: the block's step sound at 0.15 volume,
// muffled under the swim sound in water.
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)
@@ -4794,6 +4917,10 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// Player.causeFallDamage: a fall of 2 blocks or more counts.
MotionEvent::Fall(horizon_client::landing::FallEvent::Statistic(cm)) => {
self.stats.custom("fall_one_cm", cm)
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
@@ -5133,9 +5260,6 @@ impl Game<'_> {
| Screen::Advancements
| Screen::WorldOptions
| Screen::Friends
| Screen::ServerLinks
| Screen::ConfirmLink
| Screen::DialogWarning
| Screen::CommandBlock
) || self.book.is_some()
|| self.win_screen_pauses())
@@ -5619,9 +5743,6 @@ impl Game<'_> {
Screen::Advancements => self.draw_advancements(&mut batch),
Screen::WorldOptions => self.draw_world_options(&mut batch),
Screen::Friends => self.draw_friends_screen(&mut batch),
Screen::ServerLinks => self.draw_server_links(&mut batch),
Screen::ConfirmLink => self.draw_confirm_link(&mut batch),
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
@@ -6042,41 +6163,15 @@ impl Game<'_> {
if let Some(key) = self.container.take() {
// A cart's or chest boat's items go back to it.
self.close_cart_container(key);
self.container_opener_event(key, false);
let last = self.container_opener_event(key, false);
self.post_block_game_event(
"minecraft:container_close",
[key.0, key.1, key.2],
Some(horizon_client::mobs::Who::Player),
None,
);
let name = self
.res
.facts()
.get(self.world.block(key.0, key.1, key.2))
.name
.clone();
let sound = if storage::is_shulker_box(&name) {
Some("minecraft:block.shulker_box.close")
} else if &*name == "minecraft:ender_chest" {
Some("minecraft:block.ender_chest.close")
} else if name.contains("chest") {
Some("minecraft:block.chest.close")
} else if &*name == "minecraft:barrel" {
Some("minecraft:block.barrel.close")
} else {
None
};
if let Some(sound) = sound {
self.audio.play(
sound,
Some([
f64::from(key.0) + 0.5,
f64::from(key.1) + 0.5,
f64::from(key.2) + 0.5,
]),
0.5,
1.0,
);
if last {
self.container_edge_sound(key, false);
}
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
}
@@ -6596,6 +6691,7 @@ impl Game<'_> {
self.player_menu_sound(sound);
}
let crafted = std::mem::take(&mut menu.crafted);
let brewed = std::mem::take(&mut menu.brewed);
let equipped = std::mem::take(&mut menu.equipped);
for (i, stack) in after.into_iter().enumerate() {
if let Some(g) = game_of[i] {
@@ -6611,6 +6707,9 @@ impl Game<'_> {
self.player_drop(stack);
}
self.award_crafted(&crafted);
for stack in &brewed {
self.trigger_brewed_potion(stack);
}
if furnace_before > furnace_after
&& self.remote.is_none()
&& let (Some(pos), Some(p)) = (self.container, self.player.as_ref())
@@ -7930,13 +8029,15 @@ impl Game<'_> {
}
/// The visual and sound part of `Player.doSweepAttack` after a successful hit.
pub(super) fn sweep_attack(&mut self, at: [f64; 3], from: Option<[f64; 3]>) {
self.particles.add(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
),
at,
[0.0; 3],
let sweep = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::SweepAttack,
);
self.particles.add(&sweep, at, [0.0; 3]);
// Player.sweepAttack's `sendParticles(SWEEP_ATTACK, ..., 0, dx, 0, dz, 0)`:
// the offset is the one from the player to the particle.
let origin = from.unwrap_or(at);
let spread = [at[0] - origin[0], 0.0, at[2] - origin[2]];
self.lan_send_particles(&sweep, at, spread, 0.0, 0);
self.audio
.play("minecraft:entity.player.attack.sweep", from, 1.0, 1.0);
}
@@ -8091,11 +8192,6 @@ impl Game<'_> {
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
let player = self.player.as_ref();
let from = player.map(|p| [p.position.x, p.position.y, p.position.z]);
if knockback_attack {
@@ -8110,6 +8206,15 @@ impl Game<'_> {
None => horizon_client::mobs::Damage::of(horizon_client::mobs::DamageKind::Generic),
};
let landed = self.hurt_by_player(index, amount, source);
if landed {
let victim = self.mob_entity(&self.mobs[index]);
self.player_hit(
&victim,
creatures::damage_type(source.kind, true),
None,
amount,
);
}
if let Some(p) = self.player.as_mut() {
if p.attack_log.len() >= 64 {
p.attack_log.remove(0);
@@ -8175,6 +8280,14 @@ impl Game<'_> {
0.2,
(actual * 0.5) as u32,
);
// The players a server serves are sent them too.
self.lan_send_particles(
&indicator,
[feet[0], feet[1] + height * 0.5, feet[2]],
[0.1, 0.0, 0.1],
0.2,
(actual * 0.5) as u32,
);
}
self.last_hurt_by_player
.insert(self.mobs[index].id, self.lan_acting);
@@ -8195,16 +8308,32 @@ impl Game<'_> {
// The hurt or death sound the mob raised (getHurtSound / getDeathSound,
// its voice pitch from its own random).
let queued = std::mem::take(&mut self.mobs[index].outbox);
let mut infested = false;
for event in queued {
if let horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) = event {
self.audio
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
} else {
// Left for the end of the tick.
self.mobs[index].outbox.push(event);
match event {
horizon_client::mobs::MobEvent::Sound(s, at, volume, pitch) => {
self.audio
.play(&format!("minecraft:{s}"), Some(at), volume, pitch);
}
horizon_client::mobs::MobEvent::Infested { .. } => infested = true,
other => {
// Left for the end of the tick.
self.mobs[index].outbox.push(other);
}
}
}
if infested {
let id = self.mobs[index].id;
self.mob_infested(id);
}
self.weapon_hit(index);
// Player.attack: causeFoodExhaustion(0.1F) only when the hit hurt
// (inside `if (wasHurt)`), after damageStatsAndHearts.
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
} else {
self.audio
.play("minecraft:entity.player.attack.nodamage", from, 1.0, 1.0);
@@ -8465,6 +8594,7 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
self.clear_entities();
@@ -8768,6 +8898,10 @@ impl Game<'_> {
}
self.lan_close(false);
self.save_game()?;
if let Some(mut remote) = self.remote.take() {
// Minecraft.disconnect closes the connection, so the host sees a clean leave.
remote.session.disconnect();
}
self.saved_for_exit = true;
Ok(())
}
@@ -9049,6 +9183,9 @@ impl Game<'_> {
if result.outcome == Outcome::Pass {
return Ok(false);
}
if let Some(stat) = result.stat {
self.stats.custom(stat, 1);
}
if let Some(block) = result.block
&& let Ok(new) = registry.resolve(&block)
{
@@ -9158,6 +9295,8 @@ impl Game<'_> {
else {
return Ok(false);
};
// BlockTransformer.transformBlock: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", pos, stack);
self.level_set(pos, new)?;
// BlockTransformer.TransformParticle.send.
self.transform_particle(&particle, pos);
@@ -9430,6 +9569,8 @@ impl Game<'_> {
if let Some(waxed) = old.and_then(|old| self.res.transformers().waxed(registry, &old))
&& let Ok(state) = registry.resolve(&waxed)
{
// HoneycombItem.useOn: ITEM_USED_ON_BLOCK before the swap.
self.trigger_block("minecraft:item_used_on_block", hit.pos, stack);
self.level_set(hit.pos, state)?;
self.level_event_at(3003, hit.pos, 0);
self.audio.play(
@@ -10143,37 +10284,7 @@ impl Game<'_> {
if self.flash > 0 {
self.flash -= 1;
}
let (rain, _) = self.world.weather.levels(1.0);
let Some(eye) = self.eye_position() else {
return;
};
// WeatherEffectRenderer.tickRainParticles: occasional rain sounds.
if rain > 0.0 && self.world.random.next_int_bound(3).unwrap_or(1) < (rain * 3.0) as i32 {
let r = &mut self.world.random;
let (dx, dz) = (
r.next_int_bound(21).unwrap_or(10) - 10,
r.next_int_bound(21).unwrap_or(10) - 10,
);
let (x, z) = (eye[0].floor() as i32 + dx, eye[2].floor() as i32 + dz);
let top = self.rain_height(x, z);
if self.precipitation(x, top, z) == Some(false)
&& (top - eye[1] as i32).abs() < 10
&& self.world.random.next_int_bound(5).unwrap_or(1) == 0
{
let above = f64::from(top) > eye[1] + 1.0;
let (name, volume, pitch) = if above {
("minecraft:weather.rain.above", 0.1, 0.5)
} else {
("minecraft:weather.rain", 0.2, 1.0)
};
self.audio.play(
name,
Some([f64::from(x) + 0.5, f64::from(top), f64::from(z) + 0.5]),
volume,
pitch,
);
}
}
// The rain sounds come with the rain splashes (`tick_particles`).
}
/// WeatherEffectRenderer: rain and snow columns within the weather radius.
fn weather_geometry(
@@ -11416,10 +11527,6 @@ impl Game<'_> {
Screen::GameModeSwitcher => self.close_gamemode_switcher(false),
Screen::Dead => {}
Screen::Stats | Screen::Friends => self.screen = Screen::Paused,
Screen::ServerLinks => self.screen = Screen::Paused,
// ConfirmScreen.keyPressed: Escape answers no; both return to
// the Server Links screen they came from.
Screen::ConfirmLink | Screen::DialogWarning => self.screen = Screen::ServerLinks,
Screen::Advancements => self.close_advancements(),
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
@@ -11488,10 +11595,8 @@ impl Game<'_> {
// key.swapOffhand: on a server, ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND
// (the server swaps the hands and sends both slots back; spectators can't).
KeyCode::KeyF if self.screen == Screen::Playing && self.remote.is_some() => {
if !self.spectator()
&& let Some(r) = self.remote.as_mut()
{
r.pending_keys.push(key);
if !self.spectator() {
self.remote_swap_hands();
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
@@ -11588,28 +11693,11 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
/// Distance when it is on, else a mod's far terrain
/// (`render.set-far-terrain`).
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
if g.lod_distance > 0 {
return (g.lod_distance, reduced);
}
match self.mod_far_terrain() {
Some(f) => (f.distance, reduced || f.reduced),
None => (0, reduced),
}
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
/// LOD Distance as far terrain uses it (`Graphics::lod_distance_on`).
fn lod_distance(&self) -> u32 {
let (distance, _) = self.far_terrain_choice();
match &self.renderer {
Some(renderer) => {
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
}
None => distance.min(1024),
Some(renderer) => self.settings.horizon.lod_distance_on(&renderer.info),
None => self.settings.horizon.lod_distance.min(1024),
}
}
/// Only enabled LOD worlds extend the renderer's range, never simulation.
@@ -11630,7 +11718,7 @@ impl Game<'_> {
}
}
fn prepare_lod(&mut self) {
let enabled = self.far_terrain_choice().0 > 0
let enabled = self.settings.horizon.lod_distance > 0
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
@@ -11727,7 +11815,6 @@ impl Game<'_> {
{
log::warn!("Horizon: failed to save unloaded chunks: {e}");
}
let (far_distance, far_reduced) = self.far_terrain_choice();
if let Some(renderer) = self.renderer.as_mut() {
let g = &self.settings.horizon;
let budgeted = g.chunk_updates > 0 || g.upload_budget_kib > 0;
@@ -11781,10 +11868,12 @@ impl Game<'_> {
}
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance =
horizon_client::graphics::lod_distance_capped(far_distance, &renderer.info);
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
lod.set_near_loading(self.streaming.busy());
lod.set_reduced_detail(far_reduced);
lod.set_reduced_detail(
self.settings.horizon.far_detail
== horizon_client::graphics::FarDetail::Reduced,
);
// The covered columns change only with the complete ones and
// the render area: rebuilt then, not every frame.
let at = (
@@ -12118,9 +12207,6 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::WorldOptions && d != 0.0 {
self.world_options_scroll(d);
}
if self.screen == Screen::ServerLinks && d != 0.0 {
self.server_links_scroll(d);
}
if self.screen == Screen::Advancements {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
@@ -12312,15 +12398,6 @@ impl ApplicationHandler for Game<'_> {
Screen::WorldOptions if pressed && button == MouseButton::Left => {
self.world_options_click()
}
Screen::ServerLinks if pressed && button == MouseButton::Left => {
self.server_links_click()?
}
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
self.confirm_link_click()
}
Screen::DialogWarning if pressed && button == MouseButton::Left => {
self.dialog_warning_click()?
}
Screen::WorldOptions if button == MouseButton::Left => {
self.world_options_release()
}
@@ -12752,6 +12829,16 @@ impl ApplicationHandler for Game<'_> {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0
&& self.pending_save.as_ref().is_none_or(|save| !save.quit)
{
// SIGTERM, SIGINT or SIGHUP: the shutdown hook's server halt, as a window close.
if let Err(error) = self.begin_closing_save(true) {
self.error = Some(error);
event_loop.exit();
return;
}
}
if self.pending_save.is_some() {
match self.poll_closing_save() {
Ok(true) => event_loop.exit(),
@@ -13042,11 +13129,13 @@ impl<'a> Game<'a> {
dragging: false,
avatar: Default::default(),
flash: 0,
rain_sound_time: 0,
lightning_seen: [0; 2],
sleeping: None,
sleep_timer: 0,
dim: Dim::Overworld,
world_root: PathBuf::new(),
world_flat: false,
travelling: None,
server_trips: Vec::new(),
level_teleports: Vec::new(),
@@ -13096,6 +13185,8 @@ impl<'a> Game<'a> {
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
text_displays: Default::default(),
remote_knots: Default::default(),
knot_ids: Default::default(),
text_display_vertices: 0,
skin_downloads: RefCell::new(horizon_client::skins::Downloader::new(
std::env::current_dir()
@@ -13168,7 +13259,6 @@ impl<'a> Game<'a> {
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
lan_cushion_breaks: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
@@ -13177,7 +13267,6 @@ impl<'a> Game<'a> {
lan_acting: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
guest_entity_events: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
chat_capture: None,
@@ -13204,7 +13293,6 @@ impl<'a> Game<'a> {
restore_player_state: true,
stats_ui: Default::default(),
adv_ui: Default::default(),
links_ui: Default::default(),
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
@@ -14241,6 +14329,11 @@ impl Loading {
}
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
if horizon_threads::stop_requests() > 0 {
// No world is open yet: nothing to save.
event_loop.exit();
return;
}
if let Err(error) = self.poll_menu_resources() {
self.error = Some(error);
event_loop.exit();
@@ -14518,6 +14611,9 @@ pub fn run() -> Result<()> {
{
horizon_client::heartbeat::start();
}
if options.execution_role.is_client() {
horizon_threads::catch_stop_signals();
}
let started = Instant::now();
horizon_client::spans::enable(options.spikes.is_some());
let vanilla_root = options.resources.clone();
@@ -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 {
-6
View File
@@ -419,9 +419,6 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -710,12 +707,9 @@ impl Game<'_> {
// the recipe buttons of the page, page arrows, filter and tabs.
let book = self.recipe_book_targets();
if !book.is_empty() {
let (open, filtering) = self.recipe_book_flags().unwrap_or_default();
v["recipe_book"] = json!({
"targets": book.iter().map(|t| t.rect).collect::<Vec<_>>(),
"known": self.recipe_book_known(),
"open": open,
"filtering": filtering,
});
}
v["slot_ids"] = json!(
+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`).
+1 -1
View File
@@ -862,7 +862,7 @@ impl Game<'_> {
let a = alpha as f32;
let range = super::entity_culling::default_range(
glam::DVec3::new(WIDTH, HEIGHT, WIDTH),
self.view_scale(),
self.settings.entity_distance,
);
for b in &self.boats {
let entity_start = out.len();
+3 -6
View File
@@ -1009,12 +1009,9 @@ impl Game<'_> {
}
};
let indicator = |batch: &mut HudBatch, page: usize, count: usize| {
// BookViewScreen.getPageNumberMessage: 26.3's en_us template is
// positional ("Page %1$s of %2$s").
let text = horizon_protocol::text::format_java(
&tr("book.pageIndicator", "Page %1$s of %2$s"),
&[(page + 1).to_string(), count.max(1).to_string()],
);
let text = tr("book.pageIndicator", "Page %s of %s")
.replacen("%s", &(page + 1).to_string(), 1)
.replacen("%s", &count.max(1).to_string(), 1);
let tw = HudBatch::text_width(atlas, &text);
batch.text(atlas, &text, left + 148.0 - tw, top + 16.0, BLACK, false);
};
+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,
+24
View File
@@ -36,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.
@@ -90,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;
@@ -136,6 +156,10 @@ impl Game<'_> {
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();
@@ -907,12 +907,10 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4))),
));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
+11 -10
View File
@@ -19,17 +19,7 @@ use std::convert::Infallible;
type ItemConsumable = Consumable<String, ConsumeEffect>;
/// `EntityEvent.USE_ITEM_COMPLETE`: the server finished the player's use.
pub(super) const USE_ITEM_COMPLETE_EVENT: i8 = 9;
impl Game<'_> {
/// `ServerPlayer.completeUsingItem`: the player's own client is told
/// first, and completes the use itself (eating the food there too).
pub(super) fn send_use_item_complete(&mut self) {
if self.lan_acting.is_some() {
self.guest_entity_events.push(USE_ITEM_COMPLETE_EVENT);
}
}
/// `Consumable.emitParticlesAndSounds(user.getRandom(), user, stack, count)`.
pub(super) fn emit_consume(
&mut self,
@@ -67,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);
}
@@ -878,9 +878,6 @@ impl Game<'_> {
Screen::Advancements => "advancements",
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::ServerLinks => "server-links",
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
@@ -44,13 +44,13 @@ impl Game<'_> {
slot: usize,
stack: Option<ItemStack>,
) {
if let Some(e) = self
.world
.block_entities
.get_mut(&(chest[0], chest[1], chest[2]))
let key = (chest[0], chest[1], chest[2]);
if let Some(e) = self.world.block_entities.get_mut(&key)
&& let Some(s) = e.slots_mut().get_mut(slot)
{
*s = stack;
// TransportItemsBetweenContainers calls Container.setChanged.
self.container_changed(key);
}
}
/// `ContainerOpenersCounter.recheckOpeners` every five ticks: the golems
@@ -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() {
+306 -59
View File
@@ -46,10 +46,22 @@ pub(super) struct Creatures {
pub projectiles: Vec<Projectile>,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Explosion packets from a guest's turn, waiting for it to end
/// (centre, small, radius, blocks hit; game/lan_particles.rs).
pub lan_explosions: Vec<([f64; 3], bool, f32, usize)>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
/// Particles from a guest's turn, waiting for it to end
/// (`lan_send_particles`: options, centre, spread, speed, count).
pub lan_particles: Vec<(
horizon_client::particles::ParticleOptions,
[f64; 3],
[f64; 3],
f64,
u32,
)>,
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
@@ -187,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(),
@@ -198,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.
@@ -419,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 },
@@ -512,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).
@@ -606,6 +622,11 @@ 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 {
@@ -685,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()?;
@@ -757,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);
@@ -887,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
@@ -980,21 +1024,6 @@ impl Game<'_> {
}
}
}
MobEvent::Shift { target, delta } => {
let d = Vec3::new(delta[0], delta[1], delta[2]);
match target {
Who::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(d);
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.position = m.position.add_vector(d);
}
}
}
}
MobEvent::Wololo { mob } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
// DyeColor.RED.
@@ -1014,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
@@ -1030,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?
}
@@ -1041,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;
@@ -1049,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
@@ -1108,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()
@@ -1271,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);
@@ -1387,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")
@@ -1515,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);
@@ -1787,6 +1855,10 @@ impl Game<'_> {
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()
@@ -1799,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);
}
@@ -2100,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;
@@ -2451,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
}
@@ -2494,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) => {
@@ -2519,6 +2639,7 @@ impl Game<'_> {
}
}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:egg");
}
(ProjectileKind::EnderPearl, hit) => {
@@ -2622,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 {
@@ -2768,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 {
@@ -2807,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) {
@@ -2868,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);
@@ -2923,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;
@@ -3043,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);
@@ -3117,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);
@@ -3171,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
@@ -3195,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.
@@ -3270,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;
@@ -3347,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);
@@ -3421,7 +3675,7 @@ impl Game<'_> {
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
let scale = f64::from(self.settings.entity_distance);
for mob in &self.mobs {
let entity_start = out.len();
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws.
@@ -3430,16 +3684,9 @@ impl Game<'_> {
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
// mean edge, scaled by the entity distance option.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0
* if &*mob.kind.id == "minecraft:player" {
10.0
} else {
1.0
};
let range = edge * 64.0 * scale;
let range = (w + h + w) / 3.0 * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
@@ -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);
}
}
}
+15 -12
View File
@@ -348,15 +348,11 @@ impl Game<'_> {
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let p = c.position;
// Entity.getY(2/3) is relative to the cushion's quarter-block height.
let particle_at = [p.x, p.y + 0.25 * (2.0 / 3.0), p.z];
// Guest actions temporarily take self.lan; flush once the hub is back.
// Queue before resolving a sprite: a dedicated server has no renderer.
if self.audio.capture.is_some() {
self.lan_cushion_breaks.push((state, particle_at));
}
let at = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
@@ -365,8 +361,14 @@ impl Game<'_> {
sprite,
tint,
);
self.particles
.send_particles(&options, particle_at, [0.25, 0.0625, 0.25], 0.05, 10);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
@@ -453,7 +455,8 @@ impl Game<'_> {
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+1 -127
View File
@@ -24,7 +24,6 @@ use horizon_client::{
use horizon_protocol::text::translatable;
use horizon_world::nbt::{JavaString, Tag};
use std::{
io::Write,
path::PathBuf,
sync::{Arc, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
@@ -90,9 +89,7 @@ fn log_from(thread: &str, level: &str, message: &str) {
}
});
// Log4j's %d{HH:mm:ss}: the local time, as in logs/latest.log.
// The launcher can close its stdout pipe while the server keeps running.
let _ = writeln!(
std::io::stdout().lock(),
println!(
"[{}] [{thread}/{level}]: {message}",
chrono::Local::now().format("%H:%M:%S")
);
@@ -621,22 +618,6 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
server_links: if props.bug_report_link.is_empty() {
Vec::new()
} else if super::server_links::valid_untrusted_uri(&props.bug_report_link) {
vec![horizon_protocol::packets::configuration::ServerLink {
kind: horizon_protocol::packets::configuration::ServerLinkKind::Known(0),
link: props.bug_report_link.clone(),
}]
} else {
log(
"WARN",
&format!("Failed to parse bug link {}", props.bug_report_link),
);
Vec::new()
},
};
let config = HubConfig {
default_game_mode: game_mode,
@@ -1330,110 +1311,3 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
Some((w, h))
}
#[cfg(test)]
mod tests {
use super::{capture_log, log, log_from};
use std::{
fs,
io::{self, BufRead, BufReader, Read, Write},
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};
const LOGIN: &str = "Player[/127.0.0.1:0] logged in with entity id 2 at (0.5, 115.0, 0.5)";
const WARNING: &str = "Authentication warning";
const READY: &str = "horizon-console-log-ready\n";
const CHILD_HOME: &str = "HORIZON_TEST_CONSOLE_LOG_HOME";
fn assert_log_lines(text: &str) {
let expected = [
format!("] [Server thread/INFO]: {LOGIN}"),
format!("] [User Authenticator #1/WARN]: {WARNING}"),
];
assert_eq!(text.lines().count(), expected.len());
for (line, suffix) in text.lines().zip(expected) {
assert!(line.starts_with('['));
assert!(chrono::NaiveTime::parse_from_str(&line[1..9], "%H:%M:%S").is_ok());
assert_eq!(&line[9..], suffix);
}
}
#[test]
fn console_logging_survives_closed_stdout() {
if let Some(home) = std::env::var_os(CHILD_HOME) {
horizon_client::logging::init(std::path::Path::new(&home), "Server thread");
// Let the parent close the real stdout pipe only after libtest's startup output.
io::stdout().write_all(READY.as_bytes()).unwrap();
io::stdout().flush().unwrap();
io::stdin().read_exact(&mut [0]).unwrap();
std::thread::Builder::new()
.name("Server thread".into())
.spawn(|| {
let captured = capture_log(|| {
log("INFO", LOGIN);
log_from("User Authenticator #1", "WARN", WARNING);
});
assert_eq!(captured, [LOGIN, WARNING]);
horizon_client::logging::flush();
})
.unwrap()
.join()
.unwrap();
// Libtest's final status also writes to stdout; only the game logger is under test.
std::process::exit(0);
}
for closed_stdout in [false, true] {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let home = std::env::temp_dir().join(format!(
"horizon-console-log-test-{}-{unique}",
std::process::id()
));
fs::create_dir(&home).unwrap();
let mut child = Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"game::dedicated::tests::console_logging_survives_closed_stdout",
"--nocapture",
])
.env(CHILD_HOME, &home)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = BufReader::new(child.stdout.take().unwrap());
loop {
let mut line = String::new();
assert!(
stdout.read_line(&mut line).unwrap() > 0,
"child was not ready"
);
if line.ends_with(READY) {
break;
}
}
if closed_stdout {
drop(stdout); // No reader remains: the next console write gets EPIPE.
} else {
child.stdout = Some(stdout.into_inner());
}
child.stdin.take().unwrap().write_all(&[1]).unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"closed_stdout={closed_stdout}: {}",
String::from_utf8_lossy(&output.stderr)
);
if !closed_stdout {
assert_log_lines(std::str::from_utf8(&output.stdout).unwrap());
}
assert_log_lines(&fs::read_to_string(home.join("logs/latest.log")).unwrap());
fs::remove_dir_all(home).unwrap();
}
}
}
@@ -120,6 +120,11 @@ impl Game<'_> {
p.guest = guest;
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
// `needsSync` that `AIM_DEFLECT` sets: a server's players see it turn
// at once, not at the fireball's next update ten ticks later.
p.acceleration = 0.1;
p.needs_sync = true;
self.reset_attack_strength();
self.swing_arm();
let from = self
+64 -14
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;
@@ -2257,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`.
+1 -1
View File
@@ -19,7 +19,7 @@ use horizon_core::{
use std::{collections::HashMap, sync::Arc};
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
const GATEWAY_RADIUS: f64 = 96.0;
const GATEWAY_Y: i32 = 75;
@@ -60,27 +60,12 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Entity.setViewScale (LevelExtractor.extractVisibleEntities): the entity
/// distance option times the render distance's boost — 1 at 8 chunks, 2.5
/// at 20 or more. The render distance is the client setting, capped by
/// the server's view distance when connected
/// (Options.getEffectiveRenderDistance).
pub(super) fn view_scale(&self) -> f64 {
let distance = match &self.remote {
Some(r) => self.settings.render_distance.min(r.view_distance),
None => self.settings.render_distance,
};
(f64::from(distance) / 8.0).clamp(1.0, 2.5) * f64::from(self.settings.entity_distance)
}
}
/// Entity.shouldRenderAtSqrDistance's default range: 64 times the bounding
/// box's mean edge, scaled by the view scale.
pub(super) fn default_range(size: glam::DVec3, view_scale: f64) -> f64 {
/// box's mean edge, scaled by the entity distance option.
pub(super) fn default_range(size: glam::DVec3, view_scale: f32) -> f64 {
let edge = (size.x + size.y + size.z) / 3.0;
let edge = if edge.is_nan() { 1.0 } else { edge };
edge * 64.0 * view_scale
edge * 64.0 * f64::from(view_scale)
}
impl Game<'_> {
+43 -1
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(
@@ -606,6 +628,22 @@ impl Game<'_> {
// 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);
@@ -638,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") {
+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((
+2 -3
View File
@@ -311,9 +311,8 @@ impl Game<'_> {
let sprites = &self.gui.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
// of 1.
let range = 16.0 * 64.0 * self.view_scale();
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at 100%.
let range = 16.0 * 64.0 * f64::from(self.settings.entity_distance);
for f in &self.item_frames {
let entity_start = out.len();
let map = f.item.as_ref().and_then(super::maps::map_id);
@@ -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.
+22 -33
View File
@@ -21,7 +21,6 @@ use horizon_core::geometry::Vec3;
use horizon_protocol::{
crypto::offline_uuid,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use std::{
collections::HashMap,
@@ -339,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};
@@ -653,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());
@@ -716,7 +724,6 @@ impl Game<'_> {
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.lan_cushion_breaks.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -912,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))
@@ -921,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];
@@ -930,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;
@@ -1006,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
@@ -1166,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(
@@ -1333,33 +1349,6 @@ impl Game<'_> {
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (state, at) in std::mem::take(&mut self.lan_cushion_breaks) {
if let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:block")
{
host.hub.broadcast_near(
at,
32.0,
&C::LevelParticles(play::LevelParticles {
particle: horizon_protocol::entity_data::Particle {
id,
data: Some(horizon_protocol::entity_data::ParticleData::State(
state.0 as i32,
)),
},
override_limiter: false,
always_show: false,
pos: at,
dist: [0.25, 0.0625, 0.25],
max_speed: [0.05; 3],
count: 10,
randomization: 0,
}),
None,
);
}
}
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
+70 -44
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,
@@ -165,8 +168,6 @@ pub(super) struct GuestSwap {
acting: (Option<u32>, Option<String>, Option<u128>),
/// The packets queued for that guest.
packets: Vec<play::Clientbound>,
/// The entity events queued for that guest.
entity_events: Vec<i8>,
/// The host's count of swings started, kept while the guest acts.
swings: u32,
/// How many projectiles there were: those added since are the guest's.
@@ -267,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];
@@ -384,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),
@@ -391,7 +395,6 @@ impl Game<'_> {
self.lan_acting_uuid.replace(g.uuid),
);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let swings = self.swings_started;
let projectiles = self.creatures.projectiles.len();
let modifiers = std::mem::replace(
@@ -405,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,
@@ -449,10 +458,10 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -508,11 +517,11 @@ impl Game<'_> {
host_ender,
adv_loaded,
last_inventory,
levitation,
death_cause,
combat,
acting,
packets,
entity_events,
swings,
projectiles,
modifiers,
@@ -534,17 +543,6 @@ impl Game<'_> {
for packet in std::mem::replace(&mut self.guest_packets, packets) {
host.hub.send_to(conn, &packet);
}
// ServerPlayer.connection.send: the guest's own entity events.
let entity_id = host.hub.guests[i].entity_id;
for event in std::mem::replace(&mut self.guest_entity_events, entity_events) {
host.hub.send_to(
conn,
&C::EntityEvent(play::EntityEvent {
id: entity_id,
event,
}),
);
}
// ServerItemCooldowns.onCooldownStarted: the guest's own cooldown.
for (group, duration) in std::mem::take(&mut self.guest_cooldowns) {
host.hub.send_to(
@@ -630,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);
@@ -673,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));
@@ -874,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);
}
}
@@ -974,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 => {
@@ -1540,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;
}
@@ -1847,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,
@@ -1932,8 +1956,21 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// ServerGamePacketListenerImpl.tick's dropSpamThrottler.
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
play.creative_drops = play.creative_drops.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1970,24 +2007,6 @@ impl Game<'_> {
play.digging = None;
}
}
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled, a thrown pearl), after
// LivingEntity.tick's use: an entry a finished use starts has 20
// ticks left when the next tick's packets are handled, as the
// guest's own client counts.
let play = host.play.entry(conn).or_default();
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
// ServerItemCooldowns.onCooldownEnded: a finished entry is told to
// the guest (a duration of 0 removes it).
for group in play.cooldowns.tick() {
host.hub.send_to(
conn,
&C::Cooldown(play::Cooldown {
group: group.to_string(),
duration: 0,
}),
);
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
let g = &host.hub.guests[gi];
@@ -2332,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)
@@ -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| {
+36 -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,
@@ -466,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(
@@ -484,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,
}
}
@@ -318,16 +318,86 @@ impl Game<'_> {
}
}
/// `ServerLevel.sendParticles` for the players a server serves: one
/// `ClientboundLevelParticlesPacket` to each player in the level whose
/// block is within 32 blocks of `at`. The window's own player is not
/// sent it (the caller draws it in this window). While the host holds
/// the players (a guest's turn through `as_guest`, a host's hit on a
/// guest) the packet waits for the turn to end so that it reaches every
/// player, the guest too.
pub(super) fn lan_send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if self.lan.is_none() {
// The server is running (its sounds are captured for the guests).
if self.audio.capture.is_some() {
self.creatures
.lan_particles
.push((options.clone(), at, spread, speed, count));
}
return;
}
let Some(particle) = self.network_particle(options, None) else {
return;
};
let Some(host) = self.lan.as_ref() else {
return;
};
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count: count as i32,
randomization: 0,
})
.encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
let d: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if d < 32.0 * 32.0 {
g.sender.send(body.clone());
}
}
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for (options, at, spread, speed, count) in std::mem::take(&mut self.creatures.lan_particles)
{
self.lan_send_particles(&options, at, spread, speed, count);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(
&self,
&mut self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
// In a guest's turn (their hit on an end crystal or a minecart) the
// host holds the players (`as_guest`): the packet waits for the turn
// to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures
.lan_explosions
.push((center, small, radius, block_count));
return;
}
let emitter = if small {
"minecraft:explosion"
} else {
@@ -341,6 +411,15 @@ impl Game<'_> {
}
}
/// The explosions guests' turns put off.
pub(super) fn flush_lan_explosions(&mut self) {
for (center, small, radius, block_count) in
std::mem::take(&mut self.creatures.lan_explosions)
{
self.lan_explode(center, small, radius, block_count);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
+39 -8
View File
@@ -86,9 +86,11 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
self.pvp_exhaustion();
if strike.stops_sprint() {
self.stop_sprint_after_extra_knockback();
}
}
self.lan = Some(host);
true
@@ -106,8 +108,18 @@ impl Game<'_> {
.map(|g| Victim::Guest(g.conn))
}
/// `Player.attack`'s `causeFoodExhaustion(0.1F)`, charged to the attacker
/// (the host, or a guest swapped in) only once the hit hurt the victim.
pub(super) fn pvp_exhaustion(&mut self) {
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
}
/// The attacker's side of `Player.attack` (the host, or a guest swapped
/// in): the damage, the charge reset, the exhaustion and the swing.
/// in): the damage, the charge reset and the swing.
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
@@ -116,11 +128,6 @@ impl Game<'_> {
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
self.attack_ticks = 0;
if let Some(p) = self.player.as_mut()
&& !self.creative
{
p.cause_food_exhaustion(0.1);
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
@@ -260,6 +267,30 @@ impl Game<'_> {
} else {
Default::default()
};
// 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)),
@@ -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);
+191 -22
View File
@@ -1,24 +1,20 @@
//! 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) {
self.particles
.block_teleportation(pos, data, [16, 8, 16], self.settings.particles);
}
/// Event 2016: `Shulker.teleportSomewhere`'s particles between the
/// shell's old and new cells.
pub(super) fn shulker_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
.block_teleportation(pos, data, [8, 8, 8], self.settings.particles);
}
/// Event 2017: `TeleportRandomlyConsumeEffect`'s directional particles.
pub(super) fn consume_teleport_particles(&mut self, pos: [i32; 3], data: i32) {
self.particles
@@ -40,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))),
}
@@ -72,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);
@@ -108,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.
+4
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>,
@@ -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 ----
+67 -107
View File
@@ -9,7 +9,7 @@ use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
containers::BlockEntity,
items::ItemStack,
items::{ItemStack, Items},
mesher::Vertex,
mobs::{Part, anim::Poses},
particles::{ParticleOptions, ParticleType},
@@ -90,8 +90,6 @@ pub(super) struct Minecart {
/// Chest minecarts: the loot table rolled into `items` on load, saved
/// again while the items are still that roll.
pub loot: Option<horizon_client::save::RolledLoot>,
/// `Entity.firstTick`: not ticked yet (saved as `HasTicked`).
pub first_tick: bool,
}
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
@@ -188,7 +186,6 @@ impl Minecart {
fuse: -1,
enabled: true,
loot: None,
first_tick: true,
}
}
pub fn bounds(&self) -> Aabb {
@@ -201,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>,
@@ -539,7 +547,6 @@ impl Game<'_> {
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
c.previous = c.position;
c.previous_yaw = c.yaw;
c.hurt_time = (c.hurt_time - 1).max(0);
@@ -547,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" {
@@ -589,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;
}
}
}
_ => {}
}
@@ -648,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
@@ -831,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;
@@ -950,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];
@@ -960,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));
@@ -1054,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 {
@@ -1071,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
@@ -1344,8 +1334,9 @@ impl Game<'_> {
return;
};
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's mean
// edge, scaled by the view scale.
let range = (WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * self.view_scale();
// edge, scaled by the entity distance option.
let range =
(WIDTH + HEIGHT + WIDTH) / 3.0 * 64.0 * f64::from(self.settings.entity_distance);
for c in &self.carts {
let entity_start = out.len();
let Some(model) = self.cart_models.models.get(&c.kind) else {
@@ -1512,10 +1503,6 @@ impl Minecart {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
@@ -1565,7 +1552,6 @@ impl Minecart {
_ => 0.0,
};
c.flipped = int("FlippedRotation").unwrap_or(0) != 0;
c.first_tick = int("HasTicked").unwrap_or(0) != 0;
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
@@ -1590,30 +1576,4 @@ mod tests {
assert_eq!(display_offset(CartKind::CommandBlock), 6.0);
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
#[test]
fn a_cart_saves_as_never_ticked_until_it_ticks() {
use horizon_world::nbt::Tag;
let generated = Tag::compound([
("id", Tag::String("minecraft:chest_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("HasTicked", Tag::Byte(1)),
("Fire", Tag::Short(0)),
]);
let mut cart = Minecart::from_nbt(&generated, 1, CartKind::Chest).unwrap();
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(1)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
cart.first_tick = false;
let saved = cart.to_nbt([0; 4], Some(&generated));
assert_eq!(saved.get("HasTicked"), Some(&Tag::Byte(0)));
assert_eq!(saved.get("Fire"), Some(&Tag::Short(0)));
}
}
@@ -31,9 +31,6 @@ impl Game<'_> {
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
@@ -54,13 +51,6 @@ pub(super) struct ModMinimap {
pub(super) round: bool,
}
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModFarTerrain {
pub(super) distance: u32,
pub(super) reduced: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
@@ -200,12 +190,4 @@ impl Game<'_> {
round: m.round,
})
}
/// The far terrain the first mod drawing one asks for.
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
+15 -19
View File
@@ -126,19 +126,6 @@ impl Game<'_> {
if !equine::is_equine(species) || !m.alive() || Some(m.id) == self.riding {
return false;
}
// AbstractHorse.mobInteract: a horse a mob rides (`isVehicle`) has no
// seat, inventory or feeding to give. Camels seat two and check their
// passengers themselves.
let id = m.id;
if !matches!(species, Species::Camel | Species::CamelHusk)
&& self
.mobs
.iter()
.any(|p| p.extra.mount == Some(id) && !p.removed && p.alive())
{
return false;
}
let m = &self.mobs[index];
// AbstractHorse.mobInteract: shift + use opens a tamed adult's inventory.
if !m.is_baby()
&& m.extra.tame
@@ -228,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);
@@ -251,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",
@@ -260,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
}
@@ -465,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
+91 -295
View File
@@ -2,9 +2,7 @@
//! `Entity.setAsInsidePortal` / `handlePortal` with `PortalProcessor`,
//! `NetherPortalBlock.getPortalTransitionTime` and `getPortalDestination`
//! (`PortalForcer` through `horizon_world::level::portal_travel`), and
//! `LocalPlayer.handlePortalTransitionEffect`. Items and mobs also go through
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
//! the player's End portals are game/end.rs's.
//! `LocalPlayer.handlePortalTransitionEffect`.
//!
//! Only one dimension is loaded at a time. The player's destination is found
//! once the other dimension has loaded around it. An item or mob that goes
@@ -14,7 +12,7 @@ use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::nether_portal::PortalState;
use horizon_core::{
geometry::{Aabb, Axis, Vec3},
geometry::{Axis, Vec3},
position::BlockPos,
random::RandomSource,
};
@@ -42,32 +40,6 @@ pub(super) struct Plan {
offset: Vec3,
width: f32,
height: f32,
/// Through an End portal: `approximate` is the End's spawn point, or the
/// Overworld's shared spawn.
end: bool,
}
impl Plan {
/// `EndPortalBlock.getPortalDestination` for an entity other than a
/// player in `from`: the End's spawn point, or out of the End the
/// Overworld's shared spawn `world_spawn`.
fn end_portal(from: Dim, world_spawn: BlockPos, width: f32, height: f32) -> Self {
let (dim, approximate) = if from == Dim::End {
(Dim::Overworld, world_spawn)
} else {
let [x, y, z] = super::end::END_SPAWN;
(Dim::End, BlockPos::new(x, y, z))
};
Self {
dim,
approximate,
entry_x_axis: true,
offset: Vec3::ZERO,
width,
height,
end: true,
}
}
}
/// An entity on its way to a dimension that isn't loaded.
@@ -109,89 +81,6 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// Whether a `width` by `height` box moving from `from` to `to` meets the End
/// portal at `pos`: `Entity.collidedWithShapeMovingFrom` against its
/// entity-inside shape, y 6/16..12/16 of the block.
fn touches_end_portal(from: Vec3, to: Vec3, width: f32, height: f32, pos: BlockPos) -> bool {
let half = f64::from(width) / 2.0;
let start = Aabb::new(
Vec3::new(from.x - half, from.y, from.z - half),
Vec3::new(from.x + half, from.y + f64::from(height), from.z + half),
);
let (x, y, z) = (f64::from(pos.x), f64::from(pos.y), f64::from(pos.z));
let shape = Aabb::new(
Vec3::new(x, y + 0.375, z),
Vec3::new(x + 1.0, y + 0.75, z + 1.0),
);
start.collided_along_vector(to.subtract(from), &[shape])
}
/// `PositionMoveRotation.calculateAbsolute` for an End portal's transition
/// of an entity facing `[yaw, pitch]`: into the End it faces west and keeps
/// its pitch (`Relative.X_ROT`); out of it both turn by the world spawn's
/// angles (`Relative.ROTATION`). The pitch stays within ±90.
fn end_portal_rotation(into_end: bool, [yaw, pitch]: [f32; 2], spawn: [f32; 2]) -> [f32; 2] {
let (yaw, pitch) = if into_end {
// Direction.WEST.toYRot().
(90.0, pitch)
} else {
(yaw + spawn[0], pitch + spawn[1])
};
[yaw, pitch.clamp(-90.0, 90.0)]
}
/// A waiting entity as `horizon_portal_arrivals.dat` keeps it.
fn arrival_tag(a: &Arrival) -> Tag {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("end", Tag::Int(i32::from(p.end))),
("entity", a.tag.clone()),
])
}
/// The waiting entity `arrival_tag` wrote.
fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = Dim::from_id(&horizon_client::save::text(a.get("dimension"))?);
let (Some(x), Some(y), Some(z), Some(tag)) = (int("x"), int("y"), int("z"), a.get("entity"))
else {
return None;
};
Some(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
end: int("end").unwrap_or(0) != 0,
},
tag: tag.clone(),
})
}
impl Game<'_> {
/// The world border's extent.
@@ -257,54 +146,8 @@ impl Game<'_> {
offset,
width,
height,
end: false,
}
}
/// The plan of an item or mob going through the portal block at `entry`:
/// an End portal's fixed destination, else the Nether portal's.
fn entity_plan(
&self,
entry: BlockPos,
at: Vec3,
width: f32,
height: f32,
reset_forward: bool,
) -> Plan {
if !self.is_end_portal(self.world.block(entry.x, entry.y, entry.z)) {
return self.portal_plan(entry, at, width, height, reset_forward);
}
let spawn = self.level.world_spawn.unwrap_or_else(|| {
self.spawn.map_or([0, 64, 0], |s| {
[s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32]
})
});
Plan::end_portal(
self.dim,
BlockPos::new(spawn[0], spawn[1], spawn[2]),
width,
height,
)
}
fn is_end_portal(&self, state: horizon_world::registry::BlockStateId) -> bool {
&*self.res.facts().get(state).name == "minecraft:end_portal"
}
/// Where an item or mob through an End portal lands once `plan`'s level
/// has loaded around it: the bottom centre of the End's spawn point,
/// above the platform built below it (`EndPlatformFeature.createEndPlatform`);
/// out of the End, the top of the shared spawn's column
/// (`Entity.adjustSpawnLocation`).
fn end_portal_exit(&mut self, plan: &Plan) -> Vec3 {
let p = plan.approximate;
let centre = Vec3::new(f64::from(p.x) + 0.5, f64::from(p.y), f64::from(p.z) + 0.5);
if plan.dim == Dim::End {
if let Err(e) = self.end_arrival(centre) {
log::error!("End platform: {e}");
}
return centre;
}
let y = self.motion_top(p.x, p.z);
Vec3::new(centre.x, f64::from(y), centre.z)
}
/// The nether portal blocks of the chunks within `radius` of `center`
/// (the points of interest `PortalForcer` searches): loaded chunks as
/// they are, the others as saved; chunks never generated have none.
@@ -677,10 +520,9 @@ impl Game<'_> {
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs and empty boats and minecarts that moved this
/// tick: the portal blocks their box swept through (an End portal's
/// transition time is 0 too).
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -728,7 +570,7 @@ impl Game<'_> {
.set_inside(pos, ENTITY_DELAY);
}
}
/// The first nether or End portal block an entity box swept through
/// The first nether portal block an entity box swept through
/// (`Entity.checkInsideBlocks`, whose first portal sets the entry).
fn swept_portal(
&self,
@@ -762,9 +604,11 @@ impl Game<'_> {
bounds,
1_000_000,
|p, _| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.behaviours().portal_axis(state).is_some()
|| (self.is_end_portal(state) && touches_end_portal(from, to, width, height, p))
if self
.res
.behaviours()
.portal_axis(self.world.block(p.x, p.y, p.z))
.is_some()
{
found = Some(p);
return false;
@@ -774,8 +618,8 @@ impl Game<'_> {
);
found
}
/// Whether a section `reach` touches has a nether or End portal state in
/// its palette: without one, no block there is a portal. Palette answers are
/// Whether a section `reach` touches has a nether portal state in its
/// palette: without one, no block there is a portal. Palette answers are
/// kept per chunk across ticks (`portal_sections`); a box over many
/// sections is not checked.
fn may_hold_portal(
@@ -812,8 +656,9 @@ impl Game<'_> {
(z0..=z1).any(|cz| {
chunks.get(&(cx, cz)).is_some_and(|chunk| {
sections.get((cx, cz), chunk, |v| {
let state = horizon_world::registry::BlockStateId(v);
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
}) & y_mask
!= 0
})
@@ -825,7 +670,7 @@ impl Game<'_> {
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let e = &self.dropped[i];
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
let plan = self.portal_plan(entry, e.position, 0.25, 0.25, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -841,7 +686,7 @@ impl Game<'_> {
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
let plan = self.portal_plan(entry, m.position, m.width(), m.height(), true);
if !self.may_enter(plan.dim) {
return;
}
@@ -871,7 +716,7 @@ impl Game<'_> {
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.entity_plan(entry, b.position, width, height, false);
let plan = self.portal_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -896,7 +741,7 @@ impl Game<'_> {
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.entity_plan(entry, c.position, width, height, false);
let plan = self.portal_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
@@ -909,8 +754,8 @@ impl Game<'_> {
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
/// their exit portal (found or built) or End destination, position,
/// turned yaw and motion, and portal cooldown; they join the world with the chunk entities.
/// their exit portal (found or built), position, turned yaw and motion,
/// and portal cooldown; they join the world with the chunk entities.
pub(super) fn resolve_arrivals(&mut self) {
if self.remote.is_some() {
return;
@@ -927,28 +772,21 @@ impl Game<'_> {
}
let Arrival { plan, mut tag } = self.portals.arrivals.remove(i);
self.portals.dirty = true;
let motion = horizon_client::entity_nbt::motion(&tag);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
let (at, motion, [yaw, pitch]) = if plan.end {
// Relative.DELTA: the motion is kept.
let at = self.end_portal_exit(&plan);
let into_end = plan.dim == Dim::End;
let turn =
end_portal_rotation(into_end, [yaw, pitch], self.level.world_spawn_angle);
(at, motion, turn)
} else {
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
(at, rotate_y(motion, -rotation), [yaw + rotation, pitch])
let Some(exit) = self.nether_exit_portal(&plan) else {
continue;
};
let (at, rotation) = self.arrival(&plan, exit);
let motion = rotate_y(horizon_client::entity_nbt::motion(&tag), -rotation);
let [yaw, pitch] = horizon_client::entity_nbt::rotation(&tag);
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([motion.x, motion.y, motion.z]));
c.insert(
"Rotation".into(),
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
Tag::List(vec![
Tag::Float((yaw + rotation).to_bits()),
Tag::Float(pitch.to_bits()),
]),
);
// setPortalCooldown before the teleport, copied by restoreFrom.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
@@ -985,117 +823,75 @@ impl Game<'_> {
let Some(Tag::List(list)) = root.get("arrivals") else {
return;
};
self.portals
.arrivals
.extend(list.iter().filter_map(arrival_from_tag));
for a in list {
let int = |k: &str| match a.get(k) {
Some(Tag::Int(v)) => Some(*v),
_ => None,
};
let float = |k: &str| match a.get(k) {
Some(Tag::Float(v)) => Some(f32::from_bits(*v)),
_ => None,
};
let double = |k: &str| match a.get(k) {
Some(Tag::Double(v)) => Some(f64::from_bits(*v)),
_ => None,
};
let dim = match horizon_client::save::text(a.get("dimension")).as_deref() {
Some("minecraft:the_nether") => Dim::Nether,
Some(_) => Dim::Overworld,
None => continue,
};
let (Some(x), Some(y), Some(z), Some(tag)) =
(int("x"), int("y"), int("z"), a.get("entity"))
else {
continue;
};
self.portals.arrivals.push(Arrival {
plan: Plan {
dim,
approximate: BlockPos::new(x, y, z),
entry_x_axis: int("x_axis").unwrap_or(1) != 0,
offset: Vec3::new(
double("right").unwrap_or(0.5),
double("up").unwrap_or(0.0),
double("forward").unwrap_or(0.0),
),
width: float("width").unwrap_or(0.25),
height: float("height").unwrap_or(0.25),
},
tag: tag.clone(),
});
}
}
/// Write the waiting entities when they changed.
pub(super) fn save_portal_arrivals(&mut self) {
if !self.portals.dirty || self.remote.is_some() {
return;
}
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
let list = self
.portals
.arrivals
.iter()
.map(|a| {
let p = &a.plan;
Tag::compound([
("dimension", Tag::String(p.dim.id().into())),
("x", Tag::Int(p.approximate.x)),
("y", Tag::Int(p.approximate.y)),
("z", Tag::Int(p.approximate.z)),
("x_axis", Tag::Int(i32::from(p.entry_x_axis))),
("right", Tag::Double(p.offset.x.to_bits())),
("up", Tag::Double(p.offset.y.to_bits())),
("forward", Tag::Double(p.offset.z.to_bits())),
("width", Tag::Float(p.width.to_bits())),
("height", Tag::Float(p.height.to_bits())),
("entity", a.tag.clone()),
])
})
.collect();
let root = Tag::compound([("arrivals", Tag::List(list))]);
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
self.portals.dirty = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn end_portals_lead_to_the_end_spawn_and_back_to_the_world_spawn() {
let spawn = BlockPos::new(-7, 70, 12);
for from in [Dim::Overworld, Dim::Nether] {
let plan = Plan::end_portal(from, spawn, 0.9, 1.4);
assert_eq!(plan.dim, Dim::End);
assert_eq!(plan.approximate, BlockPos::new(100, 50, 0));
assert!(plan.end);
}
let plan = Plan::end_portal(Dim::End, spawn, 0.25, 0.25);
assert_eq!(plan.dim, Dim::Overworld);
assert_eq!(plan.approximate, spawn);
}
#[test]
fn the_end_portal_shape_takes_falling_items_and_standing_mobs() {
let portal = BlockPos::new(0, 100, 3);
let at = |y| Vec3::new(0.5, y, 3.5);
// A dropped item falling through the slab, or in it.
assert!(touches_end_portal(
at(100.6),
at(100.56),
0.25,
0.25,
portal
));
// One tick's fall across the whole slab, in it at neither end.
assert!(touches_end_portal(at(101.0), at(99.9), 0.25, 0.25, portal));
// Resting below the slab (on the floor) or above it.
assert!(!touches_end_portal(
at(100.0),
at(100.0),
0.25,
0.25,
portal
));
assert!(!touches_end_portal(
at(100.8),
at(100.8),
0.25,
0.25,
portal
));
// A cow standing in the portal block.
assert!(touches_end_portal(at(100.0), at(100.0), 0.9, 1.4, portal));
// Beside it.
let beside = Vec3::new(2.0, 100.0, 3.5);
assert!(!touches_end_portal(beside, beside, 0.9, 1.4, portal));
}
#[test]
fn end_portals_turn_entities_like_their_teleport_transition() {
// Into the End: facing west, pitch kept.
assert_eq!(
end_portal_rotation(true, [-30.0, 20.0], [45.0, 10.0]),
[90.0, 20.0]
);
// Out of it: turned by the world spawn's yaw and pitch, pitch clamped.
assert_eq!(
end_portal_rotation(false, [-30.0, 20.0], [45.0, 10.0]),
[15.0, 30.0]
);
assert_eq!(
end_portal_rotation(false, [0.0, 80.0], [0.0, 30.0]),
[0.0, 90.0]
);
}
#[test]
fn waiting_arrivals_keep_their_end_destination_when_saved() {
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
for (from, dim) in [(Dim::Overworld, Dim::End), (Dim::End, Dim::Overworld)] {
let arrival = Arrival {
plan: Plan::end_portal(from, BlockPos::new(3, 64, -5), 0.9, 1.4),
tag: tag.clone(),
};
let back = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
assert_eq!(back.plan.dim, dim);
assert_eq!(back.plan.approximate, arrival.plan.approximate);
assert!(back.plan.end);
assert_eq!(back.tag, tag);
}
// Arrivals saved before End portals were Nether portal plans.
let mut old = arrival_tag(&Arrival {
plan: Plan::end_portal(Dim::End, BlockPos::new(0, 64, 0), 0.25, 0.25),
tag,
});
if let Tag::Compound(c) = &mut old {
c.remove(&"end".into());
}
assert!(!arrival_from_tag(&old).unwrap().plan.end);
}
}
-4
View File
@@ -34,10 +34,6 @@ pub fn open_folder(dir: &Path) {
log::warn!("Horizon: could not open {}: {e}", dir.display());
}
}
/// `Util.getPlatform().openUri`: the system browser on the link.
pub fn open_uri(uri: &str) {
open_folder(Path::new(uri));
}
/// The resource root to load from, given the vanilla root, the selection and
/// a server's packs, which go on top (`Pack.Position.TOP`, fixed).
pub fn startup_root(
+2 -1
View File
@@ -267,7 +267,8 @@ impl Game<'_> {
// measured from the box's centre (HangingEntity's position).
let (lo, hi) = p.bounds();
let (lo, hi) = (glam::DVec3::from(lo), glam::DVec3::from(hi));
let range = super::entity_culling::default_range(hi - lo, self.view_scale());
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
+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
+494 -62
View File
@@ -2,7 +2,12 @@
//! `Consumable`), throwing splash/lingering potions (`ThrownSplashPotion`),
//! filling glass bottles (`BottleItem`), the per-tick effect application,
//! effect attribute modifiers and the HUD effect icons (`Hud.extractEffects`).
use super::{Game, Screen, item_draw::owner_at};
use super::{
Game, Screen,
dragon::{Breath, FightPlayer, FightWho},
item_draw::owner_at,
remote_projectiles::cloud_client_tick,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
block_entity_render::vertex_light,
@@ -12,9 +17,13 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
random::{LegacyRandom, RandomSource},
};
/// A thrown splash or lingering potion in flight.
#[derive(Clone, Debug)]
@@ -31,6 +40,35 @@ pub(super) struct ThrownPotion {
/// for a dispenser's).
owner: Option<Text>,
}
/// `AreaEffectCloud` wait time set by `ThrownLingeringPotion.onHitAsPotion`.
pub(super) const LINGERING_WAIT: i32 = 10;
/// `AreaEffectCloud.DEFAULT_REAPPLICATION_DELAY`.
const REAPPLICATION_DELAY: i32 = 20;
/// An entity a cloud has affected (`AreaEffectCloud.victims`).
#[derive(Clone, Copy, PartialEq)]
enum Victim {
Mob(u32),
Player(FightWho),
}
/// What a lingering potion's `AreaEffectCloud` applies: the potion's effects
/// (at the lingering item's 0.25 duration scale) and who it has affected.
pub(super) struct PotionCloud {
effects: &'static [(&'static str, i32, i32)],
/// The potion's colour (`PotionContents.getColor`).
color: u32,
/// Each victim and the tick it can be affected again.
victims: Vec<(Victim, i32)>,
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
/// thrower, else the cloud.
source: Text,
}
impl PotionCloud {
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
pub(super) fn particle(&self) -> ParticleOptions {
let c = |shift: u32| (self.color >> shift & 255) as f32 / 255.0;
ParticleOptions::colored(ParticleType::EntityEffect, [c(16), c(8), c(0), 1.0])
}
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
use std::{collections::HashMap, sync::OnceLock};
@@ -48,7 +86,203 @@ 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(
@@ -313,7 +547,6 @@ impl Game<'_> {
return true;
}
self.using = None;
self.send_use_item_complete();
self.post_entity_event("minecraft:drink", horizon_client::mobs::Who::Player);
if ominous {
self.drink_ominous_bottle(slot, stack);
@@ -391,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
@@ -401,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();
@@ -548,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, t.owner.clone()));
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, owner) in landed {
self.splash(&stack, at, owner);
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
@@ -672,44 +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) {
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
self.apply_indirect_effect_action(a, source);
}
} else {
let d = (intensity * f64::from(*duration) * scale + 0.5) as i32;
let d = (intensity * f64::from(*duration) + 0.5) as i32;
if d > 20
&& let Some(e) = brewing::effect(id)
{
@@ -720,8 +1127,16 @@ impl Game<'_> {
}
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
/// `ThrownSplashPotion.onHitAsPotion`, or `ThrownLingeringPotion.onHitAsPotion`
/// for a lingering potion; `mob` is where it hit a mob, `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
@@ -745,31 +1160,48 @@ impl Game<'_> {
let color = brewing::potion_color(stack.potion());
self.level_event_at(if instant { 2007 } else { 2002 }, block, color as i32);
self.level_event_at(if instant { 1054 } else { 1053 }, block, 0);
let scale = if lingering { 0.25 } else { 1.0 };
let Some(def) = potion else {
return;
};
if lingering {
// The cloud sits at the mob it hit (its feet), else the impact;
// radius 3, wait 10, 600 ticks, shrinking to nothing, and a
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
self.breath_clouds.push(Breath {
id: 0,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
duration: 600,
from_dragon: false,
potion: Some(PotionCloud {
effects: def.effects,
color,
victims: Vec::new(),
source: owner.unwrap_or_else(|| {
Text::translate("entity.minecraft.area_effect_cloud", &[])
}),
}),
});
}
return;
}
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
// Instant damage is indirectMagic(potion or cloud, owner), which the
// death message names by the owner, else by the potion or cloud.
let source = owner.unwrap_or_else(|| {
let entity = if lingering {
"entity.minecraft.area_effect_cloud"
} else {
"entity.minecraft.splash_potion"
};
Text::translate(entity, &[])
});
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale, &source));
// Instant damage is indirectMagic(potion, owner), which the death
// message names by the owner, else by the potion.
let source =
owner.unwrap_or_else(|| Text::translate("entity.minecraft.splash_potion", &[]));
self.for_each_player(|g| g.splash_on_player(def.effects, at, 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));
@@ -784,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)
{
@@ -796,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);
}
}
@@ -609,7 +609,6 @@ impl Game<'_> {
if inside(cursor, bx, by, 20.0, 18.0) {
let open = !self.book_open(book_type);
self.level.recipe_book.open[book_type.index()] = open;
self.send_book_settings(book_type);
if !open {
self.recipe_ui.overlay = None;
}
@@ -695,7 +694,6 @@ impl Game<'_> {
if inside(cursor, xo + 110.0, yo + 12.0, 26.0, 16.0) {
let filtering = !self.book_filtering(book_type);
self.level.recipe_book.filtering[book_type.index()] = filtering;
self.send_book_settings(book_type);
self.recipe_ui.page = 0;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
@@ -716,22 +714,6 @@ impl Game<'_> {
}
false
}
/// `RecipeBookComponent.sendUpdateSettings`: the server owns the saved book.
fn send_book_settings(&self, book_type: BookType) {
self.send(
horizon_protocol::packets::play::Serverbound::RecipeBookChangeSettings(
horizon_protocol::packets::recipes::RecipeBookChangeSettings {
book_type: book_type.index() as i32,
open: self.book_open(book_type),
filtering: self.book_filtering(book_type),
},
),
);
}
pub(super) fn recipe_book_flags(&self) -> Option<(bool, bool)> {
let book = self.active_book()?;
Some((self.book_open(book), self.book_filtering(book)))
}
/// How many displays the client's book holds.
pub(super) fn recipe_book_known(&self) -> usize {
self.recipe_ui.client.displays().len()
+161 -177
View File
@@ -117,17 +117,13 @@ pub(super) struct Remote {
joined: bool,
status: String,
pub(super) entity_id: i32,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
dimension: DimensionType,
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>,
/// Decodes chunk packets as they arrive, off the frame.
decoder: ChunkDecoder,
/// Options.serverRenderDistance: the server's view distance from the
/// login or the last chunk-cache-radius packet.
pub(super) view_distance: i32,
view_distance: i32,
sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
@@ -160,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)>,
@@ -193,12 +192,10 @@ pub(super) struct Remote {
destroying: Option<([i32; 3], u8, f32)>,
destroy_delay: u32,
input: horizon_client::interaction_input::InteractionInput,
/// Keys whose presses send a packet (swap hands, drop, a mount's
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
/// `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.
@@ -220,9 +217,6 @@ pub(super) struct Remote {
/// `ClientboundTransferPacket`'s target: when the connection ends the
/// game connects here, the stored cookies carried over (`TransferState`).
transfer: Option<String>,
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -405,9 +399,6 @@ impl Game<'_> {
self.carried = None;
self.using = None;
self.clicks.miss_time = 0;
// A new LocalPlayer has no effects: the server sends what it has
// (ClientboundUpdateMobEffectPacket on placement).
self.potions = Default::default();
self.set_mode_id(0);
self.experience = Experience {
level: 0,
@@ -442,7 +433,6 @@ impl Game<'_> {
joined: false,
status: "Connecting to the server...".into(),
entity_id: 0,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
@@ -469,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,
@@ -488,9 +479,9 @@ impl Game<'_> {
destroying: None,
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
stop_mining: false,
placing: None,
flat: false,
death_message: None,
player_type,
synced: vec![None; 36],
@@ -502,7 +493,6 @@ impl Game<'_> {
chunk_center: (0, 0),
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
@@ -879,11 +869,6 @@ impl Game<'_> {
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::DecodeError(e) => {
@@ -1011,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();
@@ -1430,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) {
@@ -1643,11 +1636,7 @@ impl Game<'_> {
}
C::Login(p) => {
self.level.hardcore = p.hardcore;
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0).
self.selected = 0;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
r.view_distance = p.chunk_radius;
@@ -1665,10 +1654,8 @@ impl Game<'_> {
self.send(S::ClientInformation(info));
}
C::Respawn(p) => {
// A new player, without effects (the server sends them
// again) and with no item in use.
// A new player, without effects: the server sends them again.
self.potions.effects.clear();
self.using = None;
self.enter_remote_dimension(&p.spawn, true);
self.screen = Screen::Playing;
if let Some(r) = self.remote.as_mut() {
@@ -1896,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) {
@@ -1904,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)),
},
};
}
}
@@ -2137,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;
@@ -2185,6 +2179,37 @@ impl Game<'_> {
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 {
@@ -2202,11 +2227,6 @@ impl Game<'_> {
self.honey_slide_particles(at);
}
}
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
{
self.remote_complete_using();
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
@@ -2369,11 +2389,10 @@ impl Game<'_> {
}
self.apply_remote_abilities();
}
// ClientPacketListener.handleSetHeldSlot: a hotbar slot only, and the
// carried index stays, so the next tick answers with SetCarriedItem.
C::SetHeldSlot(p) => {
if (0..9).contains(&p.slot) {
self.selected = p.slot as usize;
self.selected = p.slot.clamp(0, 8) as usize;
if let Some(r) = self.remote.as_mut() {
r.carried = Some(self.selected);
}
}
C::ContainerSetContent(p) => {
@@ -3091,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()
@@ -3113,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;
@@ -3172,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.
@@ -3205,6 +3260,12 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
// for a server's client (multiplayer and the default single player),
// so the entries advance here or the slot stays shaded until the
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
@@ -3240,14 +3301,6 @@ impl Game<'_> {
// Window callbacks only queue clicks, so an interaction cannot leak
// between CLIENT_TICK_END and a later pong.
self.remote_handle_input();
// Player.tick's ItemCooldowns.tick, in ClientLevel.tickEntities after
// handleKeybinds: this path replaces `tick_world`'s for a server's
// client (multiplayer and the default single player), so the entries
// advance here or the slot stays shaded until the server says the
// entry ended. `onCooldownEnded` is the plain ItemCooldowns' no-op on
// a client. Ticking it after the keybinds keeps a held use from
// trying again a tick before the server's entry ends.
self.cooldowns.tick();
// ClientLevel.tick: the border's size change.
self.border.tick();
self.player_border();
@@ -3388,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)
@@ -4275,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,
@@ -4384,27 +4447,13 @@ impl Game<'_> {
let Some(info) = self.res.items().get(&held.item).cloned() else {
return false;
};
// MultiPlayerGameMode.useItem's prediction: an item cooling down
// passes (its packet still goes).
let group = horizon_client::cooldowns::group(&held, info.use_cooldown.as_ref());
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// Food starts being eaten (Consumable.startConsuming). A food that
// can't be eaten (full and not always-edible) fails the use
// (InteractionResult.FAIL): startUseItem tries the other hand, and
// no cooldown or use animation starts.
let eating = self.with_hand(hand, |g| g.start_eating(&info));
if info.food.is_some() && !eating {
return false;
}
// It runs the use here too: ItemStack.use starts the `use_cooldown`
// of an item with no use time, and the server's
// ClientboundCooldownPacket confirms it; one held in use starts it at
// finishUsingItem (remote_complete_using).
if !info.consumable.as_ref().is_some_and(|c| c.ticks() > 0) {
self.apply_use_cooldown(&held);
}
// MultiPlayerGameMode.useItem runs the use here too (inside
// startPrediction): the `use_cooldown` component starts its entry, and
// the server's ClientboundCooldownPacket confirms it.
self.apply_use_cooldown(&held);
// Food starts being eaten (Consumable.startConsuming), a book and
// quill opens.
self.with_hand(hand, |g| g.start_eating(&info));
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
}
@@ -4476,25 +4525,14 @@ impl Game<'_> {
self.remote_use_item(Hand::Off);
}
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a consumable's eating animation and
/// its crumbs until the server's USE_ITEM_COMPLETE. The server sends the
/// stack, its hunger and the sounds.
/// use (`held_until_released`), and a
/// consumable's eating animation and its crumbs. The server eats it and
/// sends the stack, the hunger and the sounds.
fn remote_tick_using(&mut self) {
let Some((slot, ticks)) = self.using else {
return;
};
let stack = self.stack_in(slot).cloned();
// The use stops when the hand no longer holds the item it began with
// (ItemStack.isSameItem).
let item = stack.as_ref().map(|s| Arc::clone(&s.item));
if let Some(r) = self.remote.as_mut() {
if ticks == 0 {
r.use_item = item;
} else if r.use_item != item {
self.using = None;
return;
}
}
// Another hotbar slot picked: LivingEntity.updatingUsingItem sees a
// different stack in the hand. The off hand keeps its item.
let in_hand = slot == self.selected || slot == super::shield::OFFHAND_SLOT;
@@ -4519,51 +4557,20 @@ impl Game<'_> {
self.using = None;
return;
}
// Consumable.startConsuming eats one with no use time at once, without
// a use (nor a USE_ITEM_COMPLETE).
if consumable.ticks() <= 0 {
self.using = None;
return;
}
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
if consumable.particles && consumable.should_emit(remaining) {
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item.
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
if consumable.particles && consumable.should_emit(remaining as i32) {
self.eat_particles(&stack, 5);
}
self.using = Some((slot, ticks + 1));
}
/// `Player.handleEntityEvent(9)`: `completeUsingItem` on the client. A consumable's last crumbs, and FoodProperties.onConsume,
/// which runs on the client too (Consumable.onConsume calls its
/// ConsumableListeners on both sides): the client's FoodData eats at once,
/// so a held right click finds `Player.canEat` false instead of starting
/// another bite before the server's ClientboundSetHealth arrives.
fn remote_complete_using(&mut self) {
let Some((slot, _)) = self.using.take() else {
return;
};
let Some(stack) = self.stack_in(slot).cloned() else {
return;
};
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return;
};
if info.consumable.as_ref().is_some_and(|c| c.particles) {
self.eat_particles(&stack, 16);
if ticks + 1 >= duration {
self.using = None;
if consumable.particles {
self.eat_particles(&stack, 16);
}
} else {
self.using = Some((slot, ticks + 1));
}
if let Some((nutrition, saturation)) = info.food
&& let Some(r) = self.remote.as_mut()
{
// FoodData.eat(nutrition, saturation), as finish_consuming does;
// the next ClientboundSetHealth replaces it.
let (_, food, sat) = &mut r.health;
*food = (*food + nutrition as i32).clamp(0, 20);
*sat = (*sat + saturation).clamp(0.0, *food as f32);
self.apply_remote_health();
}
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
}
/// MouseHandler updates KeyMapping; handleKeybinds sends packets on a tick.
pub(super) fn remote_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
@@ -4628,31 +4635,6 @@ impl Game<'_> {
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
.remote
.as_mut()
.map(|r| std::mem::take(&mut r.pending_keys))
.unwrap_or_default();
for key in pending {
match key {
KeyCode::KeyF if !self.spectator() => self.remote_swap_hands(),
KeyCode::KeyQ if !self.spectator() => {
let all = self.keys.contains(&KeyCode::ControlLeft);
self.remote_drop(all);
}
KeyCode::KeyE if self.riding_mount_with_menu() => {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
}
_ => {}
}
}
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
@@ -4764,8 +4746,22 @@ impl Game<'_> {
if self.spectator() {
return Ok(true);
}
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
let all = self.keys.contains(&KeyCode::ControlLeft);
// MultiPlayerGameMode.dropItem: predict removal before sending
// the action, then swing only if a nonempty stack was removed.
let dropped = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
false,
);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
Ok(true)
}
@@ -4773,9 +4769,12 @@ impl Game<'_> {
// horse or llama, the server opens its inventory
// (ServerboundPlayerCommandPacket OPEN_INVENTORY).
KeyCode::KeyE if self.riding_mount_with_menu() => {
if let Some(r) = self.remote.as_mut() {
r.pending_keys.push(key);
}
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 5,
data: 0,
}));
Ok(true)
}
KeyCode::KeyE => {
@@ -4822,21 +4821,6 @@ impl Game<'_> {
}));
}
}
/// MultiPlayerGameMode.dropItem (key.drop): predict the removal, then send
/// the action; swing only if a nonempty stack was removed.
fn remote_drop(&mut self, all: bool) {
let dropped =
horizon_client::items::take_held_drop(&mut self.inventory[self.selected], all, false);
self.send(S::PlayerAction(play::PlayerAction {
action: if all { 4 } else { 5 },
pos: [0; 3],
direction: 0,
sequence: 0,
}));
if dropped.is_some() {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
}
/// Minecraft.handleKeybinds, key.swapOffhand: the server swaps the hands
/// (ServerboundPlayerActionPacket SWAP_ITEM_WITH_OFFHAND at BlockPos.ZERO, facing down) and
/// sends both slots back; nothing is predicted.
@@ -97,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;
@@ -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 {
@@ -360,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);
@@ -414,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);
}
}
}
}
+63 -6
View File
@@ -92,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,
@@ -108,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(),
@@ -272,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,
@@ -280,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 = [
@@ -289,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;
@@ -306,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);
@@ -337,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 {
+17 -74
View File
@@ -19,9 +19,6 @@ impl Screen {
| Self::Advancements
| Self::WorldOptions
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
| Self::Mod => true,
Self::Playing
| Self::GameModeSwitcher
@@ -38,35 +35,20 @@ impl Screen {
}
/// PauseScreen buttons: (x, y, width, label key, action id, active).
/// `links`: the `minecraft:server_links` dialog's button, which the
/// `pause_screen_additions` tag (empty in vanilla) falls back to when the
/// server sent links — a full-width row that pushes the last two rows down.
/// https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/PauseScreen
fn pause_buttons(
w: f32,
h: f32,
remote: bool,
links: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
fn pause_buttons(w: f32, h: f32, remote: bool) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
// GridLayout of two 98px columns (padding 4), aligned at (0.5, 0.25).
let (grid_w, grid_h) = (212.0, 166.0 + if links { 24.0 } else { 0.0 });
let (grid_w, grid_h) = (212.0, 166.0);
let x0 = ((w - grid_w) * 0.5).floor();
let y0 = ((h - grid_h) * 0.25).floor();
let down = if links { 24.0 } else { 0.0 };
let mut v = vec![
vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
(x0 + 110.0, y0 + 74.0, 98.0, "gui.stats", 3, true),
];
if links {
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
(x0 + 4.0, y0 + 122.0, 98.0, "menu.options", 1, true),
(
x0 + 110.0,
y0 + 122.0 + down,
y0 + 122.0,
98.0,
"options.worldOptions.button",
9,
@@ -74,7 +56,7 @@ fn pause_buttons(
),
(
x0 + 4.0,
y0 + 146.0 + down,
y0 + 146.0,
204.0,
if remote {
"menu.disconnect"
@@ -84,17 +66,16 @@ fn pause_buttons(
2,
true,
),
]);
v
]
}
/// The icon row: report bugs, feedback, friends, player reporting.
/// `FRIENDS_ICON` (vanilla's `CommonButtons.friends`) opens Horizon's
/// Play with Friends; the others stay inactive.
const FRIENDS_ICON: usize = 2;
fn pause_icons(w: f32, h: f32, links: bool) -> [(f32, f32, &'static str); 4] {
fn pause_icons(w: f32, h: f32) -> [(f32, f32, &'static str); 4] {
let x0 = ((w - 212.0) * 0.5).floor() + 60.0;
let y = ((h - 166.0 - if links { 24.0 } else { 0.0 }) * 0.25).floor() + 98.0;
let y = ((h - 166.0) * 0.25).floor() + 98.0;
[
(x0, y, "gui/sprites/pause_menu/bug"),
(x0 + 24.0, y, "gui/sprites/pause_menu/social_interactions"),
@@ -139,9 +120,7 @@ impl Game<'_> {
u32::MAX,
true,
);
let links = !self.server_links().is_empty();
let mut tooltip = None;
for (x, y, bw, key, id, active) in pause_buttons(w, h, self.external_server(), links) {
for (x, y, bw, key, _, active) in pause_buttons(w, h, self.external_server()) {
let hover = active && ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(
batch,
@@ -154,12 +133,9 @@ impl Game<'_> {
active,
hover,
);
// PauseScreen's customAdditions button: CUSTOM_OPTIONS_TOOLTIP.
if id == 11 && hover {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(w, h, links).into_iter().enumerate() {
let mut tooltip = None;
for (i, (x, y, sprite)) in pause_icons(w, h).into_iter().enumerate() {
let active = i == FRIENDS_ICON && friends;
let hover = ui::hovered(self.cursor, x, y, 20.0, 20.0);
// The icon has no label: name it for controller focus and the
@@ -192,13 +168,7 @@ impl Game<'_> {
}
// SpriteIconButton.withTootip: the label as a tooltip.
if let Some(tip) = tooltip {
let lines: Vec<Text> = atlas
.font
.split_lines(&tip, batch.width)
.into_iter()
.map(|(line, _)| line)
.collect();
ui::tooltip(batch, atlas, &lines, self.cursor);
ui::tooltip(batch, atlas, &[tip], self.cursor);
}
}
pub(super) fn pause_click(&mut self) -> Result<()> {
@@ -210,9 +180,8 @@ impl Game<'_> {
let b = HudBatch::new(size);
(b.width, b.height)
};
let links = !self.server_links().is_empty();
let friends = pause_icons(w, h, links)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server(), links)
let friends = pause_icons(w, h)[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
@@ -231,11 +200,6 @@ impl Game<'_> {
Some(4) => self.open_advancements(true),
Some(9) => self.open_world_options(),
Some(10) => self.screen = Screen::Friends,
// DialogConnectionAccess.openDialog(Dialogs.SERVER_LINKS).
Some(11) => {
self.links_ui.scroll = 0.0;
self.screen = Screen::ServerLinks;
}
Some(2) => self.close_world()?,
_ => {}
}
@@ -393,7 +357,7 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, pause_buttons, pause_icons};
use super::{Screen, pause_buttons};
#[test]
fn menu_blur_matches_vanilla_screen_backgrounds() {
@@ -426,32 +390,11 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false);
let buttons = pause_buttons(427.0, 240.0, remote);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
assert!(buttons.iter().any(|b| b.4 == 9 && b.5));
}
}
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true);
let without = pause_buttons(427.0, 240.0, true, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
for b in &with {
if b.4 == 1 || b.4 == 9 || b.4 == 2 {
let same = without.iter().find(|o| o.4 == b.4).unwrap();
assert_eq!(b.1, same.1 + 24.0 - 6.0, "{}", b.3);
}
}
}
}
@@ -1,487 +0,0 @@
//! `ServerLinksDialogScreen`: the pause menu's "Server Links..." button opens
//! the `minecraft:server_links` dialog (the `pause_screen_additions` tag's
//! fallback when a server sent ClientboundServerLinksPacket): a scrollable
//! list of the server's links, each opened through `ConfirmLinkScreen`
//! (Screen.clickUrlAction), and the `dialog/warning_button`'s
//! DialogScreen.WarningScreen ("Note about custom screens").
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/dialog/ServerLinksDialogScreen
use super::{Game, Screen, packs::open_uri};
use horizon_client::{Result, clipboard::Clipboard, hud::HudBatch, text::Text, ui};
use horizon_protocol::packets::configuration::{ServerLink, ServerLinkKind};
/// HeaderAndFooterLayout's header and footer heights; the content wants to
/// start 30 pixels under the header (its `CONTENTS_MARGIN_TOP`).
const HEADER: f32 = 33.0;
const FOOTER: f32 = 33.0;
const CONTENT_TOP: f32 = HEADER + 30.0;
/// `server_links.json`: `columns` 1, `button_width` 310, `exit_action`'s 200.
const LINK_WIDTH: f32 = 310.0;
/// packControlsIntoColumns: rowSpacing 2 under the 20-pixel buttons.
const ROW: f32 = 22.0;
/// ScrollableLayout's BOTH reserve (scrollbarSpacing 4 + SCROLLBAR_WIDTH 6).
const SCROLL_RESERVE: f32 = 10.0;
/// DialogScreen's `dialog/warning_button` ImageButton.
const WARNING_BUTTON: f32 = 20.0;
/// The ServerLinks screen's state: ScrollableLayout's scroll and the link
/// ConfirmLinkScreen is asking about (`Screen::ConfirmLink`).
#[derive(Default)]
pub(super) struct LinksUi {
pub scroll: f32,
pub confirm: Option<String>,
}
/// `Util.parseAndValidateUntrustedUri`: an absolute `http`/`https` URI
/// (java.net.URI's scheme then the allowlist; whitespace-free).
pub(super) fn valid_untrusted_uri(uri: &str) -> bool {
let Some(colon) = uri.find(':') else {
return false;
};
let scheme = &uri[..colon];
let mut chars = scheme.chars();
if !chars.next().is_some_and(|c| c.is_ascii_alphabetic())
|| !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
{
return false;
}
if !matches!(scheme.to_ascii_lowercase().as_str(), "http" | "https") {
return false;
}
!uri.chars().any(|c| c.is_control() || c == ' ')
}
/// `ClientCommonPacketListenerImpl.handleServerLinks`: keep only the links
/// that validate, warning about the dropped ones.
pub(super) fn trusted(links: Vec<ServerLink>) -> Vec<ServerLink> {
links
.into_iter()
.filter(|l| {
let ok = valid_untrusted_uri(&l.link);
if !ok {
log::warn!("Horizon: server sent an invalid link: {}", l.link);
}
ok
})
.collect()
}
/// `ServerLinks.Entry.displayName`: `known_server_link.<name>` for a known
/// type (ByIdMap.ZERO reads unknown ids as `report_bug`), the component else.
fn link_label(link: &ServerLink) -> Text {
const KNOWN: [&str; 10] = [
"report_bug",
"community_guidelines",
"support",
"status",
"feedback",
"community",
"website",
"forums",
"news",
"announcements",
];
match &link.kind {
ServerLinkKind::Known(id) => Text::translate(
&format!(
"known_server_link.{}",
KNOWN.get(*id as usize).copied().unwrap_or(KNOWN[0])
),
&[],
),
ServerLinkKind::Custom(tag) => {
horizon_client::scoreboard::comp_text(&horizon_client::scoreboard::tag_to_json(tag))
}
}
}
/// The scrolled column of link buttons: content height and, for a list that
/// fits, the `HeaderAndFooterLayout` contents frame's top.
fn list_height(n: usize) -> f32 {
n as f32 * ROW - 2.0
}
fn list_top(h: f32, n: usize) -> f32 {
CONTENT_TOP.min(h - FOOTER - list_height(n).min(h - HEADER - FOOTER))
}
fn scroll_limit(h: f32, n: usize) -> f32 {
(list_height(n) - (h - HEADER - FOOTER)).max(0.0)
}
/// `ServerLinksScreen`'s buttons as (x, y) over the scrolled content, and the
/// footer `Back` button's corner. `top` includes the scroll.
fn link_buttons(w: f32, top: f32, n: usize) -> Vec<(f32, f32)> {
let x = ((w - LINK_WIDTH - 2.0 * SCROLL_RESERVE) / 2.0 + SCROLL_RESERVE).floor();
(0..n).map(|i| (x, top + i as f32 * ROW)).collect()
}
/// The warning button beside the title (DialogScreen's title row: 10 pixels
/// apart), clamped onto the screen (makeSureWarningButtonIsInBounds).
fn warning_button(w: f32, h: f32, title_width: f32) -> (f32, f32) {
let x = ((w - title_width - 30.0) / 2.0).floor() + title_width + 10.0;
let y = ((HEADER - WARNING_BUTTON) / 2.0).floor();
if x < 0.0 || y < 0.0 || x > w - WARNING_BUTTON || y > h - WARNING_BUTTON {
(0.0f32.max(w - 40.0), 5.0f32.min(h))
} else {
(x, y)
}
}
fn back_button(w: f32, h: f32) -> (f32, f32) {
(
((w - 200.0) / 2.0).floor(),
h - FOOTER + ((FOOTER - 20.0) / 2.0).floor(),
)
}
impl Game<'_> {
/// `connection.serverLinks()`: the links the open server sent (empty for
/// a world without a connection or one that sent none).
pub(super) fn server_links(&self) -> &[ServerLink] {
self.remote
.as_ref()
.map_or(&[], |r| r.server_links.as_slice())
}
/// DialogScreen over the pause menu: `menu.server_links.title`, the
/// scrollable link buttons and `gui.back` in the footer.
pub(super) fn draw_server_links(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let links = self.server_links().to_vec();
self.links_ui.scroll = self
.links_ui
.scroll
.clamp(0.0, scroll_limit(h, links.len()));
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
// The title row: the title, then 10 pixels on the warning button.
let title = Text::translate("menu.server_links.title", &[]);
let tw = atlas.font.width_text(&title);
let tx = ((w - tw - 30.0) / 2.0).floor();
batch.text_styled(atlas, &title, tx, 12.0, u32::MAX, true);
let (wx, wy) = warning_button(w, h, tw);
let wh = ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON);
horizon_client::widgets::record(
"menu.custom_screen_info.tooltip",
[wx, wy, WARNING_BUTTON, WARNING_BUTTON],
true,
);
batch.sprite_scaled(
atlas,
if wh {
"gui/sprites/dialog/warning_button_highlighted"
} else {
"gui/sprites/dialog/warning_button"
},
wx,
wy,
WARNING_BUTTON,
WARNING_BUTTON,
u32::MAX,
);
let first = batch.vertices.len();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
for (link, (x, y)) in links.iter().zip(link_buttons(w, top, links.len())) {
let hover = inside && ui::hovered(self.cursor, x, y, LINK_WIDTH, 20.0);
ui::button(
batch,
atlas,
x,
y,
LINK_WIDTH,
20.0,
&link_label(link),
true,
hover,
);
}
batch.clip_since(first, 0.0, HEADER, w, h - FOOTER);
ui::scrollbar(
batch,
atlas,
(w / 2.0).floor() + 155.0 + 4.0,
HEADER,
h - FOOTER,
list_height(links.len()),
self.links_ui.scroll.trunc(),
);
let (bx, by) = back_button(w, h);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
batch,
atlas,
bx,
by,
200.0,
20.0,
&Text::translate("gui.back", &[]),
true,
hover,
);
if wh {
ui::tooltip(
batch,
atlas,
&[Text::translate("menu.custom_screen_info.tooltip", &[])],
self.cursor,
);
}
}
/// A click on `Screen::ServerLinks`: a link, the warning button or Back.
pub(super) fn server_links_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let links = self.server_links().to_vec();
let top = list_top(h, links.len()) - self.links_ui.scroll.trunc();
let inside = self.cursor.1 >= HEADER && self.cursor.1 < h - FOOTER;
if inside
&& let Some(i) = link_buttons(w, top, links.len())
.iter()
.position(|(x, y)| ui::hovered(self.cursor, *x, *y, LINK_WIDTH, 20.0))
{
self.audio.play_button_click();
self.click_link(&links[i].link);
return Ok(());
}
let title = Text::translate("menu.server_links.title", &[]);
let tw = self.gui.atlas.font.width_text(&title);
let (wx, wy) = warning_button(w, h, tw);
if ui::hovered(self.cursor, wx, wy, WARNING_BUTTON, WARNING_BUTTON) {
self.audio.play_button_click();
self.screen = Screen::DialogWarning;
return Ok(());
}
let (bx, by) = back_button(w, h);
if ui::hovered(self.cursor, bx, by, 200.0, 20.0) {
self.audio.play_button_click();
self.screen = Screen::Paused;
}
Ok(())
}
/// `Screen.clickUrlAction`: "Web Links" off ignores the click; "Prompt on
/// Links" asks with ConfirmLinkScreen, else the browser opens directly.
fn click_link(&mut self, url: &str) {
if !self.settings.chat_links {
return;
}
if self.settings.chat_links_prompt {
self.links_ui.confirm = Some(url.to_owned());
self.screen = Screen::ConfirmLink;
} else {
open_uri(url);
}
}
/// `ScrollableLayout`'s mouse wheel over the links.
pub(super) fn server_links_scroll(&mut self, delta: f32) {
let (_, h) = self.gui_size();
self.links_ui.scroll = (self.links_ui.scroll - delta * 10.0)
.clamp(0.0, scroll_limit(h, self.server_links().len()));
}
/// `ConfirmLinkScreen` (untrusted): "Are you sure you want to open the
/// following website?", the link, the red warning, and Yes / Copy Link to
/// Clipboard / No.
pub(super) fn draw_confirm_link(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(atlas, (w, h), &url);
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let labels = ["gui.yes", "gui.copy_link_to_clipboard", "gui.no"];
for ((x, y), key) in layout.buttons.iter().zip(labels) {
let hover = ui::hovered(self.cursor, *x, *y, 100.0, 20.0);
ui::button(
batch,
atlas,
*x,
*y,
100.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// ConfirmLinkScreen's answer: yes opens the browser, the middle button
/// copies the link, no closes; all return to Server Links.
pub(super) fn confirm_link_click(&mut self) {
let (w, h) = self.gui_size();
let url = self.links_ui.confirm.clone().unwrap_or_default();
let layout = confirm_link_layout(&self.gui.atlas, (w, h), &url);
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 100.0, 20.0) {
continue;
}
self.audio.play_button_click();
match i {
0 => open_uri(&url),
1 => {
self.clipboard.set(&url);
}
_ => {}
}
self.screen = Screen::ServerLinks;
return;
}
}
/// `DialogScreen.WarningScreen`: the custom-screens note, a disconnect
/// button ("Custom screen rejected" is the shown reason) and Back.
pub(super) fn draw_dialog_warning(&mut self, batch: &mut HudBatch) {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let layout = dialog_warning_layout(atlas, (w, h));
for (line, x, y) in &layout.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
let quit = if self.external_server() {
"menu.disconnect"
} else {
"menu.returnToMenu"
};
for ((x, y), key) in layout.buttons.into_iter().zip([quit, "gui.back"]) {
let hover = ui::hovered(self.cursor, x, y, 150.0, 20.0);
ui::button(
batch,
atlas,
x,
y,
150.0,
20.0,
&Text::translate(key, &[]),
true,
hover,
);
}
}
/// WarningScreen's answer: disconnect, or back to Server Links.
pub(super) fn dialog_warning_click(&mut self) -> Result<()> {
let (w, h) = self.gui_size();
let layout = dialog_warning_layout(&self.gui.atlas, (w, h));
for (i, (x, y)) in layout.buttons.iter().enumerate() {
if !ui::hovered(self.cursor, *x, *y, 150.0, 20.0) {
continue;
}
self.audio.play_button_click();
if i == 0 {
// DialogConnectionAccess.disconnect: leave with the custom
// screens' reason.
let reason = self.translate(
"menu.custom_screen_info.disconnect",
"Custom screen rejected",
);
self.leave_server(Some(reason))?;
} else {
self.screen = Screen::ServerLinks;
}
return Ok(());
}
Ok(())
}
}
/// A confirm screen's laid-out text (title, message, extra warning lines) and
/// button corners; the whole column centred (ConfirmScreen.init).
struct ConfirmLayout {
lines: Vec<(Text, f32, f32)>,
buttons: Vec<(f32, f32)>,
}
/// `ConfirmScreen.init`: title, message wrapped to `width - 50` (15 rows at
/// most), the extra lines, then a button row `spacing` 4 with `paddingTop` 16.
fn confirm_layout(
font: &horizon_client::font::Font,
(w, h): (f32, f32),
title: &Text,
message: &Text,
extra: &[Text],
button_widths: &[f32],
) -> ConfirmLayout {
let message: Vec<Text> = font
.split_lines(message, w - 50.0)
.into_iter()
.take(15)
.map(|(line, _)| line)
.collect();
// LinearLayout vertical spacing 8; the button row's children pad 16 on top.
let extra_h = 17.0 * extra.len() as f32;
let mh = 9.0 * message.len() as f32;
let total = 9.0 + 8.0 + mh + extra_h + 8.0 + 16.0 + 20.0;
let mut y = ((h - total) / 2.0).trunc();
let mut lines = Vec::new();
let mut push = |line: &Text, at: f32| {
let lw = font.width_text(line);
lines.push((line.clone(), ((w - lw) / 2.0).trunc(), at));
};
push(title, y);
y += 17.0;
for line in &message {
push(line, y);
y += 9.0;
}
for line in extra {
y += 8.0;
push(line, y);
y += 9.0;
}
let row_w: f32 = button_widths.iter().sum::<f32>() + 4.0 * (button_widths.len() - 1) as f32;
let by = y + 8.0 + 16.0;
let mut bx = ((w - row_w) / 2.0).trunc();
let buttons = button_widths
.iter()
.map(|bw| {
let p = (bx, by);
bx += bw + 4.0;
p
})
.collect();
ConfirmLayout { lines, buttons }
}
fn confirm_link_layout(
atlas: &horizon_client::hud::HudAtlas,
size: (f32, f32),
url: &str,
) -> ConfirmLayout {
// ConfirmLinkScreen (untrusted): `chat.link.confirm`, the link as the
// message, `chat.link.warning` in its red (0xFFCCCC).
confirm_layout(
&atlas.font,
size,
&Text::translate("chat.link.confirm", &[]),
&Text::literal(url.to_owned()),
&[Text::translate("chat.link.warning", &[]).with_color(0xFFCCCC)],
&[100.0; 3],
)
}
fn dialog_warning_layout(atlas: &horizon_client::hud::HudAtlas, size: (f32, f32)) -> ConfirmLayout {
confirm_layout(
&atlas.font,
size,
&Text::translate("menu.custom_screen_info.title", &[]),
&Text::translate("menu.custom_screen_info.contents", &[]),
&[],
&[150.0; 2],
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn untrusted_uris_are_http_or_https_with_a_scheme() {
for ok in [
"https://example.net/x",
"http://example.net",
"HTTPS://EXAMPLE.NET",
] {
assert!(valid_untrusted_uri(ok), "{ok}");
}
for bad in [
"example.net/no-scheme",
"ftp://example.net",
"file:///etc/passwd",
"javascript:alert(1)",
"https://exa mple.net",
"://example.net",
"",
] {
assert!(!valid_untrusted_uri(bad), "{bad}");
}
}
}
+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();
@@ -779,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();
@@ -795,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();
}
+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)
@@ -164,12 +164,11 @@ impl Game<'_> {
} else {
[self.yaw, self.pitch]
};
let scale = self.view_scale();
for display in self.text_displays.values_mut() {
display.last_progress = display.progress(partial as f32);
let d = display.shown.blend(&display.from, display.last_progress);
let position = display.previous.lerp(display.position, partial);
let range = f64::from(d.view_range) * 64.0 * scale;
let range = f64::from(d.view_range) * 64.0 * f64::from(self.settings.entity_distance);
if position.distance_squared(camera) >= range * range || d.text.is_empty() {
continue;
}
+4 -2
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;
}
@@ -252,7 +254,7 @@ impl Game<'_> {
}
let range = super::entity_culling::default_range(
glam::DVec3::splat(PrimedTnt::SIZE),
self.view_scale(),
self.settings.entity_distance,
);
for t in &self.tnts {
// EntityRenderer.shouldRender.
+24 -6
View File
@@ -245,7 +245,7 @@ impl Game<'_> {
self.handle_mob_events(events)?;
return Ok(true);
}
self.stats.custom("talk_to_villager", 1);
self.stats.custom("talked_to_villager", 1);
self.ensure_offers(index);
let m = &self.mobs[index];
let trade = m.extra.trade.as_deref().cloned().unwrap_or_default();
@@ -398,18 +398,21 @@ impl Game<'_> {
.as_ref()
.and_then(|s| s.merchant.as_ref())
.and_then(|m| m.mob);
let session = self.merchant_apply(session, mob);
let (session, traded) = self.merchant_apply(session, mob);
self.merchant_traded(mob, &traded);
if let Some(m) = self.station.as_mut().and_then(|s| s.merchant.as_mut()) {
m.session = session;
}
}
/// What a menu click's session did, applied to the world and its mob.
/// What a menu click's session did, applied to the world and its mob;
/// with the result of each trade made, for `merchant_traded`.
pub(super) fn merchant_apply(
&mut self,
session: Box<merchant::Session>,
mob: Option<u32>,
) -> Box<merchant::Session> {
) -> (Box<merchant::Session>, Vec<ItemStack>) {
let mut session = session;
let mut traded = Vec::new();
let events = std::mem::take(&mut session.events);
let (villager, origin) = (session.villager, session.origin);
if let Some(m) = mob.and_then(|id| self.mobs.iter_mut().find(|m| m.id == id)) {
@@ -438,11 +441,26 @@ impl Game<'_> {
};
self.audio.play(sound, Some(origin), 1.0, pitch);
}
merchant::Event::Traded(_) => self.stats.custom("traded_with_villager", 1),
merchant::Event::Traded(index) => {
traded.extend(session.offers.get(index).map(|o| o.result.clone()));
}
merchant::Event::TradeSound => {}
}
}
session
(session, traded)
}
/// `MerchantResultSlot.checkTakeAchievements` and `AbstractVillager.notifyTrade`
/// for each trade made: the statistic and the `villager_trade` trigger.
pub(super) fn merchant_traded(&mut self, mob: Option<u32>, results: &[ItemStack]) {
for result in results {
self.stats.custom("traded_with_villager", 1);
let villager = mob
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| self.mob_entity(m));
if let Some(villager) = villager {
self.trigger_trade(&villager, result);
}
}
}
/// `Villager.customServerAiStep`: the TRADE reputation event of the last
/// trade, a tick later; the prices follow the new reputation.
+38 -1
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]),
@@ -495,7 +532,7 @@ impl Game<'_> {
let range = 10.0
* super::entity_culling::default_range(
glam::DVec3::splat(2.0 * HALF),
self.view_scale(),
self.settings.entity_distance,
);
for t in &self.tridents {
// EntityRenderer.shouldRender; ThrownTridentRenderer grows the box by 1.5.
+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.
+23 -24
View File
@@ -511,10 +511,10 @@ pub struct Renderer {
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
hdr: Option<hdr::Hdr>,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// Whether the window's surface can present scRGB.
hdr_capable: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -1071,7 +1071,7 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.ok_or("surface has no supported configuration")?;
@@ -1099,7 +1099,7 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::output(&caps))
(config, caps.present_modes.clone(), hdr::supported(&caps))
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -1113,7 +1113,7 @@ impl Renderer {
view_formats: vec![format],
color_space: wgpu::SurfaceColorSpace::default(),
};
(config, Vec::new(), None)
(config, Vec::new(), false)
};
let offscreen = surface
.is_none()
@@ -1953,23 +1953,22 @@ impl Renderer {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &entity_shader,
entry_point: Some("fs_sky_celestial"),
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
// the colour premultiplied in fs_sky_celestial. Its alpha is
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
// and moon's discs shine; nothing else reads the frame's alpha.
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
// moon shine; nothing else reads the frame's alpha.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
}),
@@ -2457,7 +2456,7 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
hdr: None,
hdr_output,
hdr_capable,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -2991,24 +2990,24 @@ impl Renderer {
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
/// scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
if self.hdr.is_some() {
config.format = hdr::FORMAT;
config.view_formats = vec![hdr::FORMAT];
config.color_space = hdr::COLOR_SPACE;
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// HDR output (Horizon setting): HDR10 or scRGB at `paper_white` nits, the world's
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
if output.is_some() != self.hdr.is_some() {
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
let on = on && self.hdr_capable && self.surface.is_some();
if on != self.hdr.is_some() {
self.hdr =
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
self.configure_surface();
}
if let Some(hdr) = &mut self.hdr
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more