Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 02:26:42 +01:00
Ludovic MarieandClaude Opus 5.5 3f0e70882c Death messages: no fallback for a message with no entity behind it
DamageSource.getLocalizedDeathMessage sends the one-argument message, and
TranslatableContents.decompose shows a template it cannot fill as written.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:31 +01:00
Ludovic MarieandClaude Opus 5.5 83b544f04f Bonus chest: draw from the level random and set blocks with UPDATE_CLIENTS
MinecraftServer.setInitialSpawn passes level.getRandom() to
BonusChestFeature.place, which sets the chest and torches with flags 2.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:10:41 +01:00
Kacpherek 94748162bc Handle a dig packet before the move queued behind it
The client breaks the block under its feet and falls in the same tick. The
hub checked that move against the block that was still there and sent the
player back. Moves queued behind a dig packet now wait for the next tick,
after the break.
2026-10-26 21:08:59 +01:00
Kacpherek 06b046d8fc Light, scaffolding and shulker box outlines follow the held item and the lid 2026-10-26 21:07:17 +01:00
Kacpherek c85f6f2e39 Give polar bears their attack AI and rearing pose
Polar bears only panicked and strolled. They now follow vanilla's goals: melee attack that rears up and growls before each strike, hurt-by and cub-defending targets, anger at the player, and fox hunting. A hurt cub calls the adults. The standing flag is synched and the client animates it.
2026-10-26 20:50:13 +01:00
Kacpherek 36c06d6133 Let a piglin celebrate a killed target and dance over a hoglin 2026-10-26 20:41:38 +01:00
Kacpherek ffc343aadd Make execute on origin and leasher find their entity 2026-10-26 20:37:39 +01:00
Kacpherek 4cf9ffd847 Draw the autosave indicator 2026-10-26 20:36:40 +01:00
Kacpherek e23fcb1040 Scale a baby's walk position when it is read, as vanilla does 2026-10-26 20:35:46 +01:00
Kacpherek 27b81d9a8f Give /damage on a player its source, knockback and kill credit 2026-10-26 20:35:42 +01:00
Kacpherek 7b3595b8f2 Draw the snow golem's pumpkin and the mooshroom's mushrooms 2026-10-26 20:34:18 +01:00
Kacpherek c417556834 Send /tellraw, /title and /playsound to LAN guests 2026-10-26 20:33:36 +01:00
Kacpherek bf62ccdabc Place the bonus chest in new worlds 2026-10-26 20:33:16 +01:00
Kacpherek 41d8b9e48a Jigsaw structures: default max distance vertical and padding top like vanilla 2026-10-26 20:32:20 +01:00
Kacpherek 0e658e3e62 Make drowned throw real tridents instead of arrows 2026-10-26 20:31:21 +01:00
Kacpherek 041ecb7b7a Structures: load the template's block entity NBT into every container and sign 2026-10-26 20:31:16 +01:00
Kacpherek 41ca489d4e Move /particle count 0 particles at speed times delta 2026-10-26 20:31:14 +01:00
Kacpherek 82d2b61a4a Swing a horse's tail now and then 2026-10-26 20:30:33 +01:00
Kacpherek f9b8b40a7d Show score components in /tellraw and /title 2026-10-26 20:30:13 +01:00
Kacpherek 08cd05ed6e Make the Entity Shadows option turn shadows off 2026-10-26 20:30:05 +01:00
Kacpherek f312aae7db Name the potion, cloud or thrower in magic death messages 2026-10-26 20:29:22 +01:00
Kacpherek 591b7b74da Predict the sort and tweak clicks on a server like an ordinary click 2026-10-26 20:15:10 +01:00
Kacpherek d78238f765 Show the enchanted hit when a melee weapon's enchantments add damage
Player.attack shows magic crit particles whenever the enchantments added
damage. Horizon showed only the critical hit, for the window's player and
for a guest's attack.
2026-10-26 20:14:32 +01:00
Kacpherek 303281ed09 Puff a poof where a dead mob's body goes
A mob removes its body twenty ticks after dying and tells everyone with
entity event 60, which draws twenty poofs about it. Horizon removed dead
mobs silently.
2026-10-26 20:12:28 +01:00
Kacpherek 18f8a6868b Burst a breaking potion into item crumbs and coloured spell particles
A thrown potion or experience bottle that breaks sends level events 2002 or
2007 with its colour and the splash sound (1053, 1054). Horizon spawned only
eight terrain crumbs, on the server's own side, so a server player saw
nothing.
2026-10-26 20:09:23 +01:00
Kacpherek 43d1f4b801 Draw the swirls of potion effects around players and mobs
A client draws the particles of an entity's visible effects itself, from
the synched effect particle list (LivingEntity.tickEffects). Horizon drew
none and sent no such list: now the window's own player, a server's mobs
and players, and the mobs and guests of a game in the frame all swirl.
2026-10-26 20:06:26 +01:00
Kacpherek a08f3eb2fa Select a bundle's item with the mouse wheel 2026-10-26 20:02:36 +01:00
Kacpherek 96b538d48f Throw up landing dust where a hard fall lands
A landing that hurts sends the block's crumbs from the server
(LivingEntity.checkFallDamage). Horizon built the event but dropped it, for
the window's player and for every server player.
2026-10-26 19:59:59 +01:00
Kacpherek 29eae3a1af Throw a burst of spit particles when a llama spits 2026-10-26 19:58:51 +01:00
Kacpherek 4efa49ae82 Raise sprint dust under running players
A sprinting player kicks up dust of the block under their feet every tick
(Entity.spawnSprintParticle). Horizon drew none, for the window's player
or for the other players it sees.
2026-10-26 19:58:22 +01:00
Kacpherek 85db9a23a9 Use the small explosion particle when an explosion keeps blocks 2026-10-26 19:57:54 +01:00
Kacpherek 00cb813dc8 Show the missing item tooltip lines in vanilla order
Custom names are italic. Unbreakable, dyed, written book author and
generation, music disc songs, goat horn instruments, loaded crossbow
projectiles, hive bees and honey, the potion "When Applied" block and
enchantment attribute modifiers (Efficiency V gives +26 Mining Efficiency)
now appear, and advanced tooltips (F3+H) end with the item id and the
component count. Attribute lines use the attribute's sentiment colour.
2026-10-26 19:57:18 +01:00
Kacpherek 9e848670db Send swimming, gliding, fire, invisible and glowing for LAN players 2026-10-26 19:57:11 +01:00
Kacpherek dbd24e2e16 A creaking heart blown up by an explosion makes its creaking die 2026-10-26 19:57:02 +01:00
Kacpherek 5a70691d63 Show the note particle above a note block when it plays 2026-10-26 19:56:39 +01:00
Kacpherek e7e6e5bc07 Scale entity tracking range by entity-broadcast-range-percentage
The property was parsed and ignored. The tracking range of each entity type is now scaled by it on a dedicated server, like DedicatedServer.getScaledTrackingDistance.
2026-10-26 19:56:22 +01:00
Kacpherek 4ebfaef0d3 Slide slowly down the sides of honey blocks 2026-10-26 19:56:07 +01:00
Kacpherek 7112d0cd64 Mobs standing in a wither rose get wither, bees in an open eyeblossom get poison 2026-10-26 19:55:27 +01:00
Kacpherek 818b377c02 Add honey drips, poplar and azalea leaves, falling dust and campfire food smoke 2026-10-26 19:54:32 +01:00
118 changed files with 7536 additions and 545 deletions

No files matched your search

+2 -1
View File
@@ -10,7 +10,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`, and, for the
launcher, `HORIZON_LAUNCHER_TRASH` and `HORIZON_LAUNCHER_CLIPBOARD` pointing into the scratch home. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
@@ -163,6 +163,8 @@ pub fn main() -> Result<()> {
ArrowSound::HitGround => "hit",
ArrowSound::Splash => "splash",
ArrowSound::Extinguish => "extinguish",
ArrowSound::TridentHitGround => "trident_hit_ground",
ArrowSound::TridentHit => "trident_hit",
},
volume.to_bits(),
pitch.to_bits()
+137 -11
View File
@@ -35,8 +35,9 @@ enum Animate {
Campfire {
spawn_particles: bool,
},
/// Leaves: optional falling-leaf particle and chance, tinted by foliage.
Leaves(Option<(T, f32)>),
/// Leaves: optional falling-leaf particle and chance; a tinted leaf takes
/// the biome foliage colour unless a fixed colour is given (azalea).
Leaves(Option<(T, f32)>, Option<u32>),
NetherPortal,
EnchantingTable,
EndRod([i32; 3]),
@@ -49,6 +50,8 @@ enum Animate {
EnderChest,
/// FallingBlock with its dust colour.
Falling(u32),
/// A beehive or bee nest full of honey (`BeehiveBlock.animateTick`).
Beehive,
RedstoneOre,
RedstoneWire {
power: u8,
@@ -104,10 +107,17 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
spawn_particles: false,
},
"oak_leaves" | "birch_leaves" | "jungle_leaves" | "acacia_leaves" | "dark_oak_leaves"
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01))),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1))),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02))),
"spruce_leaves" | "azalea_leaves" | "flowering_azalea_leaves" => Animate::Leaves(None),
| "mangrove_leaves" => Animate::Leaves(Some((T::TintedLeaves, 0.01)), None),
"cherry_leaves" => Animate::Leaves(Some((T::CherryLeaves, 0.1)), None),
"pale_oak_leaves" => Animate::Leaves(Some((T::PaleOakLeaves, 0.02)), None),
"red_poplar_leaves" => Animate::Leaves(Some((T::RedPoplarLeaves, 0.01)), None),
"orange_poplar_leaves" => Animate::Leaves(Some((T::OrangePoplarLeaves, 0.01)), None),
"yellow_poplar_leaves" => Animate::Leaves(Some((T::YellowPoplarLeaves, 0.01)), None),
// The azalea blocks drop tinted leaves of the fixed colour -9399763.
"azalea_leaves" | "flowering_azalea_leaves" => {
Animate::Leaves(Some((T::TintedLeaves, 0.01)), Some(0x0070_922D))
}
"spruce_leaves" => Animate::Leaves(None, None),
"nether_portal" => Animate::NetherPortal,
"enchanting_table" => Animate::EnchantingTable,
"end_rod" => Animate::EndRod(facing()),
@@ -119,6 +129,14 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"sand" => Animate::Falling(0xDBD3A0),
"red_sand" => Animate::Falling(0xA95821),
"gravel" => Animate::Falling(0x807C7B),
// AnvilBlock/ConcretePowderBlock.getDustColor is the map colour;
// DragonEggBlock's is black.
"anvil" | "chipped_anvil" | "damaged_anvil" => Animate::Falling(0xA7A7A7),
"dragon_egg" => Animate::Falling(0x000000),
_ if name.ends_with("_concrete_powder") => Animate::Falling(concrete_powder_color(
name.trim_end_matches("_concrete_powder"),
)),
"beehive" | "bee_nest" if prop("honey_level").as_deref() == Some("5") => Animate::Beehive,
"redstone_ore" | "deepslate_redstone_ore" if lit => Animate::RedstoneOre,
"redstone_wire" => {
let power = prop("power").and_then(|p| p.parse().ok()).unwrap_or(0);
@@ -161,6 +179,27 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
_ => Animate::None,
}
}
/// `DyeColor.getMapColor` of a concrete powder colour.
fn concrete_powder_color(dye: &str) -> u32 {
match dye {
"white" => 0xFFFFFF,
"orange" => 0xD87F33,
"magenta" => 0xB24CD8,
"light_blue" => 0x6699D8,
"yellow" => 0xE5E533,
"lime" => 0x7FCC19,
"pink" => 0xF27FA5,
"gray" => 0x4C4C4C,
"light_gray" => 0x999999,
"cyan" => 0x4C7F99,
"purple" => 0x7F3FB2,
"blue" => 0x334CB2,
"brown" => 0x664C33,
"green" => 0x667F33,
"red" => 0x993333,
_ => 0x191919,
}
}
/// `RedstoneWireBlock.COLORS`.
fn redstone_color(power: u8) -> [f32; 3] {
let f = f32::from(power) / 15.0;
@@ -504,6 +543,53 @@ fn try_spawn_drip(
}
}
}
/// `BeehiveBlock.trySpawnDripParticles`.
fn beehive_drip(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
state: BlockStateId,
pos: [i32; 3],
) {
if ctx.facts.get(state).fluid != Fluid::None || engine.random().next_float() < 0.3 {
return;
}
let Some((bottom, top, bounds)) = ctx.shape_y(pos) else {
return;
};
if top < 1.0
|| tables
.impermeable
.get(state.0 as usize)
.copied()
.unwrap_or(false)
{
return;
}
let y = if bottom > 0.0 {
f64::from(pos[1]) + bottom - 0.05
} else {
let below = [pos[0], pos[1] - 1, pos[2]];
let below_top = ctx.shape_y(below).map_or(1.0, |s| s.1);
// `belowTop < 1 || !isCollisionShapeFullBlock` and no fluid below.
if (below_top < 1.0 || !ctx.sturdy(below))
&& ctx.facts.get(ctx.state(below)).fluid == Fluid::None
{
f64::from(pos[1]) - 0.05
} else {
return;
}
};
let r = engine.random();
let (a, b) = (r.next_double(), r.next_double());
let (fx, fz) = (f64::from(pos[0]), f64::from(pos[2]));
let p = [
fx + bounds[0] + (bounds[1] - bounds[0]) * a,
y,
fz + bounds[2] + (bounds[3] - bounds[2]) * b,
];
add(engine, T::DrippingHoney, p, [0.0; 3]);
}
/// `WaterFluid.animateTick` / `LavaFluid.animateTick`.
fn fluid_animate_tick(
engine: &mut ParticleEngine,
@@ -685,7 +771,7 @@ fn block_animate_tick(
}
}
}
Animate::Leaves(falling) => {
Animate::Leaves(falling, fixed_tint) => {
let below = [pos[0], pos[1] - 1, pos[2]];
// LeavesBlock.makeDrippingWaterParticles.
if (ctx.raining_at)([pos[0], pos[1] + 1, pos[2]]) && next_int(engine.random(), 15) == 1
@@ -705,7 +791,7 @@ fn block_animate_tick(
let r = engine.random();
let p = [fx + r.next_double(), fy - 0.05, fz + r.next_double()];
let options = if ty == T::TintedLeaves {
let c = ctx.foliage(pos);
let c = fixed_tint.unwrap_or_else(|| ctx.foliage(pos));
let f = |s: u32| ((c >> s) & 255) as f32 / 255.0;
ParticleOptions::colored(ty, [f(16), f(8), f(0), 1.0])
} else {
@@ -977,6 +1063,13 @@ fn block_animate_tick(
}
}
}
Animate::Beehive => {
// BeehiveBlock.animateTick: nextInt(1) + 1 attempts.
let attempts = next_int(engine.random(), 1) + 1;
for _ in 0..attempts {
beehive_drip(tables, engine, ctx, state, pos);
}
}
Animate::RedstoneOre => {
for d in [
[0, -1, 0],
@@ -1209,21 +1302,32 @@ fn candle(engine: &mut ParticleEngine, p: [f64; 3]) {
}
add(engine, T::SmallFlame, p, [0.0; 3]);
}
/// `CampfireBlockEntity.particleTick` for every lit campfire (no cooking items).
/// `CampfireBlockEntity.particleTick` for every lit campfire; `cooking` says
/// which of a campfire's four slots hold an item.
pub fn campfire_particles<'a>(
tables: &AnimateTables,
engine: &mut ParticleEngine,
ctx: &AnimateContext,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
cooking: &dyn Fn([i32; 3]) -> [bool; 4],
) {
for &(pos, state) in blocks {
if !tables.is_lit_campfire(state) {
continue;
}
let signal = ctx
let properties = ctx
.registry
.state(BlockStateId(state))
.is_some_and(|s| s.properties.get("signal_fire").map(String::as_str) == Some("true"));
.map(|s| &s.properties);
let prop = |k: &str| properties.and_then(|p| p.get(k)).map(String::as_str);
let signal = prop("signal_fire") == Some("true");
// Direction.get2DDataValue: south, west, north, east.
let rotation = match prop("facing") {
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let r = engine.random();
if r.next_float() < 0.11 {
let count = next_int(r, 2) + 2;
@@ -1231,6 +1335,28 @@ pub fn campfire_particles<'a>(
make_campfire_smoke(engine, pos, signal, false);
}
}
// The smoke over each cooking item, at the slot's corner of the fire.
let occupied = cooking(pos);
for (slot, _) in occupied.iter().enumerate().filter(|(_, held)| **held) {
if engine.random().next_float() >= 0.2 {
continue;
}
// Direction.from2DDataValue((slot + rotation) % 4) and its clockwise side.
let (step, clockwise) = match (slot + rotation) % 4 {
0 => ([0.0, 1.0], [-1.0, 0.0]),
1 => ([-1.0, 0.0], [0.0, -1.0]),
2 => ([0.0, -1.0], [1.0, 0.0]),
_ => ([1.0, 0.0], [0.0, 1.0]),
};
let at = [
f64::from(pos[0]) + 0.5 - step[0] * 0.3125 + clockwise[0] * 0.3125,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5 - step[1] * 0.3125 + clockwise[1] * 0.3125,
];
for _ in 0..4 {
add(engine, T::Smoke, at, [0.0, 5.0e-4, 0.0]);
}
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
+47 -4
View File
@@ -9,7 +9,7 @@ use crate::{
collisions::SceneCollisions,
mobs::Who,
motion::{FluidResources, LAVA, WATER},
projectiles::{Hit, Projectile},
projectiles::{Hit, Projectile, ProjectileKind},
world::World,
};
use horizon_core::{
@@ -132,6 +132,10 @@ pub enum ArrowSound {
HitGround,
/// `entity.generic.extinguish_fire`.
Extinguish,
/// A thrown trident's `getDefaultHitGroundSoundEvent` (`item.trident.hit_ground`).
TridentHitGround,
/// `ThrownTrident.onHitEntity`'s `item.trident.hit`.
TridentHit,
}
/// What a burning arrow does to the block it hits (`onProjectileHit`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -169,6 +173,9 @@ pub struct ArrowState {
pub was_eye_in_water: bool,
pub first_tick: bool,
pub has_been_shot: bool,
/// `ThrownTrident.dealtDamage`: it has hit an entity (or sat in a block
/// for over 4 ticks) and hits no more.
pub dealt_damage: bool,
pub removed: bool,
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
@@ -211,6 +218,7 @@ impl ArrowState {
was_eye_in_water: false,
first_tick: true,
has_been_shot: false,
dealt_damage: false,
removed: false,
random,
events: Vec::new(),
@@ -451,12 +459,14 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
let Some(mut st) = p.arrow.take() else {
return false;
};
let trident = matches!(p.kind, ProjectileKind::Trident);
let mut ticker = Ticker {
p,
st: &mut st,
scene,
};
match outcome {
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
EntityHit::Hurt => {
ticker.hit_sound();
// A piercing arrow flies on (`getPierceLevel() <= 0` discards).
@@ -501,7 +511,16 @@ impl Ticker<'_, '_, '_, '_> {
p.z,
))
}
/// Whether this is a `ThrownTrident` (`AbstractArrow` with its own water
/// inertia, sounds and hit).
fn trident(&self) -> bool {
matches!(self.p.kind, ProjectileKind::Trident)
}
fn tick(&mut self, targets: &[(Who, Aabb)]) -> Hit {
// ThrownTrident.tick.
if self.trident() && self.st.in_ground_time > 4 {
self.st.dealt_damage = true;
}
let physics = !self.st.no_physics;
let movement = self.p.velocity;
let block_pos = self.block_position();
@@ -549,8 +568,9 @@ impl Ticker<'_, '_, '_, '_> {
self.st.in_ground_time = 0;
let original = self.position();
if self.st.was_touching_water {
// getWaterInertia, then the bubbles behind it.
self.p.velocity = self.p.velocity.scale(f64::from(0.6f32));
// getWaterInertia (0.99 for a trident), then the bubbles behind it.
let inertia = if self.trident() { 0.99f32 } else { 0.6f32 };
self.p.velocity = self.p.velocity.scale(f64::from(inertia));
let v = self.p.velocity;
for _ in 0..4 {
self.st.events.push(ArrowEvent::Particle {
@@ -687,6 +707,8 @@ impl Ticker<'_, '_, '_, '_> {
.expand_towards(self.p.velocity)
.inflate(Vec3::new(1.0, 1.0, 1.0));
let mut best: Option<(Who, Vec3, f64)> = None;
// ThrownTrident.findHitEntity: none once it has dealt damage.
let targets = if self.st.dealt_damage { &[] } else { targets };
for (who, b) in targets {
if Some(*who) == self.p.owner && !self.p.left_owner || !b.intersects(area) {
continue;
@@ -757,12 +779,33 @@ impl Ticker<'_, '_, '_, '_> {
/// `playSound(soundEvent, 1, 1.2 / (random * 0.2 + 0.9))`.
fn hit_sound(&mut self) {
let pitch = 1.2 / (self.st.random.next_float() * 0.2 + 0.9);
let sound = if self.trident() {
ArrowSound::TridentHitGround
} else {
ArrowSound::HitGround
};
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::HitGround,
sound,
volume: 1.0,
pitch,
});
}
/// `ThrownTrident.onHitEntity` after the hurt, whether it landed or not:
/// the hit sound, then `deflect(REVERSE, ..., (0.02, 0.2, 0.02))`. The
/// trident keeps flying (it has dealt its damage) and falls.
fn trident_bounce(&mut self) {
self.st.dealt_damage = true;
self.st.events.push(ArrowEvent::Sound {
sound: ArrowSound::TridentHit,
volume: 1.0,
pitch: 1.0,
});
// ProjectileDeflection.REVERSE.
let rotation = 170.0 + self.st.random.next_float() * 20.0;
let v = self.p.velocity;
self.p.velocity = Vec3::new(v.x * (-0.02 * 0.5), v.y * (-0.2 * 0.5), v.z * (-0.02 * 0.5));
self.p.yaw += rotation;
}
/// `onHitEntity` when the entity took no damage: `deflect(REVERSE, 0.2)`,
/// a half turn give or take ten degrees at a tenth of the speed; one
/// left almost still drops its pickup (ALLOWED) and is discarded.
+1 -1
View File
@@ -90,7 +90,7 @@ pub const MAP_COLORS: [u32; 62] = [
14200723, 8365974,
];
/// The 12 edges of the unit cube (outline data predating the export).
fn cube_edges() -> Vec<[f64; 6]> {
pub fn cube_edges() -> Vec<[f64; 6]> {
let mut edges = Vec::new();
for a in 0..3 {
for (u, v) in [(0., 0.), (1., 0.), (0., 1.), (1., 1.)] {
+48 -8
View File
@@ -1,6 +1,7 @@
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
//! overrides on it.
use crate::item_data::BundleSelected;
use crate::items::{ItemStack, Items};
use horizon_world::nbt::Tag;
@@ -97,6 +98,8 @@ pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
pub struct Contents {
items: Vec<ItemStack>,
weight: Weight,
/// `selectedItem`: an index into `items`, -1 for none.
selected: i32,
}
impl Contents {
@@ -104,9 +107,11 @@ impl Contents {
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
let inside = contents_of(items, stack)?.to_vec();
let weight = contents_weight(items, &inside);
let selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
Some(Self {
items: inside,
weight,
selected,
})
}
@@ -154,18 +159,26 @@ impl Contents {
amount
}
/// `removeOne`: the first stack (Horizon keeps no selected item).
/// `removeOne`: the selected stack, else the first; nothing is selected after.
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
if self.items.is_empty() {
return None;
}
let stack = self.items.remove(0);
let at =
usize::try_from(self.selected).map_or(0, |i| if i < self.items.len() { i } else { 0 });
let stack = self.items.remove(at);
self.weight = self
.weight
.sub(item_weight(items, &stack).times(stack.count));
self.selected = -1;
Some(stack)
}
/// `getSelectedItemIndex`.
pub fn selected(&self) -> i32 {
self.selected
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
@@ -200,11 +213,28 @@ impl Contents {
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
let data = bundle.data_mut();
data.bundle_contents = self.items;
data.bundle_selected = BundleSelected(self.selected);
bundle.tidy();
}
}
/// `BundleItem.toggleSelectedItem`: select `index`, or nothing when it is the
/// selected one already or outside the contents.
pub fn toggle_selected(bundle: &mut ItemStack, index: i32) {
let Some(data) = bundle.extra.as_deref_mut() else {
return;
};
let inside = (0..data.bundle_contents.len() as i32).contains(&index);
let selected = if data.bundle_selected.0 != index && inside {
index
} else {
-1
};
data.bundle_selected = BundleSelected(selected);
}
#[cfg(test)]
mod tests {
use super::Weight;
@@ -232,8 +262,10 @@ pub struct TooltipLayout {
/// The component's own width (96) and height, the text's excluded.
pub width: f32,
pub height: f32,
/// Each slot's top-left and the stack in it, in reading order.
/// Each slot's top-left and the stack in it, in drawing order.
pub slots: Vec<([f32; 2], ItemStack)>,
/// The position in `slots` of the selected item, which is drawn highlighted.
pub selected: Option<usize>,
/// The `+N` of the items past the grid: its top-left and the count.
pub overflow: Option<([f32; 2], u32)>,
/// The progress bar's top-left, its fill's width out of 94, and whether
@@ -266,6 +298,7 @@ pub fn tooltip_layout(
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
selected: None,
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
@@ -282,6 +315,7 @@ pub fn tooltip_layout(
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut selected = None;
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
@@ -290,8 +324,13 @@ pub fn tooltip_layout(
let at = [x_start - column as f32 * 24.0, y_start - row as f32 * 24.0];
if hidden > 0 && row == 1 && column == 1 {
overflow = Some((at, hidden));
} else if let Some(stack) = shown.get(slot) {
slots.push((at, stack.clone()));
} else if slot < shown.len() {
// `extractSlot`: the cell of slot number n holds item `size - n`.
let index = shown.len() - 1 - slot;
if usize::try_from(contents.selected()) == Ok(index) {
selected = Some(slots.len());
}
slots.push((at, shown[index].clone()));
slot += 1;
}
}
@@ -301,6 +340,7 @@ pub fn tooltip_layout(
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
selected,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
@@ -349,8 +389,8 @@ mod tooltip_tests {
assert_eq!(layout.slots.len(), 12);
assert_eq!(layout.overflow, None);
assert_eq!(layout.height, 3.0 * 24.0 + 13.0 + 8.0);
// Rows fill bottom-up and columns right-to-left: the first item is in
// the bottom-right cell, the twelfth in the top-left.
// Rows fill bottom-up and columns right-to-left: the twelfth item is in
// the bottom-right cell, the first in the top-left.
assert_eq!(layout.slots[0].0, [96.0 - 24.0, 3.0 * 24.0 - 24.0]);
assert_eq!(&*layout.slots[0].1.item, "minecraft:stone");
assert_eq!(layout.slots[11].0, [0.0, 0.0]);
+26
View File
@@ -545,6 +545,32 @@ impl Enchantments {
}
out
}
/// `EnchantmentHelper.forEachModifier` as an item's tooltip lists them: the
/// `minecraft:attributes` effects of `stack`'s enchantments as rows of
/// (attribute, id, amount, operation, slot group), one per slot group the
/// enchantment lists.
pub fn modifier_rows(&self, stack: &ItemStack) -> Vec<(String, String, f64, String, String)> {
let mut rows = Vec::new();
for (id, level) in stack.enchantments() {
let Some(e) = self.get(id) else { continue };
for a in e.effects["minecraft:attributes"]
.as_array()
.into_iter()
.flatten()
{
for slot in &e.slots {
rows.push((
a["attribute"].as_str().unwrap_or("").to_owned(),
a["id"].as_str().unwrap_or("").to_owned(),
f64::from(level_value(&a["amount"], *level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
slot.clone(),
));
}
}
}
rows
}
/// Sum of `add_value` amounts of one attribute from enchantments on `stack`.
pub fn attribute_add(&self, stack: Option<&ItemStack>, attribute: &str) -> f64 {
stack.map_or(0.0, |s| {
+202 -12
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod bundles;
mod campfire;
mod campfire_douse;
mod cartography;
@@ -1275,6 +1276,8 @@ struct Game<'a> {
inv_clicks: horizon_client::container_screen::Clicks,
/// The Mouse Tweaks extra's drag and wheel (`tweaks`).
tweaks: tweaks::Tweaks,
/// The wheel and hovered slot of `BundleMouseActions`.
bundle_mouse: bundles::BundleMouse,
/// The last inventory click, for the double click that gathers a stack.
last_inv_click: Option<(std::time::Instant, usize, bool)>,
/// `lastQuickMoved`: what the last Shift click moved, which a Shift
@@ -1442,6 +1445,10 @@ struct Game<'a> {
titles: titles::Titles,
/// `Hud.toolHighlightTimer`: the selected item name.
tool_highlight: horizon_client::hud_overlays::ToolHighlight,
/// `Hud.autosaveIndicatorValue`.
saving_indicator: horizon_client::hud_overlays::SavingIndicator,
/// This game's world is autosaving this tick (`MinecraftServer.isSaving`).
autosaving: bool,
applied_modifiers: Vec<(String, String)>,
experience: Experience,
orbs: Vec<ExperienceOrb>,
@@ -1673,7 +1680,7 @@ struct Game<'a> {
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
/// Successful crits (attacking guest, hit entity), queued while the hub is taken.
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Global level events for every guest (`ServerLevel.globalLevelEvent`).
lan_global_events: Vec<(i32, [i32; 3], i32)>,
/// `ContainerOpenersCounter`: how many players (the host and the guests)
@@ -1821,9 +1828,46 @@ impl Game<'_> {
fn reach(&self) -> f64 {
if self.creative { 5.0 } else { 4.5 }
}
/// Outline shapes that depend on more than the block state, as one box
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
/// block while the main hand holds the block's own item
/// (`CollisionContext.isHoldingItem`), and `ShulkerBoxBlock.getShape`
/// grows with the lid (`ShulkerBoxBlockEntity.getBoundingBox`).
fn context_outline(&self, pos: [i32; 3], state: BlockStateId) -> Option<[f64; 6]> {
let name = &self.res.facts().get(state).name;
if matches!(&**name, "minecraft:light" | "minecraft:scaffolding") {
return self
.held()
.is_some_and(|s| *s.item == **name)
.then_some([0.0, 0.0, 0.0, 1.0, 1.0, 1.0]);
}
if !name.ends_with("shulker_box") {
return None;
}
let progress = self.be_anims.shulkers.get(&pos).map_or(0.0, |s| s.progress);
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
let d = facing_offset(facing.as_deref().unwrap_or("up"));
let mut b = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
for a in 0..3 {
match d[a] {
1 => b[a + 3] += 0.5 * f64::from(progress),
-1 => b[a] -= 0.5 * f64::from(progress),
_ => {}
}
}
Some(b)
}
/// A block's outline boxes in world space (`getShape` plus the random offset).
fn outline_boxes(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if let Some(b) = self.context_outline(pos, state) {
return vec![std::array::from_fn(|i| f64::from(pos[i % 3]) + b[i])];
}
let f = self.res.facts().get(state);
let o = if f.outline_offset {
self.pack
@@ -2118,6 +2162,33 @@ impl Game<'_> {
}
/// `TerrainParticle` at `pos`: the model's particle sprite and tint source
/// 0's `colorAsTerrainParticle` (white when the block has none).
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
/// particles at the entity.
fn honey_slide_particles(&mut self, at: [f64; 3]) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let Some(honey) = self
.pack
.assets
.registry
.default_state("minecraft:honey_block")
else {
return;
};
let block = [
at[0].floor() as i32,
at[1].floor() as i32,
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
for _ in 0..5 {
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
at,
[0.0; 3],
);
}
}
}
fn particle_sprite(&self, state: BlockStateId, pos: [i32; 3]) -> Option<(String, u32)> {
let sprite = Self::block_particle_sprite(&self.pack, state)?;
let chunks = &self.world.chunks;
@@ -2335,11 +2406,18 @@ impl Game<'_> {
yaw.cos() * pitch.cos() * 0.3 + spread.sin() * strength,
];
self.spawn_item(thrown, [eye[0], eye[1] - 0.3, eye[2]], v, 40);
if thrown_from_hand && self.lan_acting.is_none() {
let uuid = self.level.ensure_player_uuid();
if thrown_from_hand {
let uuid = self.acting_player_uuid();
self.dropped.last_mut().unwrap().thrower = Some(uuid);
}
}
/// The UUID of the player acting: the guest whose turn it is, else the host.
pub(super) fn acting_player_uuid(&mut self) -> [i32; 4] {
match self.lan_acting_uuid {
Some(guest) => horizon_client::lan::playerdata::uuid_ints(guest),
None => self.level.ensure_player_uuid(),
}
}
fn tick_items(&mut self) {
let mut items = std::mem::take(&mut self.dropped);
// ServerLevel.tick: only entities in entity-ticking chunks tick.
@@ -2944,6 +3022,17 @@ impl Game<'_> {
pos,
data,
} => self.smash_dust(pos, data),
// LevelEventHandler 2002 and 2007: a potion's splash.
LevelEvent::LevelEvent {
id: 2002,
pos,
data,
} => self.potion_splash_particles(pos, data, false),
LevelEvent::LevelEvent {
id: 2007,
pos,
data,
} => self.potion_splash_particles(pos, data, true),
LevelEvent::LevelEvent {
id: id @ 3002..=3005,
pos,
@@ -2979,6 +3068,8 @@ impl Game<'_> {
1044 => Some(("minecraft:block.smithing_table.use", 1.0)),
// LevelEvent.SOUND_PAGE_TURN (a lectern's page).
1043 => Some(("minecraft:item.book.page_turn", 1.0)),
// LevelEvent.SOUND_SPLASH_POTION and its instant twin.
1053 | 1054 => Some(("minecraft:entity.splash_potion.break", 1.0)),
_ => None,
};
if let Some((name, volume)) = sound {
@@ -2988,7 +3079,7 @@ impl Game<'_> {
- self.world.random.next_float())
* 0.8
}
1029 | 1030 | 1042 | 1043 | 1044 => {
1029 | 1030 | 1042 | 1043 | 1044 | 1053 | 1054 => {
self.world.random.next_float() * 0.1 + 0.9
}
1019 | 1021 => {
@@ -2998,7 +3089,7 @@ impl Game<'_> {
}
_ => 1.0,
};
if matches!(id, 1019 | 1021 | 1029 | 1030) {
if matches!(id, 1019 | 1021 | 1029 | 1030 | 1053 | 1054) {
// Clients play the anvil sound from its level event;
// capturing it also sends a duplicate sound packet.
self.audio.play_unshared(name, c, volume, pitch);
@@ -3152,6 +3243,31 @@ impl Game<'_> {
LevelEvent::BlockEntityEvent { pos, a, b, .. } => {
self.block_entity_event([pos.x, pos.y, pos.z], a, b)
}
// NoteBlock.triggerEvent: a NOTE particle above, coloured by
// the note (`note / 24.0` in the x speed).
LevelEvent::BlockEvent {
pos,
state,
kind: "note_particle",
} => {
use horizon_client::particles::{ParticleOptions, ParticleType};
let note = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("note")?.parse::<f64>().ok())
.unwrap_or(0.0);
self.particles.add(
&ParticleOptions::simple(ParticleType::Note),
[
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 1.2,
f64::from(pos.z) + 0.5,
],
[note / 24.0, 0.0, 0.0],
);
}
LevelEvent::GrowTree { .. }
| LevelEvent::PlaceFeature { .. }
| LevelEvent::BlockEvent { .. } => {}
@@ -3527,7 +3643,7 @@ impl Game<'_> {
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
self.pending.record_saved(pos, &tag);
}
self.creaking_heart_broken(pos);
self.creaking_heart_broken(pos, true);
let hive = match self.world.block_entities.get(&key) {
Some(BlockEntity::Beehive(h)) => self.hive_state(pos).map(|s| ((**h).clone(), s)),
_ => None,
@@ -4319,6 +4435,12 @@ impl Game<'_> {
// The world's spawn from the start, not only once saved: a bed set
// before the first save must not become it.
self.level.world_spawn.get_or_insert([x, y, z]);
// The "Bonus Chest" create-world option, placed once with the spawn.
if horizon_client::save::WorldGen::read(&self.world_root)
.is_some_and(|g| g.bonus_chest)
{
self.place_bonus_chest([x, y, z])?;
}
}
let s = self
.level
@@ -4422,7 +4544,11 @@ impl Game<'_> {
self.world.weather.tick_levels();
self.tick_weather_effects();
}
if self.ticks.is_multiple_of(6000) {
self.autosaving = self.ticks.is_multiple_of(6000);
if let Some(serving) = &self.serving {
serving.set_saving(self.autosaving);
}
if self.autosaving {
self.save_game_with(false)?;
}
if running {
@@ -4633,9 +4759,32 @@ impl Game<'_> {
};
self.audio.play(name, Some(feet), volume, pitch);
}
// LivingEntity.checkFallDamage: the block's crumbs where the fall landed.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
position,
count,
speed,
}) => self.landing_dust(None, state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity),
MotionEvent::EnterBlock { state } => self.trigger_enter_block(state),
MotionEvent::HoneySlide {
sound, particles, ..
} => {
if sound {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
if particles {
self.honey_slide_particles(feet);
// The server's event 53 reaches the guests too.
let id = self.lan.as_ref().map(|h| h.host_entity);
if let Some(id) = id {
self.lan_entity_event(id, 53, feet);
}
}
}
MotionEvent::InsidePortal { pos } => self.player_inside_nether_portal(pos),
MotionEvent::CauldronExtinguish { pos } => self.cauldron_extinguish(pos),
MotionEvent::InsideWitherRose => self.wither_rose_contact(),
@@ -4746,6 +4895,7 @@ impl Game<'_> {
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
self.cooldowns.tick();
horizon_client::spans::time("tick_using", || self.tick_using());
self.tick_bundle_hover();
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
// ticks; ServerLevel.tick then runs the scheduled and random block
@@ -5443,6 +5593,7 @@ impl Game<'_> {
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_saving_indicator(&mut batch);
self.draw_toasts(&mut batch);
self.draw_pack_toasts(&mut batch);
self.remote_hud(&mut batch);
@@ -5943,6 +6094,12 @@ impl Game<'_> {
/// Several clicks on one menu instance (a quick craft drag is three).
fn menu_ops(&mut self, ops: &[(Option<usize>, i32, horizon_client::menu::Input)]) -> bool {
use horizon_client::menu::Input;
// BundleMouseActions.onSlotClicked: a Shift click or a swap clears the bundle's selection.
for &(slot, _, input) in ops {
if let (Some(slot), Input::QuickMove | Input::Swap) = (slot, input) {
self.unselect_bundle(slot);
}
}
if self.remote.is_some() && self.screen == Screen::Inventory {
// On a server the clicks are the server's: predicted and sent.
return self.remote_menu_ops(ops);
@@ -7268,7 +7425,12 @@ impl Game<'_> {
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
if let Some(sprite) = self.gui.atlas.sprites.get("misc/shadow").copied() {
let shadow_sprite = self
.settings
.entity_shadows
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
let ambient = if self.dim == Dim::Nether { 0.1 } else { 0.0 };
let darken = self.sky_darken();
let a = alpha;
@@ -7667,7 +7829,7 @@ impl Game<'_> {
/// strength and whether it is a critical hit): the weapon and effects
/// scaled by the attack strength, a mace's smash, the critical hit, then
/// enchantments.
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool) {
pub(super) fn melee_damage(&mut self, target_kind: &str) -> (f32, f32, bool, bool) {
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
// Riptide's spin replaces the attack damage attribute.
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
@@ -7695,8 +7857,9 @@ impl Game<'_> {
amount *= 1.5;
}
// Enchantment damage (sharpness, smite, bane) scaled by the attack strength.
amount += self.enchant_damage_bonus(target_kind) * scale;
(amount, scale, crit)
let magic_boost = self.enchant_damage_bonus(target_kind) * scale;
amount += magic_boost;
(amount, scale, crit, magic_boost > 0.0)
}
/// `Player.getAttackStrengthScale(0.5)`.
fn attack_strength_scale(&self) -> f32 {
@@ -7883,7 +8046,7 @@ impl Game<'_> {
pub(super) fn attack_mob(&mut self, index: usize) {
let target_kind = self.mobs[index].kind.id.clone();
let target_kind = format!("minecraft:{}", target_kind.trim_start_matches("minecraft:"));
let (amount, scale, crit) = self.melee_damage(&target_kind);
let (amount, scale, crit, magic) = self.melee_damage(&target_kind);
let sweep = self.sweep_attack_position(scale, crit);
let (knockback, knockback_attack) = self.extra_knockback(&target_kind, scale);
self.attack_ticks = 0;
@@ -7946,6 +8109,18 @@ impl Game<'_> {
feet,
width,
height,
false,
);
}
// Player.attackVisualEffects: magicCrit when the enchantments added damage.
if magic {
self.critical_hit(
self.lan_acting,
self.mobs[index].id as i32,
feet,
width,
height,
true,
);
}
if actual > 2.0 {
@@ -10798,6 +10973,17 @@ impl Game<'_> {
[0.0; 3]
};
let p = h.pos.map(f64::from);
if let Some(b) = self.context_outline(h.pos, state) {
return horizon_client::blocks::cube_edges()
.iter()
.map(|e| {
std::array::from_fn(|i| {
let k = i % 3;
p[k] + b[k] + e[i] * (b[k + 3] - b[k])
})
})
.collect();
}
f.outline_edges
.iter()
.map(|e| std::array::from_fn(|i| p[i % 3] + o[i % 3] + e[i]))
@@ -11858,6 +12044,7 @@ impl ApplicationHandler for Game<'_> {
}
if self.screen == Screen::Inventory
&& d != 0.0
&& !self.bundle_scroll(scaled(raw_x), scaled(raw_y))
&& !self.tweak_scroll(d, self.inventory_target().0)
{
self.station_scroll(d);
@@ -12647,6 +12834,7 @@ impl<'a> Game<'a> {
craft_table: None,
inv_drag: None,
tweaks: Default::default(),
bundle_mouse: Default::default(),
inv_clicks: Default::default(),
last_inv_click: None,
last_quick_moved: None,
@@ -12759,6 +12947,8 @@ impl<'a> Game<'a> {
command_source: None,
titles: Default::default(),
tool_highlight: Default::default(),
saving_indicator: Default::default(),
autosaving: false,
applied_modifiers: Vec::new(),
experience: Experience::default(),
orbs: Vec::new(),
+103
View File
@@ -0,0 +1,103 @@
//! `BundleMouseActions`: the wheel over a bundle in a container screen selects
//! one of the items it shows, which a click that takes one out of it then takes
//! (`BundleContents.Mutable.removeOne`). Moving off the bundle clears the
//! selection. On a server the selection is sent to it (`ServerboundSelectBundleItemPacket`).
use super::{Game, Screen};
use horizon_client::{
bundle::{Contents, toggle_selected},
mouse::ScrollWheelHandler,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
#[derive(Default)]
pub(super) struct BundleMouse {
wheel: ScrollWheelHandler,
/// The slot under the cursor at the last tick (`AbstractContainerScreen.hoveredSlot`).
hovered: Option<usize>,
}
impl Game<'_> {
/// A slot a bundle can be selected in: the player's inventory, the off hand and a
/// container's own slots. A server only knows these in the inventory and chest-like menus.
fn bundle_slot(&mut self, slot: usize) -> bool {
(slot < 36 || slot == 504 || (300..500).contains(&slot))
&& (self.remote.is_none() || self.remote_storage_open())
&& (slot < 36 || slot == 504 || self.slots().contains(&slot))
}
/// `BundleMouseActions.onMouseScrolled`: the wheel over a bundle moves the selection
/// through the items its tooltip shows. True when the bundle took the wheel.
pub(super) fn bundle_scroll(&mut self, x: f64, y: f64) -> bool {
let Some(slot) = self.inventory_target().0 else {
return false;
};
if !self.bundle_slot(slot) {
return false;
}
let stack = self.slot_mut(slot).clone();
let items = self.res.items();
let Some(bundle) = stack.filter(|s| items.get(&s.item).is_some_and(|i| i.bundle)) else {
return false;
};
let shown = Contents::of(items, &bundle).map_or(0, |c| c.shown_count());
if shown == 0 {
return false;
}
let (wheel_x, wheel_y) = self.bundle_mouse.wheel.on_mouse_scroll(x, y);
let wheel = if wheel_y == 0 { -wheel_x } else { wheel_y };
if wheel != 0 {
let selected = bundle.data().map_or(-1, |d| d.bundle_selected.0);
let next = ScrollWheelHandler::next_selection(f64::from(wheel), selected, shown as i32);
if next != selected {
self.select_bundle(slot, next);
}
}
true
}
/// `BundleMouseActions.toggleSelectedBundleItem`: the stack here and, on a server, there.
fn select_bundle(&mut self, slot: usize, index: i32) {
if let Some(stack) = self.slot_mut(slot) {
toggle_selected(stack, index);
}
if self.remote.is_some() {
let (_, menu) = self.remote_menu();
if let Some(menu_slot) = Self::menu_slot_index(&menu, slot) {
self.send(S::SelectBundleItem(play::SelectBundleItem {
slot: menu_slot as i32,
index,
}));
}
}
}
/// `BundleMouseActions.unselectedBundleItem` for the bundle in `slot`, if it has a selection.
pub(super) fn unselect_bundle(&mut self, slot: usize) {
if !self.bundle_slot(slot) {
return;
}
let selected = self
.slot_mut(slot)
.as_ref()
.and_then(|s| s.data())
.is_some_and(|d| d.bundle_selected.0 >= 0);
if selected {
self.select_bundle(slot, -1);
}
}
/// `AbstractContainerScreen.onStopHovering` (and `onClose`): leaving a slot, or the
/// screen, clears the bundle's selection.
pub(super) fn tick_bundle_hover(&mut self) {
let now = if self.screen == Screen::Inventory {
self.inventory_target().0
} else {
None
};
if let Some(left) = std::mem::replace(&mut self.bundle_mouse.hovered, now)
&& Some(left) != now
{
self.unselect_bundle(left);
}
}
}
@@ -4,8 +4,12 @@
//! on the game that has the window, in-frame or following a server, never on
//! a server: nothing of them goes over the network.
use super::Game;
use horizon_client::blocks::RenderShape;
use horizon_client::mobs::{MobEvent, ambient};
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_client::motion::{LAVA, WATER};
use horizon_client::particles::{ParticleEngine, ParticleOptions, ParticleType};
use horizon_client::pose::Pose;
use horizon_core::{geometry::Vec3, random::RandomSource};
impl Game<'_> {
pub(super) fn tick_client_ambient(&mut self) {
@@ -33,5 +37,209 @@ impl Game<'_> {
}
}
self.eye_particles();
self.sprint_dust();
self.effect_swirls();
}
/// `Entity.baseTick`'s `spawnSprintParticle` on a client: dust of the
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
&& !p.spectator
&& p.pose != Pose::Crouching
&& !p.health.dead_or_dying()
&& !p.fluids.was_touching_water
&& !p.is_in_lava()
&& p.fluid_height(WATER) <= 0.0
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
// Another player's fluids aren't known here: none where they stand.
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
&& &*m.kind.id == "minecraft:player"
&& self
.res
.facts()
.get(self.world.block(feet[0], feet[1], feet[2]))
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
}
}
for (position, movement, support) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
let name = game.block_name(s);
[
"minecraft:fences",
"minecraft:walls",
"minecraft:fence_gates",
]
.iter()
.any(|tag| game.block_in_tag(tag, &name))
};
let at = match support {
Some(s) if tall(self, s) => s,
Some(s) => [s[0], below, s[2]],
None => [position.x.floor() as i32, below, position.z.floor() as i32],
};
let state = self.world.block(at[0], at[1], at[2]);
if self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
}
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
// Clamped into the block when the feet are in another column.
if position.x.floor() as i32 != at[0] {
x = x.clamp(f64::from(at[0]), f64::from(at[0]) + 1.0);
}
if position.z.floor() as i32 != at[2] {
z = z.clamp(f64::from(at[2]), f64::from(at[2]) + 1.0);
}
self.particles.add(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
[x, position.y + 0.1, z],
[movement.x * -4.0, 1.5, movement.z * -4.0],
);
}
}
/// `LivingEntity.tickEffects` on a client: the swirls around an entity
/// with visible effects, from the particles its synched data lists. The
/// window's player has its own effects, a server's mobs and players the
/// list the server sent, and the mobs and guests of a game in the frame
/// theirs.
fn effect_swirls(&mut self) {
if let Some(p) = self.player.as_ref() {
let effects = &self.potions.effects.list;
let list: Vec<_> = effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
swirl(
&mut self.particles,
p.position,
size,
&list,
ambient,
invisible,
);
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
if !m.alive() {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
.iter()
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.filter_map(|e| effect_particle(e.0, false))
.collect();
swirl(
&mut self.particles,
m.position,
size,
&list,
false,
invisible,
);
} else {
let list = &m.extra.effect_particles;
let ambient = m.extra.effect_ambient;
swirl(
&mut self.particles,
m.position,
size,
list,
ambient,
invisible,
);
}
}
}
}
/// `MobEffect.particleFactory`: the omens have particles of their own, any
/// other effect an entity effect in its colour, faint when ambient.
pub(super) fn effect_particle(effect: &str, ambient: bool) -> Option<ParticleOptions> {
Some(match effect.trim_start_matches("minecraft:") {
"trial_omen" => ParticleOptions::simple(ParticleType::TrialOmen),
"raid_omen" => ParticleOptions::simple(ParticleType::RaidOmen),
other => {
let color = horizon_client::brewing::effect(other)?.color;
let channel = |shift: u32| ((color >> shift) & 255) as f32 / 255.0;
// MobEffect.AMBIENT_ALPHA is 38 of 255.
let alpha = if ambient { 38.0 / 255.0 } else { 1.0 };
ParticleOptions::colored(
ParticleType::EntityEffect,
[channel(16), channel(8), channel(0), alpha],
)
}
})
}
/// One tick of `LivingEntity.tickEffects`' client branch: a one in four
/// chance (one in fifteen while invisible, a fifth of either when all the
/// effects are ambient) of one of the particles at a random spot of the box.
fn swirl(
particles: &mut ParticleEngine,
feet: Vec3,
size: (f64, f64),
list: &[ParticleOptions],
ambient: bool,
invisible: bool,
) {
if list.is_empty() {
return;
}
let bound = if invisible { 15 } else { 4 } * if ambient { 5 } else { 1 };
let r = particles.random();
let Ok(0) = r.next_int_bound(bound) else {
return;
};
// Util.getRandom, then getRandomX(0.5), getRandomY and getRandomZ.
let pick = r.next_int_bound(list.len() as i32).unwrap_or_default() as usize;
let x = feet.x + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
let y = feet.y + size.1 * r.next_double();
let z = feet.z + size.0 * (2.0 * r.next_double() - 1.0) * 0.5;
particles.add(&list[pick], [x, y, z], [1.0; 3]);
}
+32
View File
@@ -10,6 +10,9 @@ struct Entry {
fall_distance: f32,
/// The causing entity: its id (for comparisons) and display name.
attacker: Option<(u32, Text)>,
/// The direct entity's display name when no entity caused the damage
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
#[derive(Default)]
pub(super) struct CombatTracker {
@@ -19,6 +22,8 @@ pub(super) struct CombatTracker {
/// Attackers of damage dealt this tick, claimed by the matching
/// `RecordedDamage` event.
pending: Vec<(String, (u32, Text))>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -66,6 +71,14 @@ impl Game<'_> {
.pending
.push((damage_type.to_owned(), (id, name)));
}
/// The `DamageSource.directEntity` of the next `damage_type` damage when
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
}
/// `LivingEntity.handleDamageEvent` for the player: the hurt sound of the
/// source's `DamageEffects` (`Player.getHurtSound`).
pub(super) fn player_hurt_sound(&mut self, sound: &str, at: [f64; 3]) {
@@ -94,6 +107,15 @@ impl Game<'_> {
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending.remove(i).1);
let direct = match attacker {
Some(_) => None,
None => self
.combat
.pending_direct
.iter()
.position(|(t, _)| *t == name)
.map(|i| self.combat.pending_direct.remove(i).1),
};
let (fall_location, fall_distance) = self.fall_location();
let now = self.world.game_time;
if let Some(a) = &attacker {
@@ -111,10 +133,12 @@ impl Game<'_> {
fall_location,
fall_distance,
attacker,
direct,
});
self.combat.last_damage = now;
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -224,6 +248,7 @@ impl Game<'_> {
let entries = std::mem::take(&mut self.combat.entries);
let killing = entries.last().expect("checked above");
let killer = killing.attacker.clone();
let direct = killing.direct.clone();
if def.1 == "fall_variants"
&& let Some(knock) = knock.and_then(|i| entries.get(i))
{
@@ -248,15 +273,22 @@ impl Game<'_> {
.append(Text::literal("]"));
return Text::translate(&format!("{key}.message"), &[me, link]);
}
// `getLocalizedDeathMessage`: the causing entity's name, or the
// direct entity's when nothing caused the damage.
if let Some((_, name)) = killer {
return Text::translate(&key, &[me, name]);
}
if let Some(name) = direct {
return Text::translate(&key, &[me, name]);
}
// getKillCredit: the last mob that hurt the player in the past 100 ticks.
if let Some(((_, name), at)) = &self.combat.last_hurt_by
&& self.world.game_time - at <= 100
{
return Text::translate(&format!("{key}.player"), &[me, name.clone()]);
}
// No entity at all: the one-argument message, as vanilla sends it (a
// template that names a killer then shows as written).
Text::translate(&key, &[me])
}
}
+1 -2
View File
@@ -541,8 +541,7 @@ impl Game<'_> {
.enumerate()
// The kinds /data can write (entity_nbt).
.filter(|(_, p)| {
p.arrow.is_some()
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
})
.map(|(i, _)| Target::Projectile(i)),
);
@@ -91,7 +91,12 @@ impl Game<'_> {
}
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
m.add_effect(namespaced(effect.id), duration, amplifier);
let name = namespaced(effect.id);
m.add_effect(name, duration, amplifier);
m.extra.hidden_effects.retain(|h| *h != name);
if !particles {
m.extra.hidden_effects.push(name);
}
true
}
None => false,
@@ -154,6 +159,10 @@ impl Game<'_> {
m.extra
.effects
.retain(|x| e.is_some_and(|e| x.0 != namespaced(e.id)));
let left = &m.extra.effects;
m.extra
.hidden_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra.effects.len() != before
}
None => false,
@@ -640,6 +640,72 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
// Leashable.getLeashHolder: a fence knot is not an entity here.
Some("leasher") => match entity {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
.and_then(|(holder, player)| match holder {
// The player who tied it (the host's when unrecorded).
horizon_client::mobs::leash::Holder::Player => {
let uuid = player.or(self.level.player_uuid)?;
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
}
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
_ => Vec::new(),
},
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
Target::Projectile(i) => self
.creatures
.projectiles
.get(i)
.and_then(|p| match (p.owner?, p.guest) {
// A guest's shot: `Who::Player` is them.
(horizon_client::mobs::Who::Player, Some(conn))
if self.lan_acting == Some(conn) =>
{
Some(Target::Player)
}
(horizon_client::mobs::Who::Player, Some(conn)) => {
Some(Target::Guest(conn))
}
(horizon_client::mobs::Who::Player, None) => Some(Target::Player),
(horizon_client::mobs::Who::Mob(id), _) => Some(Target::Mob(id)),
})
.into_iter()
.collect(),
Target::Item(id) => self
.dropped
.iter()
.find(|d| d.id == id)
.and_then(|d| d.thrower)
.and_then(|words| {
let uuid = words
.iter()
.fold(0u128, |all, w| all << 32 | u128::from(*w as u32));
self.target_by_uuid(uuid)
})
.into_iter()
.collect(),
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id && m.kind.id == "minecraft:vex")
.map(|m| m.extra.counter2 as u32)
.filter(|owner| *owner != 0)
.map(Target::Mob)
.into_iter()
.collect(),
_ => Vec::new(),
},
Some("vehicle") => self.ride_vehicle(entity).into_iter().collect(),
Some("controller") | Some("passengers") => match entity {
Target::Mob(id) if self.mobs.iter().any(|m| m.id == id && m.rider.is_some()) => {
@@ -14,10 +14,11 @@ impl Game<'_> {
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
// NbtContents.resolve: the path's values in the entity, block or
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
// ScoreContents.resolve: the holder's score in the objective.
let nbt_values = std::cell::RefCell::new(Vec::new());
{
let mut collect = |o: &serde_json::Map<String, serde_json::Value>| {
if o.contains_key("nbt") {
if o.contains_key("nbt") || o.contains_key("score") {
nbt_values.borrow_mut().push(o.clone());
}
};
@@ -40,7 +41,11 @@ impl Game<'_> {
}
let mut resolved: Vec<(serde_json::Map<String, serde_json::Value>, Text)> = Vec::new();
for o in nbt_values.into_inner() {
let t = self.resolve_nbt_component(&o);
let t = if o.contains_key("score") {
self.resolve_score_component(&o)?
} else {
self.resolve_nbt_component(&o)
};
resolved.push((o, t));
}
let mut resolve = |raw: &str| -> Text {
@@ -73,6 +78,54 @@ impl Game<'_> {
},
))
}
/// The text of a `score` component (ScoreContents.resolve): the named
/// holder's score, `*` being the player the command runs for.
fn resolve_score_component(
&mut self,
o: &serde_json::Map<String, serde_json::Value>,
) -> R<Text> {
let score = o.get("score");
let field = |k: &str| score.and_then(|s| s.get(k)).and_then(|v| v.as_str());
let (name, objective) = (
field("name").unwrap_or(""),
field("objective").unwrap_or(""),
);
// findTargetName: a selector that matches one entity is that entity,
// one that matches none is just the name.
let mut reader = horizon_client::command::Reader::new(name);
let ty = horizon_client::command::args::ArgType::Entity {
single: false,
players: false,
};
let mut holder = name.to_owned();
if let Ok(Arg::Entity(sel)) = ty.parse(&mut reader, &self.ui.registries)
&& !reader.can_read()
{
let found = self.select(&sel);
if found.len() > 1 {
return Err(err("argument.entity.toomany", &[]));
}
if let Some(t) = found.first().and_then(|t| self.holder_name(*t)) {
holder = t;
}
}
if holder == "*" {
holder = self.acting_player_name().to_owned();
}
let board = &self.score.board;
let (Some(objective), Some(score)) =
(board.objective(objective), board.score(&holder, objective))
else {
return Ok(Text::empty());
};
// numberFormatOrDefault(StyledFormat.NO_STYLE).
let format = (score.number_format.clone())
.or_else(|| objective.number_format.clone())
.unwrap_or(horizon_client::scoreboard::NumberFormat::Styled(
serde_json::json!({}),
));
Ok(format.format(score.value))
}
/// The text of an `nbt` component.
fn resolve_nbt_component(&mut self, o: &serde_json::Map<String, serde_json::Value>) -> Text {
use horizon_world::nbt::Tag;
@@ -136,28 +189,40 @@ impl Game<'_> {
}
out
}
fn player_targets(&mut self, ctx: &Context) -> R<usize> {
// Only the local player can receive messages and titles.
/// The players the command reaches: the window's own and the LAN guests.
fn player_targets(&mut self, ctx: &Context) -> R<Vec<Target>> {
let targets = self.entities(ctx, "targets", true)?;
Ok(targets.iter().filter(|t| **t == Target::Player).count())
Ok(targets
.into_iter()
.filter(|t| matches!(t, Target::Player | Target::Guest(_)))
.collect())
}
pub(super) fn cmd_tellraw(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let players = self.player_targets(ctx)?;
let message = self.component_arg(ctx, "message")?;
if n > 0 {
self.system_message(message);
// Each player's chat (a guest's through the connection).
for &t in &players {
self.for_player(t, |g| g.system_message(message.clone()));
}
Ok(n as i32)
Ok(players.len() as i32)
}
pub(super) fn cmd_title(&mut self, ctx: &Context) -> R<i32> {
let n = self.player_targets(ctx)?;
let name = self.target_name(Target::Player);
let players = self.player_targets(ctx)?;
let n = players.len();
let name = players
.first()
.map_or_else(Text::empty, |t| self.target_name(*t));
let single = n == 1;
let key = if ctx.has("clear") {
self.titles_or_packet(Titles::clear, C::ClearTitles(play::Bool1 { value: false }));
self.titles_or_packet(
&players,
Titles::clear,
C::ClearTitles(play::Bool1 { value: false }),
);
"cleared"
} else if ctx.has("reset") {
self.titles_or_packet(
&players,
|t| {
t.clear();
t.reset_times();
@@ -172,6 +237,7 @@ impl Game<'_> {
};
let (fade_in, stay, fade_out) = (get("fadeIn"), get("stay"), get("fadeOut"));
self.titles_or_packet(
&players,
|t| t.set_times(fade_in, stay, fade_out),
C::SetTitlesAnimation(play::TitlesAnimation {
fade_in,
@@ -186,13 +252,17 @@ impl Game<'_> {
text: component_nbt(&text.component()),
};
if ctx.has("subtitle") {
self.titles_or_packet(|t| t.set_subtitle(text), C::SetSubtitleText(sent));
self.titles_or_packet(&players, |t| t.set_subtitle(text), C::SetSubtitleText(sent));
"show.subtitle"
} else if ctx.has("actionbar") {
self.titles_or_packet(|t| t.set_overlay(text, false), C::SetActionBarText(sent));
self.titles_or_packet(
&players,
|t| t.set_overlay(text, false),
C::SetActionBarText(sent),
);
"show.actionbar"
} else {
self.titles_or_packet(|t| t.set_title(text), C::SetTitleText(sent));
self.titles_or_packet(&players, |t| t.set_title(text), C::SetTitleText(sent));
"show.title"
}
};
@@ -207,13 +277,27 @@ impl Game<'_> {
self.feedback(message);
Ok(n as i32)
}
/// The player the title is for: the guest acting now is sent the packet
/// (TitleCommand's `connection.send`), the window's own player shows it.
fn titles_or_packet(&mut self, local: impl FnOnce(&mut Titles), packet: C) {
if self.lan_acting.is_some() {
self.guest_packets.push(packet);
} else {
local(&mut self.titles);
/// TitleCommand's `connection.send` for each player: a guest is sent the
/// packet, the window's own player shows it.
fn titles_or_packet(&mut self, players: &[Target], local: impl FnOnce(&mut Titles), packet: C) {
let mut local = Some(local);
for &t in players {
match t {
Target::Player if self.lan_acting.is_some() => {
self.guest_packets.push(packet.clone());
}
Target::Player => {
if let Some(local) = local.take() {
local(&mut self.titles);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
}
}
@@ -141,27 +141,37 @@ impl Game<'_> {
Some(Arg::Entity(_)) => self.entities(ctx, "entity", false)?.first().copied(),
_ => None,
};
let cause = match ctx.get("cause") {
Some(Arg::Entity(_)) => self.entities(ctx, "cause", false)?.first().copied(),
_ => None,
};
let from = match ctx.get("location") {
Some(Arg::Coords(_)) => self.position_arg(ctx, "location"),
_ => by.and_then(|t| self.target_position(t)),
};
let hurt = match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player => {
if self.creative || self.spectator() {
// `DamageSource(type, cause, by)`: the death message names the
// causing entity, else the direct one. A mob is noted by
// `hurt_player_from`, a player here.
if let Some(t @ (Target::Player | Target::Guest(_))) = cause.or(by) {
let name = self.target_name(t);
self.note_direct_entity(&ty, name);
}
if self.spectator() {
false
} else if let Some(player) = self.player.as_mut() {
match self.motion.health.damage.id(&ty) {
Some(id) => player
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.unwrap_or(false),
None => false,
}
} else {
false
let attacker = match cause.or(by) {
Some(Target::Mob(i)) => Some(Some(i)),
_ => Some(None),
};
self.hurt_player_from(amount, from, &ty, attacker)
}
}
Target::Mob(id) => {
let attacker = by.and_then(|t| match t {
let attacker = cause.or(by).and_then(|t| match t {
Target::Player => Some(Who::Player),
Target::Mob(i) => Some(Who::Mob(i)),
_ => None,
@@ -302,9 +312,18 @@ impl Game<'_> {
float("minVolume", 0.0),
);
let range = if volume > 1.0 { 16.0 * volume } else { 16.0 };
// PlaySoundCommand: one seed for every player.
let seed = self.world.random.next_long();
let here = self.dim.id();
let mut tracker = Tracker::default();
for t in targets {
if t != Target::Player {
if !matches!(t, Target::Player | Target::Guest(_)) {
continue;
}
// Only players in the source's level hear it.
if let Target::Guest(conn) = t
&& self.command_guest(conn).is_none_or(|g| g.dimension != here)
{
continue;
}
let Some(p) = self.target_position(t) else {
@@ -328,8 +347,26 @@ impl Game<'_> {
} else {
(pos, volume)
};
self.audio.play_on(source, &sound, Some(at), v, pitch);
tracker.mark(&holder(Text::literal(self.acting_player_name())));
// The connection is sent the sound; the window's own player
// plays it.
let guest = match t {
Target::Guest(conn) => Some(conn),
_ => self.lan_acting,
};
match guest.and_then(|conn| Some((conn, self.lan.as_ref()?))) {
Some((conn, host)) => {
let packet = host
.hub
.sound_packet(&sound, at, v, pitch, source as i32, seed);
if t == Target::Player {
self.guest_packets.push(packet);
} else {
host.hub.send_to(conn, &packet);
}
}
None => self.audio.play_on(source, &sound, Some(at), v, pitch),
}
tracker.mark(&holder(self.target_name(t)));
}
let name = Text::literal(sound.clone());
let (message, total) = tracker.message(
@@ -606,8 +643,10 @@ impl Game<'_> {
let reached = reached.len() as i32;
if !local {
} else if count <= 0 {
// No count: one particle with the delta as velocity.
self.particles.create(&options, pos, delta);
// handleParticleEvent, count 0: one particle, the speed times
// the delta as velocity (both sent as floats, multiplied as floats).
self.particles
.create(&options, pos, delta.map(|d| f64::from(speed * d as f32)));
} else {
for _ in 0..count {
let r = self.particles.random();
+4 -3
View File
@@ -218,8 +218,9 @@ impl Game<'_> {
);
}
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
/// death and a natural heart drops experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3]) {
/// death and a natural heart drops experience. `onExplosionHit` does the
/// same for the creaking (`by_player` false), without the experience.
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
let key = (pos[0], pos[1], pos[2]);
if !matches!(
self.world.block_entities.get(&key),
@@ -242,7 +243,7 @@ impl Game<'_> {
.state(state)
.and_then(|s| s.properties.get("natural").cloned())
.is_some_and(|v| v == "true");
if natural && !self.creative && !self.spectator() {
if by_player && natural && !self.creative && !self.spectator() {
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
self.spawn_orbs(
[
+197 -32
View File
@@ -18,6 +18,7 @@ use horizon_client::{
spawning::{self, BiomeFacts},
},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3, traverse_blocks},
@@ -532,6 +533,8 @@ impl Game<'_> {
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The mobs whose bodies go this tick: (id, feet, width, height).
let mut gone = Vec::new();
let level_random;
{
// The level random, lent to the mobs (brain behaviours draw from
@@ -604,6 +607,11 @@ impl Game<'_> {
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
// LivingEntity.tickDeath: after twenty ticks the body is removed.
if !kept && !m.removed && !m.alive() && m.death_time >= 20 {
let (width, height) = (f64::from(m.width()), f64::from(m.height()));
gone.push((m.id, m.position, width, height));
}
if let Some(conn) = seen_guest {
// ShulkerBullet.finalTarget is the guest's entity: the
// bullet homes on it while the projectiles tick, where
@@ -662,6 +670,12 @@ impl Game<'_> {
}
self.mobs.append(&mut mobs);
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
let at = [at.x, at.y, at.z];
self.lan_entity_event(id as i32, 60, at);
self.death_poof(at, width, height);
}
self.sync_riders();
self.tick_skeleton_traps();
self.tick_apparition();
@@ -688,6 +702,22 @@ impl Game<'_> {
self.handle_mob_events(queued)?;
Ok(())
}
/// `LivingEntity.makePoofParticles` (entity event 60) about a mob's box.
pub(super) fn death_poof(&mut self, at: [f64; 3], width: f64, height: f64) {
let options = horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Poof,
);
for _ in 0..20 {
let r = self.particles.level_random();
let xa = r.next_gaussian() * 0.02;
let ya = r.next_gaussian() * 0.02;
let za = r.next_gaussian() * 0.02;
let x = at[0] + width * (2.0 * r.next_double() - 1.0) - xa * 10.0;
let y = at[1] + height * r.next_double() - ya * 10.0;
let z = at[2] + width * (2.0 * r.next_double() - 1.0) - za * 10.0;
self.particles.add(&options, [x, y, z], [xa, ya, za]);
}
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let killer = self.last_hurt_by_player.remove(&m.id);
@@ -1084,6 +1114,11 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
let (owner, at) = (p.owner, [p.position.x, p.position.y, p.position.z]);
// LlamaSpit.recreateFromPacket: the spit's burst on the client.
if p.kind == ProjectileKind::LlamaSpit {
let v = p.velocity;
self.particles.llama_spit_burst(at, [v.x, v.y, v.z]);
}
self.creatures.projectiles.push(p);
self.projectile_event("minecraft:projectile_shoot", owner, at);
}
@@ -1646,11 +1681,34 @@ impl Game<'_> {
pub(super) fn mob_hits_player_kind(&mut self, amount: f32, from: [f64; 3], kind: &str) {
self.mob_hits_player(amount, from, kind);
}
/// Damage whose causing mob is known (`cause`, or none): not guessed
/// from the mob nearest to `from`.
pub(super) fn mob_hits_player_caused(
&mut self,
amount: f32,
from: [f64; 3],
kind: &str,
cause: Option<u32>,
) {
self.hurt_player_from(amount, Some(from), kind, Some(cause));
}
pub(super) fn mob_hits_player(
&mut self,
amount: f32,
from: [f64; 3],
damage_type: &str,
) -> bool {
self.hurt_player_from(amount, Some(from), damage_type, None)
}
/// `hurtServer` on the player. `from` is the source's position (none: no
/// direction), `known`: the causing mob when it is given (`/damage`),
/// else the melee attacker is the mob nearest to `from`.
pub(super) fn hurt_player_from(
&mut self,
amount: f32,
from: Option<[f64; 3]>,
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
@@ -1663,23 +1721,26 @@ impl Game<'_> {
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(Some(from), damage_type, false);
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
let attacker = (!projectile && damage_type != "minecraft:player_attack")
.then(|| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
})
.flatten();
let attacker = match known {
Some(known) => known,
None => from
.filter(|_| !projectile && damage_type != "minecraft:player_attack")
.and_then(|from| {
self.mobs
.iter()
.filter(|m| m.alive())
.min_by(|a, b| {
let d = |m: &horizon_client::mobs::Mob| {
(m.position.x - from[0]).powi(2) + (m.position.z - from[2]).powi(2)
};
d(a).total_cmp(&d(b))
})
.map(|m| m.id)
}),
};
// The causing entity for the death message.
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
@@ -1707,7 +1768,10 @@ impl Game<'_> {
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
// No source position: a random direction.
let (xd, zd) = from.map_or((0.0, 0.0), |from| {
(from[0] - player.position.x, from[2] - player.position.z)
});
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()
@@ -2045,6 +2109,33 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
/// (Impaling's `minecraft:damage` on `#sensitive_to_impaling`).
fn shot_damage(&mut self, shot: &mut Projectile, victim: Option<&str>) -> f32 {
if !matches!(shot.kind, ProjectileKind::Trident) {
return arrow_damage(shot, &mut self.world.random) + shot.bonus.damage;
}
let Some(trident) = shot.arrow.as_ref().map(|a| a.pickup_item.clone()) else {
return 8.0;
};
let ctx = horizon_client::enchant::EffectContext {
this: victim,
attacker: (shot.owner == Some(Who::Player)).then_some("minecraft:player"),
direct_attacker: Some("minecraft:trident"),
damage_type: Some("minecraft:trident"),
items: Some(self.res.items()),
..Default::default()
};
self.res.stations().enchantments.modify_value(
Some(&trident),
"minecraft:damage",
&ctx,
&mut self.world.random,
8.0,
)
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
@@ -2073,6 +2164,8 @@ impl Game<'_> {
ArrowSound::Splash => "minecraft:entity.generic.splash",
ArrowSound::HitGround => "minecraft:entity.arrow.hit",
ArrowSound::Extinguish => "minecraft:entity.generic.extinguish_fire",
ArrowSound::TridentHitGround => "minecraft:item.trident.hit_ground",
ArrowSound::TridentHit => "minecraft:item.trident.hit",
};
self.audio
.play(name, Some([at.x, at.y, at.z]), volume, pitch);
@@ -2222,11 +2315,11 @@ impl Game<'_> {
&& id >= super::dragon::SPECIAL
{
let by_player = p.owner == Some(Who::Player);
if let ProjectileKind::Arrow { .. } = p.kind {
if p.arrow.is_some() {
// AbstractArrow.onHitEntity: spent if the part or crystal took
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let outcome = match self.hit_special(id, amount, by_player, true) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
@@ -2247,17 +2340,15 @@ impl Game<'_> {
self.hit_special(id, amount, by_player, false);
return Ok(());
}
if let (
ProjectileKind::Arrow { .. },
Hit::Entity {
if p.arrow.is_some()
&& let Hit::Entity {
who: Who::Mob(id), ..
},
) = (&p.kind, hit)
} = hit
&& let Some(i) = self.carts.iter().position(|c| c.id == id)
{
// AbstractArrow.onHitEntity on a minecart.
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
let amount = self.shot_damage(&mut arrow, None);
let burning = p.arrow.as_ref().is_some_and(|a| a.on_fire());
let creative = p.owner == Some(Who::Player) && self.creative;
self.arrow_hits_cart(i, amount, p.velocity, burning, creative);
@@ -2268,7 +2359,7 @@ impl Game<'_> {
// Projectile.onHit: the landing is heard.
self.projectile_event("minecraft:projectile_land", p.owner, atp);
let source_kind = match p.kind {
ProjectileKind::Arrow { .. } => DamageKind::Arrow,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => DamageKind::Arrow,
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
DamageKind::Fireball
}
@@ -2280,6 +2371,40 @@ impl Game<'_> {
from: Some([p.previous.x, p.previous.y, p.previous.z]),
};
match (&p.kind, hit) {
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
// dealt whether or not the hurt lands, then it bounces off.
(ProjectileKind::Trident, Hit::Entity { who, .. }) => {
let mut trident = p.clone();
let victim = match who {
Who::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.map(|m| m.kind.id.clone()),
Who::Player => Some("minecraft:player".to_owned()),
};
let amount = self.shot_damage(&mut trident, victim.as_deref());
let hurt = match who {
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:trident",
),
Who::Mob(id) => self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source)),
};
let outcome = if hurt {
EntityHit::Hurt
} else {
EntityHit::Refused
};
self.finish_arrow_hit(trident, outcome);
}
// AbstractArrow.onHitBlock plays its sound itself (ArrowEvent).
(ProjectileKind::Trident, Hit::Block { .. }) => {}
(ProjectileKind::Arrow { effect, .. }, Hit::Entity { who, .. }) => {
let mut arrow = p.clone();
let amount = arrow_damage(&mut arrow, &mut self.world.random) + p.bonus.damage;
@@ -2501,14 +2626,30 @@ impl Game<'_> {
if d2 < 16.0 && !self.creative {
let scale = 1.0 - d2.sqrt() / 4.0;
let t = (f64::from(*ticks) * scale + 0.5) as i32;
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:indirect_magic", owner);
}
match *effect {
"minecraft:instant_damage" => self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
),
"minecraft:instant_damage" => {
// indirectMagic(potion, owner), built where the
// hurt happens: the thrower, or the potion
// itself when it has none. A status effect
// deals no damage, so it records no source.
let owner = self
.combat
.projectile_owner
.filter(|id| self.mob_display_name(*id).is_some());
match owner {
Some(owner) => {
self.note_attacker("minecraft:indirect_magic", owner)
}
None => self.note_direct_entity(
"minecraft:indirect_magic",
Text::translate("entity.minecraft.splash_potion", &[]),
),
}
self.hurt_player_by(
"minecraft:indirect_magic",
6.0 * (1 << *amplifier) as f32 * scale as f32,
)
}
e if t > 20 => self.give_player_effect(e, t, *amplifier),
_ => {}
}
@@ -3417,6 +3558,30 @@ impl Game<'_> {
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
@@ -654,6 +654,9 @@ impl Game<'_> {
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
if let Some(host) = self.lan.as_mut() {
host.hub.entity_range_percent = props.entity_broadcast_range_percentage;
}
let mut state = ServerState {
lists,
console,
+1 -1
View File
@@ -982,7 +982,7 @@ impl Game<'_> {
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
+4 -1
View File
@@ -2157,11 +2157,14 @@ impl Game<'_> {
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
/// death message names it, or the cloud once the dragon is gone.
fn dragon_hurts_player(&mut self, damage_type: &str, amount: f32) {
if let Some(id) = self.dragon.as_ref().map(|d| d.id) {
let name = Text::translate("entity.minecraft.ender_dragon", &[]);
self.note_named_attacker(damage_type, id, name);
} else if damage_type == "minecraft:indirect_magic" {
let name = Text::translate("entity.minecraft.area_effect_cloud", &[]);
self.note_direct_entity(damage_type, name);
}
self.hurt_player_by(damage_type, amount);
}
+14 -9
View File
@@ -572,16 +572,16 @@ impl Game<'_> {
let pitch = (1.0 + (client_float() - client_float()) * 0.2) * 0.7;
self.audio
.play("minecraft:entity.generic.explode", Some(center), 4.0, pitch);
let block_count = if interaction == BlockInteraction::Keep {
0
} else {
targets.len()
};
// ServerLevel.explode sends toBlow.size() whatever the interaction, and
// `ServerExplosion.isSmall` also covers an explosion that keeps blocks.
let block_count = targets.len();
let small = radius < 2.0 || interaction == BlockInteraction::Keep;
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion(center, radius, block_count, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, radius, block_count);
self.particles
.explosion(center, small, block_count, radius, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
self.lan_explode(center, small, radius, block_count);
Ok(())
}
/// `BlockBehaviour.onExplosionHit` (TNT: `wasExploded` primes a short fuse).
@@ -598,6 +598,11 @@ impl Game<'_> {
}
let name = self.res.facts().get(state).name.clone();
let is_tnt = &*name == "minecraft:tnt";
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
// experience, as Horizon does not track a TNT's owner).
if &*name == "minecraft:creaking_heart" {
self.creaking_heart_broken(pos, false);
}
// Block.onExplosionHit: the drops copy from the block entity, which
// then spills its contents as the block turns to air.
let key = (pos[0], pos[1], pos[2]);
+12 -1
View File
@@ -103,12 +103,23 @@ impl Game<'_> {
let b = horizon_core::geometry::Aabb::new(lo, hi);
// Every player in the box, the server's players too.
let pos = f.pos;
// `EvokerFangs.dealDamageTo`: indirectMagic(fangs, owner) while the
// owner lives, plain magic (nobody to name) once it is gone.
let owner = self
.mobs
.iter()
.any(|m| m.id == f.owner && m.alive())
.then_some(f.owner);
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
let kind = match owner {
Some(_) => "minecraft:indirect_magic",
None => "minecraft:magic",
};
g.mob_hits_player_caused(6.0, pos, kind, owner);
}
};
bite(self);
@@ -20,7 +20,8 @@ use horizon_core::{
};
use horizon_world::{
block_collision::BlockCollisionRegistry, collision_context::CollisionContext,
heightmap::HeightmapType, worldgen::terrain::TerrainBlockStates,
heightmap::HeightmapType, level::flags::UPDATE_CLIENTS, registry::BlockStateId,
worldgen::terrain::TerrainBlockStates,
};
/// The loaded world as the spawn searches query it.
@@ -121,6 +122,96 @@ impl Game<'_> {
}
Ok(Some([ox * 16 + 8, height, oz * 16 + 8]))
}
/// MinecraftServer.setInitialSpawn's bonus chest: BonusChestFeature.place
/// in the spawn chunk, a chest with the spawn bonus loot and a torch on
/// each side that can hold one, on the first free column of a shuffled
/// walk over the chunk. Skipped while the chunk is not loaded.
pub(super) fn place_bonus_chest(&mut self, origin: [i32; 3]) -> Result<()> {
use horizon_client::containers::BlockEntity;
let (cx, cz) = (origin[0] >> 4, origin[2] >> 4);
let registry = &self.pack.assets.registry;
let (Some(chest), Some(torch)) = (
registry.default_state("minecraft:chest"),
registry.default_state("minecraft:torch"),
) else {
return Ok(());
};
if !self.streaming.is_lit(cx, cz) {
return Ok(());
}
// `level.getRandom()`. Util.toShuffledList of the chunk's x and z.
let random = &mut self.world.random;
let mut shuffled = |min: i32| -> [i32; 16] {
let mut values: [i32; 16] = std::array::from_fn(|i| min + i as i32);
for end in (2..=16usize).rev() {
let other = random.next_int_bound(end as i32).unwrap_or(0) as usize;
values.swap(end - 1, other);
}
values
};
let (xs, zs) = (shuffled(cx * 16), shuffled(cz * 16));
for x in xs {
for z in zs {
let y = self
.spawn_columns()
.height(HeightmapType::MotionBlockingNoLeaves, x, z);
if self.world.block(x, y, z) != self.world.air && !self.collision_empty([x, y, z]) {
continue;
}
self.set_block_clients([x, y, z], chest)?;
let seed = self.world.random.next_long();
self.world
.block_entities
.insert((x, y, z), BlockEntity::Chest(Vec::new()));
self.load_block_entity_nbt(
[x, y, z],
&format!(
"{{LootTable:\"minecraft:chests/spawn_bonus_chest\",LootTableSeed:{seed}L}}"
),
);
for [dx, dz] in [[0, -1], [1, 0], [0, 1], [-1, 0]] {
let at = [x + dx, y, z + dz];
let survives = self.with_level(|b, level, ticks| {
b.can_survive(level, ticks, BlockPos::new(at[0], at[1], at[2]), torch)
});
if survives {
self.set_block_clients(at, torch)?;
}
}
return Ok(());
}
}
Ok(())
}
/// `Level.setBlock(pos, state, UPDATE_CLIENTS)`: no neighbour updates.
fn set_block_clients(&mut self, pos: [i32; 3], state: BlockStateId) -> Result<()> {
self.with_level(|b, level, ticks| {
b.set_block(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
state,
UPDATE_CLIENTS,
);
});
self.process_level_events()
}
/// The block at `pos` has no collision shape.
fn collision_empty(&self, pos: [i32; 3]) -> bool {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape_with_context(
&mut scene,
self.world.block(pos[0], pos[1], pos[2]),
BlockPos::new(pos[0], pos[1], pos[2]),
&CollisionContext::empty(false),
)
.is_ok_and(|shape| shape.is_empty())
}
/// PlayerSpawnFinder.findSpawn around the world spawn: the feet of a
/// player who has no position yet. None while a candidate's chunk loads.
pub(super) fn find_player_spawn(&mut self, suggestion: [i32; 3]) -> Result<Option<Vec3>> {
@@ -52,6 +52,8 @@ struct Flags {
/// server follows while it runs (IntegratedServer.tickServer).
render_distance: AtomicI32,
simulation_distance: AtomicI32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
}
/// Work the window hands its server thread (`MinecraftServer.execute`).
@@ -69,6 +71,10 @@ impl Integrated {
pub fn smoothed_tick_ms(&self) -> f32 {
f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed))
}
/// `MinecraftServer.isCurrentlySaving`.
pub fn is_saving(&self) -> bool {
self.flags.saving.load(Ordering::Relaxed)
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
@@ -148,6 +154,12 @@ pub(super) struct Serving {
/// is out of its place: applied after the tick.
pending: Option<Publish>,
}
impl Serving {
/// The window reads this as `MinecraftServer.isCurrentlySaving`.
pub(super) fn set_saving(&self, saving: bool) {
self.flags.saving.store(saving, Ordering::Relaxed);
}
}
enum Publish {
Open(TcpListener, LanSettings),
Close,
+82 -12
View File
@@ -11,7 +11,7 @@ use horizon_client::{
chunks::ChunkEncoder,
data::SyncData,
entity::{EntityState, States},
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
hub::{BodyFlags, HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, Who, leash::Holder},
@@ -191,6 +191,17 @@ fn mob_meta(
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID: the
// swirls of its effects (the ones a mob has are visible, not ambient).
meta.extend(
hub.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, false)),
),
);
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
defaults.push((11, serializer::BOOLEAN, Value::Bool(false)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4 (what
// draws a vindicator's axe, a skeleton's drawn bow, a piglin's raised
// weapon).
@@ -236,6 +247,27 @@ fn mob_meta(
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on (the default).
"minecraft:snow_golem" => {
if e.sheared {
meta.push((16, serializer::BYTE, Value::Byte(0)));
}
defaults.push((16, serializer::BYTE, Value::Byte(16)));
}
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
"minecraft:mooshroom" => {
if e.variant == 1 {
meta.push((17, serializer::INT, Value::Int(1)));
}
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Piglin.DATA_IS_DANCING (after the baby flag and the crossbow charge).
"minecraft:piglin" => {
if e.scratch[0] > 0 {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((19, serializer::BOOLEAN, Value::Bool(false)));
}
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 when sheared.
"minecraft:sheep" => {
let value = (e.color & 15) | if e.sheared { 16 } else { 0 };
@@ -350,6 +382,14 @@ fn mob_meta(
}
defaults.push((17, serializer::BOOLEAN, Value::Bool(false)));
}
// PolarBear.DATA_STANDING_ID (after AgeableMob's two): the bear
// rears up; the client animates it.
"minecraft:polar_bear" => {
if e.flag {
meta.push((18, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((18, serializer::BOOLEAN, Value::Bool(false)));
}
// SpellcasterIllager.DATA_SPELL_CASTING_ID: the casting pose.
"minecraft:evoker" | "minecraft:illusioner" => {
if e.variant2 != 0 {
@@ -845,6 +885,16 @@ impl Game<'_> {
living_flags: living_flags(self.using)
| if p.auto_spin_attack_ticks > 0 { 4 } else { 0 },
bed: self.sleeping,
body: BodyFlags {
gliding: p.fall_flying,
swimming: p.swimming,
crawling: p.pose == horizon_client::pose::Pose::Swimming && !p.swimming,
crouching: p.pose == horizon_client::pose::Pose::Crouching,
spin_attack: p.auto_spin_attack_ticks > 0,
on_fire: p.fire_ticks > 0,
invisible: self.potions.effects.get("invisibility").is_some(),
glowing: self.potions.effects.get("glowing").is_some(),
},
})
}
@@ -962,6 +1012,16 @@ impl Game<'_> {
host.hub.block_event(pos, b0, b1, state);
}
let entities = states.as_slice();
// An entity that left the level since the last tick (a snowball
// that hit) is dropped by the tracker below: its event goes out
// first, as `Level.broadcastEntityEvent` precedes the discard.
let (gone, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.lan_entity_events)
.into_iter()
.partition(|(id, ..)| !entities.iter().any(|e| e.id == *id));
self.lan_entity_events = later;
for (id, event, at) in gone {
host.hub.entity_event(id, event, at);
}
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
@@ -1243,8 +1303,9 @@ impl Game<'_> {
self.lan_entity_events.push((id, event, at));
}
}
/// `ServerPlayer.crit`: this window's emitter, and an animation for the
/// attacker's client and observers once the hub is back in place.
/// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's
/// emitter, and an animation for the attacker's client and observers once
/// the hub is back in place.
pub(super) fn critical_hit(
&mut self,
attacker: Option<u32>,
@@ -1252,16 +1313,18 @@ impl Game<'_> {
feet: [f64; 3],
width: f64,
height: f64,
magic: bool,
) {
use horizon_client::particles::{ParticleOptions, ParticleType};
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
let particle = if magic {
ParticleType::EnchantedHit
} else {
ParticleType::Crit
};
self.particles
.tracking_emitter(ParticleOptions::simple(particle), feet, width, height);
if self.audio.capture.is_some() {
self.lan_critical_hits.push((attacker, id));
self.lan_critical_hits.push((attacker, id, magic));
}
}
/// Send the tick's level events, sounds and mob animations.
@@ -1339,13 +1402,13 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
for (attacker, target) in critical_hits {
for (attacker, target, magic) in critical_hits {
let Some(attacker) = attacker.map_or(Some(host.host_entity), |conn| {
host.hub.guest(conn).map(|g| g.entity_id)
}) else {
continue;
};
host.hub.critical_hit(attacker, target);
host.hub.critical_hit(attacker, target, magic);
}
}
@@ -1428,6 +1491,13 @@ impl Game<'_> {
.each_ref()
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.crouching = g.sneaking;
m.extra.sprinting = g.sprinting;
// LivingEntity.DATA_EFFECT_PARTICLES: the swirls of its visible effects.
let visible = || g.effects.list.iter().filter(|e| e.visible);
m.extra.effect_particles = visible()
.filter_map(|e| super::client_ambient::effect_particle(e.id, e.ambient))
.collect();
m.extra.effect_ambient = visible().all(|e| e.ambient);
// Lying in its bed (LivingEntity.getBedOrientation).
m.extra.ints[1] = g.bed.map_or(0, |b| self.bed_facing(b));
// LivingEntity.updateSwingTime: the arm swing lan_swings started.
@@ -1216,6 +1216,12 @@ impl Game<'_> {
{
self.guest_carried_back(host, gi);
}
S::SelectBundleItem(p) => {
if !spectator && let Ok(slot) = usize::try_from(p.slot) {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
}
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
let slots = host.hub.guests[gi]
@@ -2319,7 +2325,35 @@ impl Game<'_> {
for event in events {
match event {
MotionEvent::HurtSound(sound) => self.player_hurt_sound(sound, at),
// LivingEntity.checkFallDamage: the crumbs reach every player.
MotionEvent::Fall(horizon_client::landing::FallEvent::Particles {
state,
position,
count,
speed,
}) => self.landing_dust(Some(host), state, position, count, speed),
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// HoneyBlock.maybeDoSlideEffects: the sound reaches the
// others (Player.playSound excludes the player, whose client
// plays it), the entity event 53 everyone around.
MotionEvent::HoneySlide {
sound, particles, ..
} => {
let Some((id, at)) = host.hub.guest(conn).map(|g| (g.entity_id, g.position))
else {
continue;
};
if sound {
let name = "minecraft:block.honey_block.slide";
let source = horizon_client::settings::sound_source(name) as i32;
self.audio.play_unshared(name, Some(at), 1.0, 1.0);
host.hub.sound(name, at, 1.0, 1.0, source, Some(conn));
}
if particles {
self.honey_slide_particles(at);
host.hub.entity_event(id, 53, at);
}
}
_ => {}
}
}
@@ -357,6 +357,20 @@ impl Game<'_> {
a.is_some_and(|a| a.in_ground) || p.stuck,
));
}
// ThrownTrident.ID_LOYALTY (none for a mob's) and ID_FOIL, on
// AbstractArrow's flags.
K::Trident => {
let a = p.arrow.as_deref();
e.meta.extend(arrow_meta(
false,
a.is_some_and(|a| a.no_physics),
0,
a.is_some_and(|a| a.in_ground) || p.stuck,
));
if a.is_some_and(|a| a.pickup_item.has_any_enchantments()) {
e.meta.push((12, serializer::BOOLEAN, Value::Bool(true)));
}
}
// WitherSkull.DATA_DANGEROUS.
K::WitherSkull { dangerous: true } => {
e.meta.push((8, serializer::BOOLEAN, Value::Bool(true)));
@@ -1,7 +1,8 @@
//! Particles a server sends its players and its clients draw:
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
@@ -81,6 +82,66 @@ impl Game<'_> {
}
}
/// `LivingEntity.checkFallDamage`'s `ServerLevel.sendParticles` of the
/// landing block's crumbs: drawn in this window, when it has one, and
/// sent to each player within 32 blocks. `host` is the hub of a guest's
/// turn, which is out of `self.lan` meanwhile.
pub(super) fn landing_dust(
&mut self,
host: Option<&LanHost>,
state: BlockStateId,
at: Vec3,
count: i32,
speed: f64,
) {
let at = [at.x, at.y, at.z];
if self.serving.is_none() && self.options.dedicated.is_none() {
let block = [
at[0].floor() as i32,
(at[1] - 0.2).floor() as i32,
at[2].floor() as i32,
];
if let Some((sprite, tint)) = self.particle_sprite(state, block) {
self.particles.send_particles(
&ParticleOptions::block(ParticleType::Block, sprite, tint),
at,
[0.0; 3],
speed,
count as u32,
);
}
}
let Some(hub) = host.or(self.lan.as_ref()).map(|h| &h.hub) else {
return;
};
let Some(particle) = Self::hub_particle(
hub,
&ParticleOptions::simple(ParticleType::Block),
Some(state),
) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: [speed as f32; 3],
count,
randomization: 0,
});
for g in hub.guests.iter().filter(|g| hub.here(g)) {
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
/// `ParticleTypes.STREAM_CODEC`'s value for `options`: the type's
/// network id and the data it carries (`state` for the block particles).
/// None while not serving, or for a particle whose data isn't sent from
@@ -89,13 +150,19 @@ impl Game<'_> {
&self,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
Self::hub_particle(&self.lan.as_ref()?.hub, options, state)
}
/// `network_particle` for a hub that is out of `self.lan` meanwhile (a
/// guest's turn).
fn hub_particle(
hub: &Hub,
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
let name = format!("minecraft:{}", options.ty.name());
let id = self
.lan
.as_ref()?
.hub
.registry_id("minecraft:particle_type", &name)?;
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
let c = options.color;
let data = match options.ty {
@@ -254,8 +321,14 @@ impl Game<'_> {
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
let emitter = if radius < 2.0 {
pub(super) fn lan_explode(
&self,
center: [f64; 3],
small: bool,
radius: f32,
block_count: usize,
) {
let emitter = if small {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
+12 -5
View File
@@ -37,6 +37,8 @@ pub(super) struct Strike {
amount: f32,
scale: f32,
crit: bool,
/// `Player.attack`'s `magicBoost > 0`: the enchantments added damage.
magic: bool,
from: [f64; 3],
sweep: Option<[f64; 3]>,
/// `causeExtraKnockback`: its strength and direction (the attacker's
@@ -109,7 +111,7 @@ impl Game<'_> {
pub(super) fn pvp_strike(&mut self) -> Option<Strike> {
let p = self.player.as_ref()?;
let from = [p.position.x, p.position.y, p.position.z];
let (amount, scale, crit) = self.melee_damage("minecraft:player");
let (amount, scale, crit, magic) = self.melee_damage("minecraft:player");
let sweep = self.sweep_attack_position(scale, crit);
let (strength, knockback_attack) = self.extra_knockback("minecraft:player", scale);
let (xd, zd) = self.extra_knockback_direction();
@@ -125,6 +127,7 @@ impl Game<'_> {
amount,
scale,
crit,
magic,
from,
sweep,
knockback: (strength, xd, zd),
@@ -257,7 +260,7 @@ impl Game<'_> {
} else {
Default::default()
};
if hurt && s.crit {
if hurt && (s.crit || s.magic) {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
@@ -267,13 +270,17 @@ impl Game<'_> {
};
if let Some((id, p)) = target {
let d = p.dimensions;
self.critical_hit(
s.attacker,
id,
let (feet, width, height) = (
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
);
if s.crit {
self.critical_hit(s.attacker, id, feet, width, height, false);
}
if s.magic {
self.critical_hit(s.attacker, id, feet, width, height, true);
}
}
}
// `Enchantment.doPostAttack`: thorns hurt the attacking player.
+5
View File
@@ -104,6 +104,9 @@ impl Game<'_> {
};
let m = &mut self.mobs[i];
if let Some(h) = holder {
if h == Holder::Player {
m.extra.leash_player = Some(player_uuid);
}
m.extra.leash = Some(h);
m.extra.leash_pending = None;
} else if m.age > 100 {
@@ -201,7 +204,9 @@ impl Game<'_> {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
self.mobs[index].extra.leash_player = Some(holder);
self.lead_sound("tied", at);
// The lead is always used up.
if let Some(stack) = self.held_mut().as_mut() {
@@ -3,6 +3,7 @@
//! consumable's teleport (2017), electric sparks (3002), waxing (3003), wax
//! off (3004) and scraping (3005).
use super::Game;
use horizon_client::items::ItemStack;
use horizon_client::particles::{self, ParticleOptions, ParticleType};
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
@@ -134,6 +135,68 @@ impl Game<'_> {
// Each guest plays it from the global packet.
self.audio.play_unshared(sound, at, volume, 1.0);
}
/// `LevelEventHandler.potionSplashParticles` (2002 with `Effect`, 2007
/// with `InstantEffect`): eight splash potion crumbs, and a hundred
/// spell particles in the potion's colour `data`, flung out in a disc.
pub(super) fn potion_splash_particles(&mut self, pos: BlockPos, data: i32, instant: bool) {
let ty = if instant {
ParticleType::InstantEffect
} else {
ParticleType::Effect
};
// Vec3.atBottomCenterOf.
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y),
f64::from(pos.z) + 0.5,
];
let crumb = self.item_particle(&ItemStack::new("minecraft:splash_potion", 1));
let r = self.particles.level_random();
let crumbs: Vec<[f64; 3]> = (0..8)
.map(|_| {
[
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
]
})
.collect();
for v in crumbs {
self.particles.add(&crumb, at, v);
}
let channel = |shift: i32| ((data >> shift) & 0xFF) as f32 / 255.0;
let color = [channel(16), channel(8), channel(0)];
for _ in 0..100 {
let r = self.particles.level_random();
let dist = r.next_double() * 4.0;
let angle = r.next_double() * std::f64::consts::PI * 2.0;
let velocity = [
angle.cos() * dist,
0.01 + r.next_double() * 0.5,
angle.sin() * dist,
];
let brightness = 0.75 + r.next_float() * 0.25;
let options = ParticleOptions {
color: [
color[0] * brightness,
color[1] * brightness,
color[2] * brightness,
1.0,
],
scale: dist as f32,
..ParticleOptions::simple(ty)
};
self.particles.add(
&options,
[
at[0] + velocity[0] * 0.1,
at[1] + 0.3,
at[2] + velocity[2] * 0.1,
],
velocity,
);
}
}
/// Queue and play level event `id` at `pos` (`Level.levelEvent`).
pub(super) fn level_event_at(&mut self, id: i32, pos: [i32; 3], data: i32) {
self.level_events.push(LevelEvent::LevelEvent {
+1 -1
View File
@@ -75,7 +75,7 @@ pub(super) struct Parked {
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
lan_block_events: Vec<([i32; 3], i32, i32, u32)>,
lan_entity_events: Vec<(i32, i8, [f64; 3])>,
lan_critical_hits: Vec<(Option<u32>, i32)>,
lan_critical_hits: Vec<(Option<u32>, i32, bool)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
+73 -1
View File
@@ -1,6 +1,7 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items.
//! dropped block items. Also the block models mobs wear (`SnowGolemHeadLayer`,
//! `MushroomCowMushroomLayer`).
use super::Game;
use horizon_client::{
items::ItemStack,
@@ -218,6 +219,7 @@ impl Game<'_> {
) {
self.captain_banner_geometry(mob, camera, alpha, light, out);
self.held_item_geometry(mob, camera, alpha, light, true, out);
self.worn_block_geometry(mob, camera, alpha, light, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.block_face_sprite(state)
@@ -244,6 +246,76 @@ impl Game<'_> {
);
}
}
/// `SnowGolemHeadLayer` and `MushroomCowMushroomLayer`: the carved pumpkin
/// on a snow golem's head, and the mushrooms on an adult mooshroom's back
/// and head, as baked block models.
fn worn_block_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
let species = mob.species();
let on_head = match species {
Species::SnowGolem => !mob.extra.sheared,
Species::Mooshroom => !mob.is_baby(),
_ => false,
};
// The layers draw nothing on an invisible mob (the glow outline is not drawn).
if !on_head || horizon_client::mobs::trader::is_invisible(mob) {
return;
}
let registry = &self.pack.assets.registry;
let block = match species {
Species::SnowGolem => "minecraft:carved_pumpkin",
_ if mob.extra.variant == 1 => "minecraft:brown_mushroom",
_ => "minecraft:red_mushroom",
};
let Some(state) = registry.default_state(block) else {
return;
};
let alpha = alpha as f32;
let Some(head) = render::part_transform(mob, alpha, camera, self.res.mob_data(), "head")
else {
return;
};
let unit = Mat4::from_translation(Vec3::splat(-0.5));
let flip = Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0));
let at = |x: f32, y: f32, z: f32| Mat4::from_translation(Vec3::new(x, y, z));
let spin = |degrees: f32| Mat4::from_rotation_y(degrees.to_radians());
let transforms = if species == Species::SnowGolem {
vec![
head * at(0.0, -0.34375, 0.0)
* spin(180.0)
* Mat4::from_scale(Vec3::new(0.625, -0.625, -0.625))
* unit,
]
} else {
let base = render::base_transform(mob, alpha, camera);
vec![
base * at(0.2, -0.35, 0.5) * spin(-48.0) * flip * unit,
base * at(0.2, -0.35, 0.5)
* spin(42.0)
* at(0.1, 0.0, -0.6)
* spin(-48.0)
* flip
* unit,
head * at(0.0, -0.7, -0.2) * spin(-78.0) * flip * unit,
]
};
// The hurt tint reaches these blocks as it does the body.
let overlay = horizon_client::overlay::living(0.0, mob.hurt_time > 0 || mob.death_time > 0);
for t in transforms {
let start = out.len();
self.block_model_vertices(state, 0, t, light, true, out);
for vertex in &mut out[start..] {
vertex.layer = overlay;
}
}
}
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
+57 -52
View File
@@ -12,6 +12,7 @@ use horizon_client::{
hud_layout, hud_overlays, inventory_effects,
items::ItemStack,
mesher::Vertex,
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -26,6 +27,9 @@ pub(super) struct ThrownPotion {
position: Vec3,
velocity: Vec3,
age: u32,
/// `Projectile.getOwner`'s display name: the player who threw it (none
/// for a dispenser's).
owner: Option<Text>,
}
/// A mob's effect ids carry the `minecraft:` namespace.
fn mob_effect_id(id: &str) -> Option<&'static str> {
@@ -47,7 +51,13 @@ fn mob_effect_id(id: &str) -> Option<&'static str> {
impl Game<'_> {
/// A splash potion, lingering potion or experience bottle in flight (the
/// player's throws and dispensers both end up here).
pub(super) fn launch_thrown(&mut self, stack: ItemStack, position: Vec3, velocity: Vec3) {
pub(super) fn launch_thrown(
&mut self,
stack: ItemStack,
position: Vec3,
velocity: Vec3,
owner: Option<Text>,
) {
self.potions.thrown.push(ThrownPotion {
id: 0,
stack,
@@ -55,6 +65,7 @@ impl Game<'_> {
position,
velocity,
age: 0,
owner,
});
}
}
@@ -135,6 +146,7 @@ impl PotionState {
position: at,
velocity,
age: 0,
owner: None,
});
}
/// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it shows.
@@ -237,6 +249,7 @@ impl Game<'_> {
},
Vec3::new(eye[0], eye[1] - 0.1, eye[2]),
v,
Some(self.player_display_text()),
);
self.swing_arm();
self.consume_held();
@@ -548,15 +561,15 @@ impl Game<'_> {
}
}
if let Some(p) = hit {
landed.push((t.stack.clone(), p));
landed.push((t.stack.clone(), p, t.owner.clone()));
return false;
}
t.position = next;
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
for (stack, at) in landed {
self.splash(&stack, at);
for (stack, at, owner) in landed {
self.splash(&stack, at, owner);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
@@ -664,6 +677,7 @@ impl Game<'_> {
effects: &'static [(&'static str, i32, i32)],
at: Vec3,
scale: f64,
source: &Text,
) {
if let Some(p) = self.player.as_ref() {
let center = Vec3::new(
@@ -679,6 +693,18 @@ impl Game<'_> {
let instant = brewing::effect(id).is_some_and(|e| e.instant);
if instant {
if let Some(a) = horizon_client::effects::instant(id, *amp, intensity) {
// HealOrHarmMobEffect.applyInstantaneousEffect with
// a source: indirectMagic, not plain magic.
let a = match a {
EffectAction::Hurt("minecraft:magic", amount) => {
self.note_direct_entity(
"minecraft:indirect_magic",
source.clone(),
);
EffectAction::Hurt("minecraft:indirect_magic", amount)
}
a => a,
};
self.apply_effect_action(a);
}
} else {
@@ -694,68 +720,47 @@ impl Game<'_> {
}
}
/// `ThrownSplashPotion.onHitAsPotion` (lingering clouds apply as a weaker splash).
fn splash(&mut self, stack: &ItemStack, at: Vec3) {
fn splash(&mut self, stack: &ItemStack, at: Vec3, owner: Option<Text>) {
let block = [at.x, at.y, at.z].map(|v| v.floor() as i32);
if &*stack.item == "minecraft:experience_bottle" {
// ThrownExperienceBottle.onHit: the water-coloured splash (level
// event 2002) and 3 to 11 experience.
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(
"minecraft:entity.splash_potion.break",
Some([at.x, at.y, at.z]),
1.0,
pitch,
);
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
0x38_5DC6,
);
self.particles.push(p);
}
// event 2002, its sound 1053) and 3 to 11 experience.
self.level_event_at(2002, block, 0xFF38_5DC6u32 as i32);
self.level_event_at(1053, block, 0);
let r = &mut self.world.random;
let value = 3 + r.next_int_bound(5).unwrap_or(0) + r.next_int_bound(5).unwrap_or(0);
self.spawn_orbs([at.x, at.y, at.z], value as u32);
return;
}
let lingering = &*stack.item == "minecraft:lingering_potion";
let event = if lingering {
"minecraft:entity.lingering_potion.break"
} else {
"minecraft:entity.splash_potion.break"
};
let pitch = self.world.random.next_float() * 0.1 + 0.9;
self.audio.play(event, Some([at.x, at.y, at.z]), 1.0, pitch);
// AbstractThrownPotion.onHit: level event 2007 and sound 1054 for a
// potion with instant effects, 2002 and 1053 otherwise.
let potion = stack.potion().and_then(brewing::potion);
let instant = potion.is_some_and(|d| {
d.effects
.iter()
.any(|(id, ..)| brewing::effect(id).is_some_and(|e| e.instant))
});
let color = brewing::potion_color(stack.potion());
for _ in 0..8 {
let v = [
f64::from(self.world.random.next_float() - 0.5) * 0.3,
0.2,
f64::from(self.world.random.next_float() - 0.5) * 0.3,
];
let p = horizon_client::particles::Particle::terrain(
&mut self.world.random,
[at.x, at.y, at.z],
v,
"item/splash_potion".into(),
color,
);
self.particles.push(p);
}
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) = stack.potion().and_then(brewing::potion) else {
let Some(def) = potion else {
return;
};
// Every player in range (`ThrownSplashPotion.applySplash` over
// `LivingEntity`s, the players among them).
self.for_each_player(|g| g.splash_on_player(def.effects, at, scale));
// Instant damage is indirectMagic(potion 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));
// Mobs take the instant effects (undead invert healing and harming).
let undead = &self.res.stations().enchantments.entity_tags;
let mut hits = Vec::new();
+64 -26
View File
@@ -1560,7 +1560,7 @@ impl Game<'_> {
}
}
/// Find a game slot in the menu's complete, contiguous slot mapping.
fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
pub(super) fn menu_slot_index(menu: &str, internal: usize) -> Option<usize> {
(0..)
.map_while(|slot| Self::menu_slot(menu, slot))
.position(|slot| slot == internal)
@@ -1741,7 +1741,17 @@ impl Game<'_> {
f64::from(p.y) / 8.0,
f64::from(p.z) / 8.0,
];
self.audio.play(&name, Some(at), p.volume, p.pitch);
// ClientPacketListener.handleSoundEvent plays with packet.getSource().
match usize::try_from(p.source)
.ok()
.filter(|s| *s < horizon_client::settings::SOUND_SOURCES.len())
{
Some(source) => {
self.audio
.play_on(source, &name, Some(at), p.volume, p.pitch)
}
None => self.audio.play(&name, Some(at), p.volume, p.pitch),
}
}
}
C::StopSound(p) => self
@@ -1975,9 +1985,9 @@ impl Game<'_> {
}
}
}
// Entity.DATA_SHARED_FLAGS_ID's crouching bit: another
// player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag).
// Entity.DATA_SHARED_FLAGS_ID's crouching and sprinting bits:
// another player's or a mannequin's crouch (their model and
// Entity.isDiscrete's name tag), and the dust a runner raises.
if (mannequin
|| self
.remote
@@ -1988,6 +1998,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
m.extra.crouching = flags & 2 != 0;
m.extra.sprinting = flags & 8 != 0;
}
}
}
@@ -2136,9 +2147,33 @@ impl Game<'_> {
{
m.health = 0.0;
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
}
if p.event == super::totem::TOTEM_EVENT {
self.remote_totem(p.id);
}
// Entity event 53: the honey slide particles of a player
// sliding down a honey block (HoneyBlock.showSlideParticles).
if p.event == 53 {
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.player
.as_ref()
.map(|q| [q.position.x, q.position.y, q.position.z])
} else {
self.mobs
.iter()
.find(|m| m.id == p.id as u32)
.map(|m| [m.position.x, m.position.y, m.position.z])
};
if let Some(at) = at {
self.honey_slide_particles(at);
}
}
}
C::Animate(p) => self.remote_animate(p.id, p.action),
// ClientPacketListener.handleSwingAnimation: the entity swings
@@ -3142,6 +3177,8 @@ impl Game<'_> {
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Leaving a slot clears its bundle's selection here and on the server.
self.tick_bundle_hover();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
@@ -3332,6 +3369,13 @@ impl Game<'_> {
horizon_client::motion::MotionEvent::InsidePortal { .. } => {
self.client_inside_nether_portal();
}
// HoneyBlock.maybeDoSlideEffects on the client's own level:
// the slide sound, heard here (LocalPlayer.playSound); the
// particles come from the server's event 53.
horizon_client::motion::MotionEvent::HoneySlide { sound: true, .. } => {
self.audio
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
}
_ => {}
}
}
@@ -3508,6 +3552,16 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::base_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Horse
| horizon_client::mobs::Species::Donkey
| horizon_client::mobs::Species::Mule
| horizon_client::mobs::Species::SkeletonHorse
| horizon_client::mobs::Species::ZombieHorse
) {
horizon_client::mobs::equine::tail_tick(m);
}
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
@@ -3518,6 +3572,10 @@ impl Game<'_> {
) {
horizon_client::mobs::guardian::client_tick(m);
}
// PolarBear.tick: the stand animation follows the synched flag.
if m.species() == horizon_client::mobs::Species::PolarBear {
horizon_client::mobs::polar_bear::client_tick(m);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -4703,7 +4761,7 @@ impl Game<'_> {
}
/// The server's open menu: its window id and menu type (0, "inventory"
/// when none is open).
fn remote_menu(&self) -> (i32, String) {
pub(super) fn remote_menu(&self) -> (i32, String) {
match self.remote.as_ref().and_then(|r| r.menu.clone()) {
Some((id, name)) => (id, name),
None => (0, "inventory".to_owned()),
@@ -4793,26 +4851,6 @@ impl Game<'_> {
}));
}
}
/// A `ContainerClick` on game slot `internal` of the server's open menu
/// (`input` is the ContainerInput ordinal). False when the menu has no
/// such slot.
pub(super) fn remote_slot_click(&mut self, internal: usize, button: i8, input: i32) -> bool {
let (container, menu) = self.remote_menu();
let slot = Self::menu_slot_index(&menu, internal);
let Some(slot) = slot else { return false };
let carried = self.to_network(&self.carried).map(|s| Hashed::of(&s));
let state = self.remote.as_ref().map_or(0, |r| r.state_id);
self.send(S::ContainerClick(play::ContainerClick {
container,
state,
slot: slot as i16,
button,
input,
changed: Vec::new(),
carried,
}));
true
}
/// The server's open menu is the inventory or a chest-like container
/// (chests, barrels, ender chests: `generic_9xN`; shulker boxes).
pub(super) fn remote_storage_open(&self) -> bool {
@@ -5,7 +5,8 @@
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_protocol::entity_data::Value;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_protocol::entity_data::{Particle, ParticleData, Value};
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
@@ -34,6 +35,10 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
// animation follow it.
(9, Value::Float(health)) => m.health = *health,
// LivingEntity.DATA_EFFECT_PARTICLES and DATA_EFFECT_AMBIENCE_ID:
// the swirls of its effects (`tickEffects` draws them).
(10, Value::Particles(list)) => m.extra.effect_particles = swirls(list, builtin),
(11, Value::Bool(ambient)) => m.extra.effect_ambient = *ambient,
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2, aggressive 4.
// Mob.isAggressive draws the illagers' and skeletons' attack
// poses and the vindicator's axe.
@@ -59,6 +64,8 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
Who::Mob(*target as u32)
});
}
// PolarBear.DATA_STANDING_ID: the client animates the rearing.
(Species::PolarBear, 18, Value::Bool(standing)) => m.extra.flag = *standing,
// Bat.DATA_ID_FLAGS: resting in bit 0.
(Species::Bat, 16, Value::Byte(flags)) => bat::set_resting(m, flags & 1 != 0),
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
@@ -66,6 +73,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.color = (*wool & 15) as u8;
m.extra.sheared = wool & 16 != 0;
}
// SnowGolem.DATA_PUMPKIN_ID: 16 while the pumpkin is on.
(Species::SnowGolem, 16, Value::Byte(flags)) => m.extra.sheared = flags & 16 == 0,
// MushroomCow.DATA_TYPE: 0 red, 1 brown.
(Species::Mooshroom, 17, Value::Int(variant)) => {
m.extra.variant = (*variant == 1) as u8
}
// Piglin.DATA_IS_DANCING: the dance pose.
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
m.extra.sitting = flags & 1 != 0;
@@ -100,6 +115,30 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
}
}
/// The options of a synched particle list: an entity effect carries its ARGB
/// colour, the omens none.
fn swirls(list: &[Particle], builtin: &Builtin) -> Vec<ParticleOptions> {
let channel = |argb: i32, shift: u32| ((argb >> shift) & 255) as f32 / 255.0;
list.iter()
.filter_map(|p| {
let name = builtin.get("minecraft:particle_type")?.name(p.id)?;
let ty = ParticleType::from_name(name)?;
Some(match p.data {
Some(ParticleData::Color(argb)) => ParticleOptions::colored(
ty,
[
channel(argb, 16),
channel(argb, 8),
channel(argb, 0),
channel(argb, 24),
],
),
_ => ParticleOptions::simple(ty),
})
})
.collect()
}
/// VillagerData as the network sends it: the type and the profession by
/// their built-in registry ids, then the level.
fn villager_data(m: &mut Mob, data: &[i32], builtin: &Builtin) {
@@ -162,6 +162,10 @@ impl Game<'_> {
let Some(k) = projectile_kind(kind) else {
return false;
};
if k == ProjectileKind::LlamaSpit {
self.particles
.llama_spit_burst(position, [velocity.x, velocity.y, velocity.z]);
}
let mut p = Projectile::new(k, at, None);
p.id = id;
p.velocity = velocity;
@@ -7,6 +7,7 @@ use horizon_client::{
biome::Biomes,
blocks::{BlockFacts, Fluid},
chunk::{HEIGHT, MIN_Y},
containers::BlockEntity,
particles::{ParticleLevel, ParticleOptions, ParticleType},
world::World,
};
@@ -344,6 +345,10 @@ impl Game<'_> {
&mut self.particles,
&ctx,
r.block_entities(),
&|pos| match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Campfire(c)) => c.items.each_ref().map(Option::is_some),
_ => [false; 4],
},
);
}
let level = ParticleLevel {
+10 -2
View File
@@ -132,8 +132,16 @@ impl Game<'_> {
match t {
Target::Player => Some(self.acting_player_name().to_owned()),
Target::Guest(conn) => self.command_guest(conn).map(|g| g.name.clone()),
Target::Mob(id) | Target::Item(id) => Some(uuid_text(self.uuid_for(id))),
_ => None,
Target::Mob(id)
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles are addressed by index and have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
}
}
fn holder_for(&mut self, t: Target) -> Option<Holder> {
+220 -55
View File
@@ -277,6 +277,10 @@ fn argb(c: i32) -> u32 {
)
}
/// A text style's `0xRRGGBB` colour from a HUD colour (`rgba`).
fn rgb_of(rgba: u32) -> u32 {
(rgba & 0xFF) << 16 | (rgba >> 8 & 0xFF) << 8 | (rgba >> 16 & 0xFF)
}
impl Game<'_> {
/// `Component.translatable`: the selected language (which `lang` layers
/// over `en_us`), the caller's English text last.
@@ -1690,20 +1694,34 @@ impl Game<'_> {
&self,
stack: &ItemStack,
) -> Vec<(horizon_client::text::Text, u32)> {
let e: &Enchantments = &self.res.stations().enchantments;
let props = e.props(&stack.item);
self.stack_tooltip_lines(stack, false)
}
/// `ItemStack.getRarity`'s colour for the name line and a `[name]` link.
fn rarity_color(&self, stack: &ItemStack) -> u32 {
let props = self.res.stations().enchantments.props(&stack.item);
let rarity = horizon_client::item_tooltip::rarity(
stack,
props.map_or("common", |p| p.rarity.as_str()),
);
let color = match rarity {
match rarity {
"uncommon" => rgba(255, 255, 85, 255),
"rare" => rgba(85, 255, 255, 255),
"epic" => rgba(255, 85, 255, 255),
_ => u32::MAX,
};
let name = if let Some(n) = stack.custom_name() {
}
}
/// `ItemStack.getHoverName`: the custom name, a written book's title, the
/// item name component, else the item's or potion's name.
fn stack_hover_name(&self, stack: &ItemStack) -> String {
if let Some(n) = stack.custom_name() {
n.to_owned()
} else if let Some(title) = stack
.data()
.and_then(|d| d.written.as_ref())
.map(|w| w.title.as_str())
.filter(|t| !t.trim().is_empty())
{
title.to_owned()
} else if let Some(n) = stack.item_name() {
n
} else if stack.data().is_some_and(|d| d.ominous_banner) {
@@ -1719,16 +1737,47 @@ impl Game<'_> {
.unwrap_or_else(|| self.pack.names.display_item(&stack.item))
} else {
self.pack.names.display_item(&stack.item)
};
}
}
/// `ItemStack.getTooltipLines`. A `nested` stack is a crossbow's
/// projectile: `ChargedProjectiles.addProjectileTooltip` takes its details
/// without the name, the player and the advanced flag.
fn stack_tooltip_lines(
&self,
stack: &ItemStack,
nested: bool,
) -> Vec<(horizon_client::text::Text, u32)> {
let e: &Enchantments = &self.res.stations().enchantments;
let color = self.rarity_color(stack);
let advanced = !nested && self.options_advanced_tooltips();
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
let mut lines = Vec::new();
if display.name_visible(self.screen == Screen::Creative) {
lines.push((horizon_client::text::Text::legacy(&name), color));
if !nested && display.name_visible(self.screen == Screen::Creative) {
// `getStyledHoverName`: a custom name is italic.
let name = self.stack_hover_name(stack);
let name = if stack.custom_name().is_some() {
horizon_client::text::Text::literal(name).italic()
} else {
horizon_client::text::Text::legacy(&name)
};
lines.push((name, color));
}
let translate = |k: &str| self.pack.names.display_get(k);
let push = |lines: &mut Vec<(horizon_client::text::Text, u32)>, s: String, c: u32| {
lines.push((horizon_client::text::Text::legacy(&s), c));
};
let gray = rgba(170, 170, 170, 255);
let item_info = self.res.items().get(&stack.item);
// InstrumentComponent.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::instrument_line(stack, &display, &translate)
{
push(&mut lines, text, gray);
}
// Bees.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::bees_line(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
@@ -1743,6 +1792,70 @@ impl Game<'_> {
push(&mut lines, text, u32::MAX);
}
}
// BannerPatternLayers.addToTooltip: up to six layer names.
if display.shows("minecraft:banner_patterns") {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations().banners.layer_key(layer);
push(
&mut lines,
self.translate(&key, &key),
rgba(170, 170, 170, 255),
);
}
}
// WrittenBookContent.addToTooltip.
for text in horizon_client::item_tooltip::written_book_lines(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// ChargedProjectiles.addToTooltip: each run of equal projectiles, then
// the projectile's own details indented under it.
if display.shows("minecraft:charged_projectiles") {
let charged = stack.data().map_or(&[][..], |d| &d.charged[..]);
for run in charged.chunk_by(|a, b| a.same_item_same_components(b)) {
let projectile = &run[0];
let name = self.stack_hover_name(projectile);
let line = if run.len() == 1 {
// `getDisplayName`: `[name]` in the rarity colour.
let mut shown = horizon_client::text::Text::literal(format!("[{name}]"))
.with_color(rgb_of(self.rarity_color(projectile)));
if projectile.custom_name().is_some() {
shown = shown.italic();
}
horizon_client::text::format_translation(
&self.translate(
"item.minecraft.crossbow.projectile.single",
"Projectile: %s",
),
&[shown],
)
} else {
horizon_client::text::format_translation(
&self.translate(
"item.minecraft.crossbow.projectile.multiple",
"Projectile: %s x %s",
),
&[
horizon_client::text::Text::literal(run.len().to_string()),
horizon_client::text::Text::literal(name),
],
)
};
lines.push((line, u32::MAX));
for (text, c) in self.stack_tooltip_lines(projectile, true) {
// `literal(" ").append(line).withStyle(GRAY)`: the line's
// own colours win over the gray.
let rgb = if c == u32::MAX {
horizon_client::text::GRAY
} else {
rgb_of(c)
};
let indented = horizon_client::text::Text::literal(" ")
.append(text)
.with_color(rgb);
lines.push((indented, u32::MAX));
}
}
}
// Fireworks / FireworkExplosion.addToTooltip.
for text in horizon_client::item_tooltip::firework_lines(stack, &translate) {
push(&mut lines, text, rgba(170, 170, 170, 255));
@@ -1765,6 +1878,17 @@ impl Game<'_> {
};
push(&mut lines, text, c);
}
// The attribute modifiers of the effects (`When Applied:`).
for (text, c) in horizon_client::item_tooltip::potion_modifier_lines(stack, &translate)
{
push(&mut lines, text, c);
}
}
// JukeboxPlayable.addToTooltip.
if let Some(text) =
horizon_client::item_tooltip::jukebox_line(stack, item_info, &display, &translate)
{
push(&mut lines, text, gray);
}
if display.shows("minecraft:trim")
&& let Some((material, pattern)) = stack.data().and_then(|d| d.trim.clone())
@@ -1812,16 +1936,12 @@ impl Game<'_> {
);
}
}
// BannerPatternLayers.addToTooltip: up to six layer names.
if display.shows("minecraft:banner_patterns") {
for layer in horizon_client::banner::tooltip_layers(stack).iter().take(6) {
let key = self.res.stations().banners.layer_key(layer);
push(
&mut lines,
self.translate(&key, &key),
rgba(170, 170, 170, 255),
);
}
// DyedItemColor.addToTooltip.
if let Some((text, italic)) =
horizon_client::item_tooltip::dyed_line(stack, &display, advanced, &translate)
{
let text = horizon_client::text::Text::legacy(&text);
lines.push((if italic { text.italic() } else { text }, gray));
}
// ItemLore.addToTooltip: after the enchantments, before the attributes
// (ItemStack.addDetailsToTooltip).
@@ -1830,6 +1950,50 @@ impl Game<'_> {
lines.push((text, u32::MAX));
}
}
if display.shows("minecraft:attribute_modifiers") {
// The item's own modifiers, then its enchantments' for each slot
// group (`ItemStack.forEachModifier`).
let mut modifiers = item_info.map(|i| i.modifiers.clone()).unwrap_or_default();
modifiers.extend(e.modifier_rows(stack));
let base = |name: &str, default| {
self.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key(name)?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(default)
};
let player_base = (!nested).then(|| {
[
base("minecraft:attack_damage", 1.0),
base(
"minecraft:attack_speed",
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
]
});
for (text, c) in
horizon_client::item_tooltip::attribute_lines(&modifiers, player_base, &translate)
{
push(&mut lines, text, c);
}
}
// ItemStack.UNBREAKABLE_TOOLTIP.
if let Some(text) =
horizon_client::item_tooltip::unbreakable_line(stack, &display, &translate)
{
push(&mut lines, text, rgba(85, 85, 255, 255));
}
// BlockItemStateProperties.addToTooltip.
if let Some(text) = horizon_client::item_tooltip::honey_line(stack, &display, &translate) {
push(&mut lines, text, gray);
}
// Spawner.appendHoverText: what a spawner or trial spawner item spawns.
if display.shows("minecraft:block_entity_data")
&& matches!(
@@ -1853,43 +2017,11 @@ impl Game<'_> {
}
}
}
if display.shows("minecraft:attribute_modifiers")
&& let Some(info) = self.res.items().get(&stack.item)
{
let base = |name: &str, default| {
self.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key(name)?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(default)
};
let player_base = [
base("minecraft:attack_damage", 1.0),
base(
"minecraft:attack_speed",
horizon_client::attack_strength::BASE_ATTACK_SPEED,
),
];
for (text, c) in horizon_client::item_tooltip::attribute_lines(
&info.modifiers,
Some(player_base),
&translate,
) {
push(&mut lines, text, c);
}
}
if display.shows("minecraft:damage")
&& let Some(info) = self.res.items().get(&stack.item)
&& let Some(info) = item_info
&& info.max_damage > 0
&& stack.damage > 0
&& self.options_advanced_tooltips()
&& advanced
{
push(
&mut lines,
@@ -1901,8 +2033,23 @@ impl Game<'_> {
u32::MAX,
);
}
// ItemStack.addDetailsToTooltipTail: with advanced tooltips the item id
// and how many components the stack has.
if advanced {
let dark_gray = rgba(85, 85, 85, 255);
push(&mut lines, stack.item.to_string(), dark_gray);
let count = horizon_client::item_tooltip::component_count(stack, item_info);
if count > 0 {
let text = self.translate("item.components", "%s component(s)");
push(
&mut lines,
text.replacen("%s", &count.to_string(), 1),
dark_gray,
);
}
}
// ItemStack.OP_NBT_WARNING, for a player with game master permission.
if self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
if !nested && self.ui.registries.permission >= 2 && block_entity_item::op_warning(stack) {
for (key, fallback) in [
("item.op_warning.line1", "Warning:"),
(
@@ -2105,12 +2252,18 @@ impl Game<'_> {
batch: &mut HudBatch,
layout: &horizon_client::bundle::TooltipLayout,
) {
for (at, stack) in &layout.slots {
for (i, (at, stack)) in layout.slots.iter().enumerate() {
let selected = layout.selected == Some(i);
let sprite = |name: &str| format!("gui/sprites/container/bundle/{name}");
// Each draw takes the atlas on its own: the item's needs `&mut self`.
horizon_client::ui::nine_slice(
batch,
&self.gui.atlas,
"gui/sprites/container/bundle/slot_background",
&sprite(if selected {
"slot_highlight_back"
} else {
"slot_background"
}),
at[0],
at[1],
24.0,
@@ -2120,6 +2273,18 @@ impl Game<'_> {
let stack = stack.clone();
let [x, y] = *at;
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
if selected {
horizon_client::ui::nine_slice(
batch,
&self.gui.atlas,
&sprite("slot_highlight_front"),
x,
y,
24.0,
24.0,
u32::MAX,
);
}
}
let atlas = &self.gui.atlas;
if let Some((at, hidden)) = layout.overflow {
+22
View File
@@ -21,6 +21,22 @@ impl Game<'_> {
draws.extend(self.titles.title(w, h, partial, font, background));
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// `Hud.extractSavingIndicator`, under the screens: the option, then the
/// fading "Saving world" text.
pub(super) fn draw_saving_indicator(&self, batch: &mut HudBatch) {
if !self.settings.autosave_indicator {
return;
}
let draws = self.saving_indicator.draw(
batch.width as i32,
batch.height as i32,
self.clock.fraction() as f32,
&self.gui.atlas.font,
Text::literal(self.translate("menu.savingLevel", "Saving world")),
self.settings.text_background(0.0),
);
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
/// The selected stack as `Hud.tick` compares it for the name highlight.
fn held_name(&self) -> Option<HeldName> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
@@ -44,6 +60,12 @@ impl Game<'_> {
/// The title, action bar and selected-item parts of `Hud.tick`.
pub(super) fn tick_hud(&mut self) {
self.titles.tick();
// `MinecraftServer.isCurrentlySaving` of single player's server.
let saving = self
.integrated
.as_ref()
.map_or(self.autosaving, |server| server.is_saving());
self.saving_indicator.tick(saving);
let held = self.held_name();
self.tool_highlight
.tick(held, f64::from(self.settings.notification_display_time));
+1 -2
View File
@@ -70,8 +70,7 @@ impl Game<'_> {
/// One ordinary click on a game slot. False when nothing took it.
pub(super) fn tweak_click(&mut self, slot: usize, button: i32, input: Input) -> bool {
if self.remote.is_some() {
let ordinal = super::remote::input_ordinal(input);
return self.remote_slot_click(slot, button as i8, ordinal);
return self.remote_menu_ops(&[(Some(slot), button, input)]);
}
if !self.menu_click(Some(slot), button, input) {
return false;
+47
View File
@@ -191,6 +191,53 @@ pub fn effects(w: i32, demo: bool, effects: &[EffectIcon]) -> Vec<GuiDraw> {
out
}
/// The saving indicator: `Hud.tickAutosaveIndicator` fades a value towards 1
/// while the server is saving and `extractSavingIndicator` draws the text.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct SavingIndicator {
last: f32,
value: f32,
}
impl SavingIndicator {
/// `Hud.tickAutosaveIndicator`: `Mth.lerp(0.2F, value, saving ? 1 : 0)`.
pub fn tick(&mut self, saving: bool) {
self.last = self.value;
self.value += (if saving { 1.0 } else { 0.0 } - self.value) * 0.2;
}
/// `Hud.extractSavingIndicator` without the option check: the text in the
/// bottom right corner.
pub fn draw(
&self,
w: i32,
h: i32,
partial: f32,
font: &Font,
label: Text,
background: u32,
) -> Vec<GuiDraw> {
let mut out = Vec::new();
if self.value <= 0.0 && self.last <= 0.0 {
return out;
}
let fade = (self.last + (self.value - self.last) * partial).clamp(0.0, 1.0);
let alpha = (255.0 * fade).floor() as u32;
if alpha > 0 {
let label_width = width(font, &label);
let color = crate::hud_layout::argb(alpha, 255, 255, 255);
text_with_backdrop(
&mut out,
label,
w - label_width - 5,
h - 9 - 5,
label_width,
color,
background,
);
}
out
}
}
/// Titles and the action bar: `Hud.title`, `subtitle`, `titleTime`, the
/// fade times and `overlayMessageString` / `overlayMessageTime`.
#[derive(Clone, Debug, PartialEq)]
+17
View File
@@ -52,6 +52,8 @@ pub struct StackData {
pub pot_decorations: Option<Box<[Option<ItemStack>; 4]>>,
/// `minecraft:bundle_contents`.
pub bundle_contents: Vec<ItemStack>,
/// `BundleContents.selectedItem`: runtime only, never saved or sent.
pub bundle_selected: BundleSelected,
/// `minecraft:suspicious_stew_effects`: (effect id, duration).
pub stew_effects: Vec<(Arc<str>, i32)>,
/// `minecraft:map_post_processing`: 0 lock, 1 scale.
@@ -60,6 +62,20 @@ pub struct StackData {
/// max stack size...), kept as their saved values in key order.
pub other: Vec<(String, Tag)>,
}
/// A bundle's selected item index (-1: none). Like vanilla's
/// `BundleContents.equals` it is left out when stacks are compared.
#[derive(Clone, Copy, Debug)]
pub struct BundleSelected(pub i32);
impl Default for BundleSelected {
fn default() -> Self {
Self(-1)
}
}
impl PartialEq for BundleSelected {
fn eq(&self, _: &Self) -> bool {
true
}
}
/// The components `components_tag` writes from modelled fields.
const MODELLED: &[&str] = &[
"minecraft:banner_patterns",
@@ -791,6 +807,7 @@ pub fn decode_components(components: Option<&Tag>) -> (u32, Option<Box<StackData
Arc::from(t.get("pattern").and_then(string)?.as_str()),
))
}),
bundle_selected: BundleSelected::default(),
other: Vec::new(),
};
let mut data = data;
+1
View File
@@ -653,6 +653,7 @@ pub fn tag_json(tag: &Tag) -> Value {
/// The resolver's view of a client stack: its item, count and component patch.
pub fn stack_view(stack: &crate::items::ItemStack) -> StackView {
let mut view = StackView::new(&stack.item, stack.count as i32);
view.bundle_selected = stack.data().map_or(-1, |d| d.bundle_selected.0);
if let Some(Tag::Compound(c)) = crate::item_data::components_tag(stack.damage, stack.data()) {
for (k, v) in &c {
let key = k.to_string_lossy();
+266 -45
View File
@@ -108,6 +108,32 @@ fn modifier_line(
)
}
/// `Attribute.getStyle`: the colour of a line for an attribute whose value
/// rises or falls. Gravity, scale and the waypoint ranges are neutral, burning
/// time and fall damage are negative, every other attribute is positive.
fn attribute_color(attribute: &str, rises: bool) -> u32 {
let (blue, red) = (rgba(85, 85, 255, 255), rgba(255, 85, 85, 255));
match attribute {
"burning_time" | "fall_damage_multiplier" => {
if rises {
red
} else {
blue
}
}
"gravity" | "scale" | "waypoint_transmit_range" | "waypoint_receive_range" => {
rgba(170, 170, 170, 255)
}
_ => {
if rises {
blue
} else {
red
}
}
}
}
/// `ItemStack.addAttributeTooltips`: slot groups, colors and optional player
/// attack damage/speed bases. Recipe search has no player context.
pub fn attribute_lines(
@@ -150,10 +176,8 @@ pub fn attribute_lines(
) {
let (line, color) = if base.is_some() {
(format!(" {line}"), rgba(0, 170, 0, 255))
} else if *amount > 0.0 {
(line, rgba(85, 85, 255, 255))
} else {
(line, rgba(255, 85, 85, 255))
(line, attribute_color(path, *amount > 0.0))
};
lines.push((line, color));
}
@@ -231,6 +255,235 @@ pub fn rarity<'a>(stack: &ItemStack, default: &'a str) -> &'a str {
}
}
fn is_beehive(path: &str) -> bool {
matches!(path, "beehive" | "bee_nest")
}
/// `InstrumentComponent.addToTooltip`: the instrument's description. A goat
/// horn without the component has the item's default, the ponder horn.
pub fn instrument_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:instrument") {
return None;
}
let id = match stack.data().and_then(|d| d.other("minecraft:instrument")) {
Some(Tag::String(id)) => id.to_string_lossy(),
// An inline instrument carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None if &*stack.item == "minecraft:goat_horn" => "minecraft:ponder_goat_horn".to_owned(),
None => return None,
};
let key = format!("instrument.{}", id.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `Bees.addToTooltip`: how many bees a hive item holds (a hive item without
/// the component has the item's default, none).
pub fn bees_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:bees") {
return None;
}
let bees = match stack.data().and_then(|d| d.other("minecraft:bees")) {
Some(Tag::List(bees)) => bees.len(),
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.bees").unwrap_or_else(|| "container.beehive.bees".into());
Some(
text.replacen("%s", &bees.to_string(), 1)
.replacen("%s", "3", 1),
)
}
/// `BlockItemStateProperties.addToTooltip`: a hive item's honey level (a hive
/// item without the component has the item's default, 0).
pub fn honey_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:block_state") {
return None;
}
let level = match stack.data().and_then(|d| d.other("minecraft:block_state")) {
Some(Tag::Compound(state)) => {
match state.get(&horizon_world::nbt::JavaString::from("honey_level")) {
Some(Tag::String(level)) => level.to_string_lossy().parse::<u32>().ok()?,
_ => return None,
}
}
Some(_) => return None,
None if is_beehive(stack.item.trim_start_matches("minecraft:")) => 0,
None => return None,
};
let text =
translate("container.beehive.honey").unwrap_or_else(|| "container.beehive.honey".into());
Some(
text.replacen("%s", &level.to_string(), 1)
.replacen("%s", "5", 1),
)
}
/// `WrittenBookContent.addToTooltip`: "by <author>" and the generation.
pub fn written_book_lines(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<String> {
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
let Some(book) = stack
.data()
.and_then(|d| d.written.as_ref())
.filter(|_| display.shows("minecraft:written_book_content"))
else {
return Vec::new();
};
let mut lines = Vec::new();
if !book.author.trim().is_empty() {
lines.push(
crate::text::format_translation(
&text("book.byAuthor"),
&[crate::text::Text::literal(&book.author)],
)
.plain(),
);
}
lines.push(text(&format!("book.generation.{}", book.generation)));
lines
}
/// `JukeboxPlayable.addToTooltip`: the song's description.
pub fn jukebox_line(
stack: &ItemStack,
info: Option<&ItemInfo>,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
if !display.shows("minecraft:jukebox_playable") {
return None;
}
let song = match stack
.data()
.and_then(|d| d.other("minecraft:jukebox_playable"))
{
Some(Tag::String(song)) => song.to_string_lossy(),
// An inline song carries its own description.
Some(inline) => {
return inline
.get("description")
.map(|d| crate::entity_nbt::name_text(d).plain());
}
None => info?.jukebox_song.clone()?,
};
let key = format!("jukebox_song.{}", song.replacen(':', ".", 1));
Some(translate(&key).unwrap_or(key))
}
/// `DyedItemColor.addToTooltip`: "Dyed" (italic, the flag), or with advanced
/// tooltips the colour's hex code.
pub fn dyed_line(
stack: &ItemStack,
display: &TooltipDisplay,
advanced: bool,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<(String, bool)> {
let rgb = stack.data()?.dyed_color?;
if !display.shows("minecraft:dyed_color") {
return None;
}
let text = |key: &str| translate(key).unwrap_or_else(|| key.to_owned());
Some(if advanced {
let hex = format!("#{:06X}", rgb & 0xFF_FFFF);
(text("item.color").replacen("%s", &hex, 1), false)
} else {
(text("item.dyed"), true)
})
}
/// `ItemStack.UNBREAKABLE_TOOLTIP`.
pub fn unbreakable_line(
stack: &ItemStack,
display: &TooltipDisplay,
translate: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
(stack.data()?.other("minecraft:unbreakable").is_some()
&& display.shows("minecraft:unbreakable"))
.then(|| translate("item.unbreakable").unwrap_or_else(|| "item.unbreakable".into()))
}
/// `PotionContents.addPotionTooltip`'s attribute block: an empty line, "When
/// Applied:" and the modifiers of the potion's effects (blue for a gain, red
/// for a loss); nothing for a potion whose effects add none.
pub fn potion_modifier_lines(
stack: &ItemStack,
translate: &dyn Fn(&str) -> Option<String>,
) -> Vec<(String, u32)> {
if !is_potion_item(&stack.item) {
return Vec::new();
}
let applied: Vec<(String, u32)> = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
let amount = amount * (*amplifier + 1) as f64;
let line = modifier_line(translate, attribute, amount, operation, false)?;
let color = if amount > 0.0 {
rgba(85, 85, 255, 255)
} else {
rgba(255, 85, 85, 255)
};
Some((line, color))
})
.collect();
if applied.is_empty() {
return applied;
}
let mut lines = vec![
(String::new(), u32::MAX),
(
translate("potion.whenDrank").unwrap_or_else(|| "potion.whenDrank".into()),
rgba(170, 0, 170, 255),
),
];
lines.extend(applied);
lines
}
/// `ItemStack.components.size()`: the item's default components with the
/// stack's own changes applied.
pub fn component_count(stack: &ItemStack, info: Option<&ItemInfo>) -> usize {
let mut keys: std::collections::HashSet<String> = info
.map(|i| i.components.iter().cloned().collect())
.unwrap_or_default();
if let Some(Tag::Compound(patch)) = crate::item_data::components_tag(stack.damage, stack.data())
{
for key in patch.keys() {
let key = key.to_string_lossy();
match key.strip_prefix('!') {
Some(removed) => keys.remove(removed),
None => keys.insert(key),
};
}
}
keys.len()
}
/// The lines of `stack` below its name that its components give, in
/// `ItemStack.addDetailsToTooltip` order.
pub fn detail_lines(
@@ -265,19 +518,9 @@ pub fn detail_lines(
))
));
}
// BeehiveBlock: the block state components of the item.
if path == "beehive" {
lines.push(
text("container.beehive.bees")
.replacen("%s", "0", 1)
.replacen("%s", "3", 1),
);
lines.push(
text("container.beehive.honey")
.replacen("%s", "0", 1)
.replacen("%s", "5", 1),
);
}
let display = TooltipDisplay::for_stack(stack);
lines.extend(bees_line(stack, &display, translate));
lines.extend(honey_line(stack, &display, translate));
let data = stack.data();
// PotDecorations.addToTooltip.
if let Some(sherds) = data
@@ -303,32 +546,13 @@ pub fn detail_lines(
.into_iter()
.map(|l| l.0),
);
let applied: Vec<String> = stack
.potion()
.and_then(crate::brewing::potion)
.into_iter()
.flat_map(|p| p.effects.iter())
.filter_map(|(effect, _, amplifier)| {
let (attribute, amount, operation) = effect_modifier(effect)?;
modifier_line(
translate,
attribute,
amount * (*amplifier + 1) as f64,
operation,
false,
)
})
.collect();
if !applied.is_empty() {
lines.push(String::new());
lines.push(text("potion.whenDrank"));
lines.extend(applied);
}
}
// JukeboxPlayable.addToTooltip: the song's description.
if path.starts_with("music_disc_") {
lines.push(text(&format!("item.minecraft.{path}.desc")));
lines.extend(
potion_modifier_lines(stack, translate)
.into_iter()
.map(|l| l.0),
);
}
lines.extend(jukebox_line(stack, info, &display, translate));
// Trim.addToTooltip.
if let Some((material, pattern)) = data.and_then(|d| d.trim.as_ref()) {
let short = |s: &str| s.trim_start_matches("minecraft:").to_owned();
@@ -342,10 +566,7 @@ pub fn detail_lines(
text(&format!("trim_material.minecraft.{}", short(material)))
));
}
// DyedItemColor.addToTooltip (without advanced tooltips).
if data.is_some_and(|d| d.dyed_color.is_some()) {
lines.push(text("item.dyed"));
}
lines.extend(dyed_line(stack, &display, false, translate).map(|l| l.0));
if let Some(info) = info {
lines.extend(
attribute_lines(&info.modifiers, None, translate)
+6
View File
@@ -131,6 +131,8 @@ pub struct ItemInfo {
pub block_transformer: Option<String>,
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
pub bundle: bool,
/// The ids of the item's default components (its prototype's keys).
pub components: Vec<String>,
}
/// `Item.Properties.craftRemainder` (not a data component, so not in the
/// default-component report): what an item leaves in the grid when crafted with.
@@ -650,6 +652,10 @@ impl Items {
cushion_color: c["minecraft:cushion/color"].as_str().map(str::to_owned),
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
bundle: c.get("minecraft:bundle_contents").is_some(),
components: c
.as_object()
.map(|c| c.keys().cloned().collect())
.unwrap_or_default(),
crafting_remainder: CRAFT_REMAINDERS
.iter()
.find(|(item, _)| *item == name)
+66 -18
View File
@@ -36,6 +36,30 @@ impl Hub {
/// ClientboundSoundPacket for a sound event name (an inline event when
/// the registry does not know it).
pub fn sound_packet(
&self,
name: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
source: i32,
seed: i64,
) -> C {
let sound = match self.sync.id("minecraft:sound_event", name) {
Some(id) => (id, None),
None => (-1, Some((name.to_owned(), None))),
};
C::Sound(play::Sound {
sound,
source,
x: (at[0] * 8.0) as i32,
y: (at[1] * 8.0) as i32,
z: (at[2] * 8.0) as i32,
volume,
pitch,
seed,
})
}
pub fn sound(
&self,
name: &str,
@@ -45,20 +69,7 @@ impl Hub {
source: i32,
except: Option<u32>,
) {
let sound = match self.sync.id("minecraft:sound_event", name) {
Some(id) => (id, None),
None => (-1, Some((name.to_owned(), None))),
};
let packet = C::Sound(play::Sound {
sound,
source,
x: (at[0] * 8.0) as i32,
y: (at[1] * 8.0) as i32,
z: (at[2] * 8.0) as i32,
volume,
pitch,
seed: self.ticks as i64,
});
let packet = self.sound_packet(name, at, volume, pitch, source, self.ticks as i64);
self.broadcast_near(at, f64::from(volume.max(1.0)) * 16.0, &packet, except);
}
@@ -120,12 +131,13 @@ impl Hub {
);
}
/// `ServerPlayer.crit`: send the victim's animation to the players tracking
/// the attacker and the attacker themselves (`sendToTrackingPlayersAndSelf`).
pub fn critical_hit(&self, attacker: i32, target: i32) {
/// `ServerPlayer.crit` (`magicCrit` when `magic`): send the victim's
/// animation to the players tracking the attacker and the attacker
/// themselves (`sendToTrackingPlayersAndSelf`).
pub fn critical_hit(&self, attacker: i32, target: i32, magic: bool) {
let body = C::Animate(play::Animate {
id: target,
action: 1,
action: if magic { 2 } else { 1 },
})
.encode();
for g in self.guests.iter().filter(|g| {
@@ -421,6 +433,42 @@ impl Hub {
drops
}
/// `AbstractContainerMenu.setSelectedBundleItemIndex` on the guest's open
/// menu: a container's own slots, or the inventory's. A bundle's selection
/// lives on the server's stack until the click that takes from it.
pub fn select_bundle_item(&mut self, conn: u32, slot: usize, index: i32, world: &mut World) {
let Some(g) = self.guests.iter_mut().find(|g| g.conn == conn) else {
return;
};
let open = g.menu.as_ref().map(|m| (m.size, m.slots));
let mut items = match open {
Some((size, source)) if slot < size => source.read(world, g),
_ => None,
};
let stack = match open {
Some((size, _)) if slot < size => items
.as_mut()
.and_then(|items| items.get_mut(slot))
.and_then(Option::as_mut),
Some((size, _)) => g
.inventory
.get_mut(player_slot(slot - size))
.and_then(Option::as_mut),
None => match slot {
9..=35 => g.inventory.get_mut(slot).and_then(Option::as_mut),
36..=44 => g.inventory.get_mut(slot - 36).and_then(Option::as_mut),
45 => g.offhand.as_mut(),
_ => None,
},
};
if let Some(stack) = stack {
crate::bundle::toggle_selected(stack, index);
}
if let (Some((_, source)), Some(items)) = (open, items) {
source.write(world, g, items);
}
}
/// A click on the inventory menu (window 0) the game applied itself
/// (the crafting grid): the client is corrected like after any click.
pub fn inventory_clicked(
+10 -4
View File
@@ -611,8 +611,9 @@ pub struct Viewer<'a> {
}
/// One entity as this tick's range checks read it: where it is
/// horizontally, its id, its type's tracking range in chunks and the chunk
/// it is in (an index into the tick's chunks).
/// horizontally, its id, its tracking range in blocks (scaled by
/// `entity-broadcast-range-percentage`) and the chunk it is in (an index
/// into the tick's chunks).
#[derive(Clone, Copy)]
struct Probe {
x: f64,
@@ -627,7 +628,7 @@ struct Probe {
/// each of the tick's chunks whether the client has it (0: not looked up
/// yet, 1: no, 2: yes), so each chunk is looked up once per client.
fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
let range = f64::from(p.range.min(v.view_distance) * 16);
let range = f64::from(p.range.min(v.view_distance * 16));
let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z);
dx * dx + dz * dz <= range * range && {
let held = &mut held[p.chunk as usize];
@@ -641,6 +642,9 @@ fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool {
/// One level's tracked entities (ChunkMap.entityMap).
#[derive(Default)]
pub struct Entities {
/// `entity-broadcast-range-percentage` (None: 100), which scales every
/// type's tracking range (MinecraftServer.getScaledTrackingDistance).
pub range_percent: Option<i32>,
states: crate::fast_hash::FxHashMap<i32, ServerEntity>,
/// The level tick, stamped on every entity still in the level.
tick: u32,
@@ -732,7 +736,9 @@ impl Entities {
x: e.position[0],
z: e.position[2],
id: e.id,
range: s.range,
range: self
.range_percent
.map_or(s.range * 16, |p| p * (s.range * 16) / 100),
chunk,
});
}
+149 -13
View File
@@ -21,7 +21,7 @@ use crate::{
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
use horizon_protocol::ids::play::clientbound as ids;
use horizon_protocol::{
entity_data::{Value, serializer},
entity_data::{Particle, ParticleData, Value, serializer},
packets::play::{self, Clientbound as C, Serverbound as S},
text::{literal, translatable},
};
@@ -87,6 +87,29 @@ pub struct HostState {
pub living_flags: i8,
/// The bed the host sleeps in (`LivingEntity.SLEEPING_POS`).
pub bed: Option<[i32; 3]>,
pub body: BodyFlags,
}
/// What the other players see of a player's body besides sneaking and
/// sprinting: the pose and the rest of `Entity.DATA_SHARED_FLAGS_ID`.
#[derive(Clone, Copy, Debug, Default)]
pub struct BodyFlags {
/// `Entity.isFallFlying`: gliding with elytra.
pub gliding: bool,
/// `Entity.isSwimming` (the swimming flag).
pub swimming: bool,
/// The swimming pose of a player crawling under a block.
pub crawling: bool,
/// The crouching pose, held without sneaking under a low ceiling.
pub crouching: bool,
/// `LivingEntity.isAutoSpinAttack`: Riptide's spin.
pub spin_attack: bool,
/// `Entity.isOnFire`.
pub on_fire: bool,
/// `Entity.isInvisible`: the invisibility effect.
pub invisible: bool,
/// The glowing effect.
pub glowing: bool,
}
/// Everything the hub needs from the game for one tick.
@@ -168,6 +191,8 @@ pub struct Hub {
level_sync: std::collections::HashMap<String, LevelSync>,
/// Operators and their permission levels (dedicated servers).
pub ops: std::collections::HashMap<u128, u8>,
/// `entity-broadcast-range-percentage` (dedicated servers).
pub entity_range_percent: i32,
chat_type: i32,
dimension_type: i32,
clock_ids: Vec<i32>,
@@ -335,6 +360,7 @@ impl Hub {
ticking: (20.0, false),
stepping: 0,
ops: Default::default(),
entity_range_percent: 100,
weather: (false, 0.0, 0.0),
level_sync: Default::default(),
chat_type,
@@ -1527,6 +1553,49 @@ impl Hub {
// ---- entities ------------------------------------------------------
/// `LivingEntity.updateSynchronizedMobEffectParticles`: DATA_EFFECT_PARTICLES
/// (10) and DATA_EFFECT_AMBIENCE_ID (11) for the visible `effects`, as
/// (effect, ambient). The entries are left out when there is nothing to
/// show, which is their default.
pub fn effect_meta<'a>(
&self,
effects: impl IntoIterator<Item = (&'a str, bool)>,
) -> Vec<(u8, i32, Value)> {
let mut particles = Vec::new();
let mut all_ambient = true;
for (effect, ambient) in effects {
let effect = effect.trim_start_matches("minecraft:");
// MobEffect.particleFactory: the omens have their own particles,
// the others an entity effect in their colour (faint if ambient).
let (name, data) = match effect {
"trial_omen" | "raid_omen" => (effect, None),
_ => {
let Some(def) = crate::brewing::effect(effect) else {
continue;
};
let alpha = if ambient { 38 } else { 255 };
let argb = (alpha << 24) | def.color as i32;
("entity_effect", Some(ParticleData::Color(argb)))
}
};
let Some(id) =
self.registry_id("minecraft:particle_type", &format!("minecraft:{name}"))
else {
continue;
};
particles.push(Particle { id, data });
all_ambient &= ambient;
}
if particles.is_empty() {
return Vec::new();
}
let mut meta = vec![(10, serializer::PARTICLES, Value::Particles(particles))];
if all_ambient {
meta.push((11, serializer::BOOLEAN, Value::Bool(true)));
}
meta
}
fn player_state(
&self,
id: i32,
@@ -1534,21 +1603,29 @@ impl Hub {
position: [f64; 3],
rot: (f32, f32, f32),
on_ground: bool,
flags: (bool, bool, bool, bool),
flags: (bool, bool, bool),
body: BodyFlags,
living_flags: i8,
bed: Option<[i32; 3]>,
skin: u8,
health: f32,
equipment: Vec<(u8, Option<horizon_protocol::item::Stack>)>,
attributes: Vec<play::AttributeSnapshot>,
effects: &[EffectInstance],
) -> EntityState {
let (sneaking, sprinting, gliding, sleeping) = flags;
let shared = shared_flags(sneaking, sprinting, gliding);
let (sneaking, sprinting, sleeping) = flags;
let shared = shared_flags(sneaking, sprinting, &body);
// Player.getDesiredPose (and updatePlayerPose's crawl): sleeping,
// swimming, gliding, Riptide's spin, crouching.
let pose = if sleeping {
2
} else if gliding {
} else if body.swimming || body.crawling {
3
} else if body.gliding {
1
} else if sneaking {
} else if body.spin_attack {
4
} else if sneaking || body.crouching {
5
} else {
0
@@ -1574,6 +1651,14 @@ impl Hub {
if skin != 0 {
meta.push((16, serializer::BYTE, Value::Byte(skin as i8)));
}
meta.extend(
self.effect_meta(
effects
.iter()
.filter(|e| e.visible)
.map(|e| (e.id, e.ambient)),
),
);
EntityState {
id,
uuid,
@@ -1586,7 +1671,11 @@ impl Hub {
velocity: [0.0; 3],
data: 0,
meta,
defaults: Vec::new(),
// The effect swirls go back to none.
defaults: vec![
(10, serializer::PARTICLES, Value::Particles(Vec::new())),
(11, serializer::BOOLEAN, Value::Bool(false)),
],
equipment,
attributes,
precise: false,
@@ -1634,13 +1723,15 @@ impl Hub {
h.position,
(h.yaw, h.pitch, h.head_yaw),
h.on_ground,
(h.sneaking, h.sprinting, false, h.bed.is_some()),
(h.sneaking, h.sprinting, h.bed.is_some()),
h.body,
h.living_flags,
h.bed,
0x7F,
h.health,
equipment,
h.attributes.clone(),
&[],
));
}
let placed: Vec<usize> = (0..self.guests.len())
@@ -1666,7 +1757,8 @@ impl Hub {
g.position,
(g.yaw, g.pitch, g.head_yaw),
g.on_ground,
(g.sneaking, g.sprinting, g.fall_flying, g.sleeping),
(g.sneaking, g.sprinting, g.sleeping),
guest_body(g),
g.living_flags,
g.bed,
g.info.model_customisation,
@@ -1676,6 +1768,7 @@ impl Hub {
.as_ref()
.map(|m| super::attributes::syncable(&m.attributes))
.unwrap_or_default(),
&g.effects.list,
)
})
.collect();
@@ -1705,6 +1798,7 @@ impl Hub {
out,
})
.collect();
level.range_percent = Some(self.entity_range_percent);
let (types, sync) = (&mut self.entity_types, &self.sync);
level.tick(&entities, &self.riding, &mut viewers, |kind| {
entity_type(types, sync, kind)
@@ -1825,7 +1919,7 @@ impl Hub {
// too, so the sprint a hit stops (Player.causeExtraKnockback) stops
// on its client.
let g = &mut self.guests[i];
let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying);
let shared = shared_flags(g.sneaking, g.sprinting, &guest_body(g));
if shared != g.sent_shared {
g.sent_shared = shared;
g.send(&C::SetEntityData(play::SetEntityData {
@@ -2070,6 +2164,20 @@ impl Hub {
S::Chat(c) => self.handle_chat(i, c, events),
S::ChatAck(p) => self.chat_ack(i, p.offset, events),
S::ChatSessionUpdate(p) => self.chat_session_update(i, p, events),
// handlePlayerAction (a dig starting, stopping or finishing) is the
// game's, run after this pass. A move queued behind it is checked
// against the block the player just broke and falls through, so it
// would be sent back; it waits for the next tick, when the block is
// gone, as vanilla handles the two packets in order.
S::PlayerAction(p) if p.action <= 3 => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerAction(p))));
if matches!(
self.guests[i].inbox.front(),
Some(S::MovePlayerPos(_) | S::MovePlayerPosRot(_))
) {
break;
}
}
other => events.push(HostEvent::Packet(conn, Box::new(other))),
}
}
@@ -2605,16 +2713,44 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
raw(ids::TICKING_STATE, w.data)
}
/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding.
fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 {
/// What the others see of a guest's body, from its server-side state.
fn guest_body(g: &Guest) -> BodyFlags {
let m = g.motion.as_deref();
BodyFlags {
gliding: g.fall_flying,
swimming: m.is_some_and(|m| m.swimming),
crawling: m.is_some_and(|m| m.pose == crate::pose::Pose::Swimming && !m.swimming),
crouching: m.is_some_and(|m| m.pose == crate::pose::Pose::Crouching),
spin_attack: m.is_some_and(|m| m.auto_spin_attack_ticks > 0),
on_fire: m.is_some_and(|m| m.fire_ticks > 0),
invisible: g.effects.get("invisibility").is_some(),
glowing: g.effects.get("glowing").is_some(),
}
}
/// Entity.DATA_SHARED_FLAGS_ID of a player: on fire 0x01, crouching 0x02,
/// sprinting 0x08, swimming 0x10, invisible 0x20, glowing 0x40, gliding 0x80.
fn shared_flags(sneaking: bool, sprinting: bool, body: &BodyFlags) -> i8 {
let mut shared = 0i8;
if body.on_fire {
shared |= 0x01;
}
if sneaking {
shared |= 0x02;
}
if sprinting {
shared |= 0x08;
}
if gliding {
if body.swimming {
shared |= 0x10;
}
if body.invisible {
shared |= 0x20;
}
if body.glowing {
shared |= 0x40;
}
if body.gliding {
shared |= -128;
}
shared
+4 -1
View File
@@ -5,7 +5,7 @@
//! menu are returned for the game to drop.
use super::guest::Guest;
use crate::{
bundle::{Contents, can_be_in_bundle},
bundle::{Contents, can_be_in_bundle, toggle_selected},
items::{ItemStack, Items},
world::World,
};
@@ -514,7 +514,10 @@ fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bo
}
}
(carried, _) => {
// toggleSelectedItem(-1), then the click goes on.
view.guest.carried = carried;
toggle_selected(&mut bundle, -1);
view.set(i, Some(bundle));
return false;
}
}
+5 -2
View File
@@ -1,7 +1,7 @@
//! `BundleItem`'s click overrides in a PICKUP click
//! (`tryItemClickBehaviourOverride`).
use super::{Env, Menu, tidy};
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle};
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle, toggle_selected};
impl Menu {
/// The carried stack's `overrideStackedOnOther`, then the clicked
@@ -88,8 +88,11 @@ impl Menu {
}
}
(carried, _) => {
// toggleSelectedItem(-1): Horizon keeps no selected item.
// toggleSelectedItem(-1), then the click goes on.
self.carried = carried;
toggle_selected(&mut bundle, -1);
let slot = self.slots[s];
self.containers[slot.container][slot.index] = Some(bundle);
return false;
}
}
+20 -2
View File
@@ -50,6 +50,7 @@ pub mod nether;
pub mod path;
pub mod phantom;
pub mod piglin;
pub mod polar_bear;
pub mod rabbit;
pub mod raider;
pub mod ravager;
@@ -645,6 +646,8 @@ pub struct Attrs {
pub struct Extra {
/// Players: crouching and riding (HumanoidModel's poses).
pub crouching: bool,
/// Players: sprinting (`Entity.DATA_SHARED_FLAGS_ID`), for the dust they raise.
pub sprinting: bool,
pub passenger: bool,
/// AgeableMob.age: negative while a baby (a tadpole counts up to 24000).
pub age: i32,
@@ -726,6 +729,12 @@ pub struct Extra {
pub vector: [f64; 3],
/// Active status effects: (effect id, ticks left, amplifier).
pub effects: Vec<(&'static str, i32, i32)>,
/// The effects above given with `hideParticles`: no swirls for them.
pub hidden_effects: Vec<&'static str>,
/// `LivingEntity.DATA_EFFECT_PARTICLES` and `DATA_EFFECT_AMBIENCE_ID` as a
/// server gave them: the swirls around it, and whether they are all faint.
pub effect_particles: Vec<crate::particles::ParticleOptions>,
pub effect_ambient: bool,
/// An invisible illusioner's mirror images.
pub illusions: Option<illusioner::Illusions>,
/// A villager's claimed bed head and meeting bell.
@@ -737,6 +746,8 @@ pub struct Extra {
/// the spin the rope's torque gives it.
pub leash: Option<leash::Holder>,
pub leash_pending: Option<leash::Pending>,
/// The UUID of the player behind `Holder::Player` (host or guest).
pub leash_player: Option<[i32; 4]>,
pub leash_spin: f64,
/// More species scratch values.
pub scratch: [i32; 4],
@@ -1959,8 +1970,15 @@ impl Mob {
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
e.1 = e.1.max(ticks);
e.2 = e.2.max(amplifier);
// MobEffectInstance.update: a stronger effect replaces the old one
// with its own duration, an equal one keeps the longer duration,
// and a weaker one never changes the active effect.
if amplifier > e.2 {
e.1 = ticks;
e.2 = amplifier;
} else if amplifier == e.2 {
e.1 = e.1.max(ticks);
}
} else {
self.extra.effects.push((effect, ticks, amplifier));
}
+62 -1
View File
@@ -106,6 +106,7 @@ pub enum Family {
Strider,
Bat,
Bee,
PolarBear,
}
pub fn family(s: Species) -> Family {
use Species as S;
@@ -167,6 +168,7 @@ pub fn family(s: Species) -> Family {
S::Strider => Family::Strider,
S::Bat => Family::Bat,
S::Bee => Family::Bee,
S::PolarBear => Family::PolarBear,
_ => Family::Generic,
}
}
@@ -227,6 +229,7 @@ pub fn register(m: &mut Mob) {
Family::Strider => super::strider::register(m),
Family::Bat => super::bat::register(m),
Family::Bee => super::bee::register(m),
Family::PolarBear => super::polar_bear::register(m),
}
// FloatGoal's constructor lets the navigation float, as do the
// constructors of these brain-driven and flying mobs. A floater rises
@@ -416,6 +419,7 @@ pub fn tick(
super::axolotl::tick_animation(m);
m.anim.end_tick(m.ai.swinging);
tick_swing(m);
flower_contact_effects(m, w);
if m.species() == Species::CopperGolem {
// CopperGolem.tick: updateWeathering after the living tick.
super::copper_golem::update_weathering(m, w, level_random, events);
@@ -674,7 +678,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
}
// AbstractHorse.aiStep: the tail swishes now and then (moveTail).
if super::equine::is_equine(m.species()) {
let _ = m.random.next_int_bound(200);
super::equine::tail_tick(m);
}
// Monster.aiStep: updateNoActionTime (Raider: always +2).
if is_monster(m.species())
@@ -701,6 +705,9 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
// serverAiStep has no goals, so it only falls and is pushed.
let immobile = (m.species() == Species::Ravager && super::ravager::immobile(m))
|| m.species() == Species::Mannequin;
if m.species() == Species::Piglin && m.alive() {
super::piglin::celebrate(m, w);
}
if m.alive() && !immobile {
server_ai_step(m, w, level_random, events);
} else {
@@ -1025,6 +1032,7 @@ fn species_ai_step(
Species::Wither => super::wither::ai_step(m, w, events),
Species::Allay => super::allay::ai_step(m),
Species::Bee => super::bee::ai_step(m, w),
Species::PolarBear => super::polar_bear::ai_step(m, w),
Species::Breeze => super::breeze::ai_step(m),
Species::Sniffer => super::sniffer::ai_step(m, events),
Species::HappyGhast => super::happy_ghast::ai_step(m, w),
@@ -1517,6 +1525,56 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
}
}
/// `Entity.applyEffectsFromBlocks` for the flowers that give an effect:
/// `WitherRoseBlock.entityInside` (wither for 40 ticks on a living mob that
/// can take it, not in peaceful) and `EyeblossomBlock.entityInside` (poison
/// for 25 ticks on a bee, when the open flower attracts bees). The blocks
/// are those the mob's box touches where it started the tick and where it
/// ended.
fn flower_contact_effects(m: &mut Mob, w: &MobWorld) {
if !m.alive() || w.difficulty.effective == 0.0 {
return;
}
let half = f64::from(m.width()) / 2.0 - 1.0e-5;
let height = f64::from(m.height()) - 1.0e-5;
let mut visited: Vec<[i32; 3]> = Vec::new();
for at in [m.previous, m.position] {
let cells = |lo: f64, hi: f64| lo.floor() as i32..=hi.floor() as i32;
for x in cells(at.x - half, at.x + half) {
for y in cells(at.y + 1.0e-5, at.y + height) {
for z in cells(at.z - half, at.z + half) {
let p = [x, y, z];
if visited.contains(&p) {
continue;
}
match w.block_name(x, y, z) {
"minecraft:wither_rose" => {
// isInvulnerableTo(wither): the invulnerable time
// after a hit; WitherBoss and WitherSkeleton
// refuse the effect (`canBeAffected`).
if m.invulnerable <= 0
&& !m.invulnerable_flag
&& !matches!(m.species(), Species::Wither | Species::WitherSkeleton)
{
m.add_effect("minecraft:wither", 40, 0);
}
}
"minecraft:open_eyeblossom"
if m.species() == Species::Bee
&& super::bee::attracts_bees(w, p)
&& !m.has_effect("minecraft:poison") =>
{
m.add_effect("minecraft:poison", 25, 0);
}
_ => continue,
}
visited.push(p);
}
}
}
}
}
/// MobEffect ticking for the effects mobs receive (poison, wither, regeneration).
fn tick_effects(m: &mut Mob) {
let mut effects = std::mem::take(&mut m.extra.effects);
@@ -1541,6 +1599,9 @@ fn tick_effects(m: &mut Mob) {
*ticks -= 1;
}
effects.retain(|e| e.1 > 0);
m.extra
.hidden_effects
.retain(|h| effects.iter().any(|e| e.0 == *h));
m.extra.effects = effects;
}
+2 -1
View File
@@ -197,7 +197,8 @@ pub fn state(m: &Mob, alpha: f32) -> State {
let walking = m.alive() && !m.extra.passenger;
State {
pos: if walking {
m.limb_position - m.limb_speed * (1.0 - alpha)
// WalkAnimationState.position(partial): babies' legs move 3 times as far.
(m.limb_position - m.limb_speed * (1.0 - alpha)) * if m.is_baby() { 3.0 } else { 1.0 }
} else {
0.0
},
+1 -1
View File
@@ -959,7 +959,7 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses, a: &Animations) -> bool {
let st = &m.anim.states;
match m.species() {
Species::Goat => goat(p, s, &Goat::from_mob(m)),
Species::PolarBear => polar_bear(p, s, 0.0),
Species::PolarBear => polar_bear(p, s, super::polar_bear::standing_scale(m, s.partial)),
Species::Panda => panda(p, s, &Panda::default()),
Species::Llama | Species::TraderLlama => {
llama(p, s, m.extra.chested && !s.baby);
+25 -1
View File
@@ -28,6 +28,8 @@ pub const MARKINGS: [&str; 5] = ["", "white", "whitefield", "whitedots", "blackd
pub const EAT: usize = 0;
pub const STAND: usize = 1;
pub const MOUTH: usize = 2;
/// `AbstractHorse.tailCounter`: client-side, it only drives the model.
pub const TAIL: usize = 3;
/// What a rider asks of its mount this tick.
#[derive(Clone, Copy, Debug, Default)]
@@ -418,6 +420,20 @@ pub fn ridden_step(m: &mut Mob) {
}
}
}
/// `AbstractHorse.aiStep`'s `moveTail` (one in 200 ticks) and the counter's
/// step in `tick` (it runs to 8, then stops). A client runs it on its own
/// random, as vanilla's client-side `aiStep` does.
pub fn tail_tick(m: &mut Mob) {
if m.random.next_int_bound(200).unwrap_or(1) == 0 {
m.anim.v[TAIL] = 1.0;
}
if m.anim.v[TAIL] > 0.0 {
m.anim.v[TAIL] += 1.0;
if m.anim.v[TAIL] > 8.0 {
m.anim.v[TAIL] = 0.0;
}
}
}
/// `AbstractHorse.aiStep` and `tick`: healing, grazing, the rear and the
/// eating/standing/mouth animations.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
@@ -603,7 +619,15 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
t[1] += speed * age;
t[2] += speed * 2.0 * age;
}
p.yrot("tail", 0.0);
// animateTail: the tail swings while the counter runs.
p.yrot(
"tail",
if m.anim.v[TAIL] > 0.0 {
(s.age * 0.7).mcos()
} else {
0.0
},
);
if baby && donkey {
// BabyDonkeyModel overrides the state's pitch with -30 degrees and
// sets the head parts again.
+39 -2
View File
@@ -1017,6 +1017,8 @@ pub struct MeleeAttack {
pub speed: f64,
pub follow_unseen: bool,
pub zombie: bool,
/// `PolarBear.PolarBearMeleeAttackGoal`: rears up before each strike.
pub polar_bear: bool,
path_target: [f64; 3],
recalc: i32,
pub until_attack: i32,
@@ -1031,6 +1033,7 @@ impl MeleeAttack {
speed,
follow_unseen,
zombie: false,
polar_bear: false,
path_target: [0.0; 3],
recalc: 0,
until_attack: 0,
@@ -1043,6 +1046,10 @@ impl MeleeAttack {
self.zombie = true;
self
}
pub fn polar_bear(mut self) -> Self {
self.polar_bear = true;
self
}
pub fn interval(mut self, ticks: i32) -> Self {
self.interval = ticks;
self
@@ -1098,6 +1105,9 @@ impl Goal for MeleeAttack {
}
m.ai.aggressive = false;
m.nav_stop();
if self.polar_bear {
super::polar_bear::set_standing(m, false);
}
}
fn every_tick(&self) -> bool {
true
@@ -1131,7 +1141,17 @@ impl Goal for MeleeAttack {
self.recalc = adjusted(true, self.recalc);
}
self.until_attack = (self.until_attack - 1).max(0);
if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) {
if self.polar_bear {
let cooldown = adjusted(true, self.interval);
super::polar_bear::check_and_perform_attack(
&mut self.until_attack,
cooldown,
m,
who,
&t,
cx,
);
} else if self.until_attack <= 0 && m.within_melee_range(&t) && m.sees(cx.w, who) {
self.until_attack = adjusted(true, self.interval);
m.swing();
super::ai::do_hurt_target(m, who, cx);
@@ -1422,6 +1442,9 @@ pub struct HurtByTarget {
/// `Bee.BeeHurtByOtherGoal`: kept only while angry, and alerting others
/// only when the attacker is in sight.
pub bee: bool,
/// `PolarBear.PolarBearHurtByTargetGoal`: a hurt cub only calls the
/// adult bears and drops the target itself.
pub polar_bear: bool,
timestamp: i32,
st: TargetState,
}
@@ -1432,6 +1455,7 @@ impl HurtByTarget {
alert_except: vec![],
ignore: vec![],
bee: false,
polar_bear: false,
timestamp: 0,
st: TargetState {
unseen_memory: 300,
@@ -1451,6 +1475,10 @@ impl HurtByTarget {
self.bee = true;
self
}
pub fn polar_bear(mut self) -> Self {
self.polar_bear = true;
self
}
}
impl Default for HurtByTarget {
fn default() -> Self {
@@ -1462,6 +1490,8 @@ impl Goal for HurtByTarget {
fn name(&self) -> &'static str {
if self.bee {
"BeeHurtByOtherGoal"
} else if self.polar_bear {
"PolarBearHurtByTargetGoal"
} else {
"HurtByTarget"
}
@@ -1500,7 +1530,9 @@ impl Goal for HurtByTarget {
self.timestamp = m.ai.last_hurt_by_time;
self.st.unseen_memory = 300;
self.st.unseen = 0;
if self.alert
// PolarBearHurtByTargetGoal.start: a cub alerts the adults itself.
let cub = self.polar_bear && m.is_baby();
if (self.alert || cub)
&& let Some(by) = m.ai.target
{
let range = m.attrs.follow_range;
@@ -1520,6 +1552,8 @@ impl Goal for HurtByTarget {
&& (s.position.z - pos.z).abs() <= range + 1.0
})
.filter(|s| !self.alert_except.contains(&s.species))
// PolarBearHurtByTargetGoal.alertOther: adults only.
.filter(|s| !(self.polar_bear && s.baby))
.filter(|s| Who::Mob(s.id) != by)
.map(|s| s.id)
.collect();
@@ -1534,6 +1568,9 @@ impl Goal for HurtByTarget {
});
}
}
if cub {
m.ai.target = None;
}
}
fn stop(&mut self, m: &mut Mob, _: &mut Cx) {
m.ai.target = None;
+1 -1
View File
@@ -908,7 +908,7 @@ impl Mob {
(distance * 4.0).min(1.0)
};
self.limb_speed += (target - self.limb_speed) * 0.4;
self.limb_position += self.limb_speed * if self.is_baby() { 3.0 } else { 1.0 };
self.limb_position += self.limb_speed;
} else {
self.limb_speed = 0.0;
}
+96 -2
View File
@@ -10,7 +10,7 @@ use super::{
},
};
use horizon_core::geometry::{Vec3, trig::Mth};
use horizon_core::random::RandomSource;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::f32::consts::PI;
/// `PiglinAi.ADMIRE_DURATION`.
@@ -20,6 +20,9 @@ pub const ADMIRE: i32 = 119;
pub const ATE_RECENTLY: i32 = 200;
/// `AbstractPiglin.CONVERSION_TIME` (and the hoglin's).
const CONVERSION: i32 = 300;
/// `PiglinAi.CELEBRATION_TIME`: how long the `CELEBRATE_LOCATION` and
/// `DANCING` memories last.
const CELEBRATION: i32 = 300;
fn hostile_to_player(m: &Mob, w: &MobWorld) -> bool {
if m.is_baby() {
@@ -50,6 +53,7 @@ pub fn register_piglin(m: &mut Mob) {
g.add(3, MeleeAttack::new(1.0, true));
g.add(4, PiglinAvoidRepellent);
g.add(4, GoToWantedItem);
g.add(4, Celebrate);
g.add(5, Stroll::avoiding_water(0.6));
g.add(6, LookAtPlayer::new(8.0));
g.add(7, RandomLookAround::default());
@@ -243,6 +247,45 @@ impl Goal for Admire {
m.nav_stop();
}
}
/// The celebrate activity: `GoToTargetLocation` on the spot a killed target
/// fell (within 2 blocks, or 4 and slower while dancing).
#[derive(Clone, Debug)]
struct Celebrate;
impl Celebrate {
fn walk(m: &mut Mob, cx: &mut Cx) {
let Some(at) = m.extra.home else { return };
let (close, speed) = if m.extra.scratch[0] > 0 {
(4.0, 0.6)
} else {
(2.0, 1.0)
};
let center = Vec3::new(
f64::from(at[0]) + 0.5,
f64::from(at[1]),
f64::from(at[2]) + 0.5,
);
if m.distance_sqr(center) >= close * close {
m.nav_move_to(cx.w, [center.x, center.y, center.z], speed);
}
}
}
impl Goal for Celebrate {
goal_clone!();
fn flags(&self) -> u8 {
MOVE
}
fn can_use(&mut self, m: &mut Mob, _: &mut Cx) -> bool {
m.extra.scratch[1] > 0 && m.extra.home.is_some() && m.extra.using_item == 0
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
Self::walk(m, cx);
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if m.ai.nav.is_done() {
Self::walk(m, cx);
}
}
}
/// `GoToWantedItem`: walk to the gold the game spotted.
#[derive(Clone, Debug)]
struct GoToWantedItem;
@@ -294,6 +337,54 @@ fn hoglin_repellent(m: &mut Mob, w: &MobWorld) {
m.ai.target = None;
}
}
/// `StartCelebratingIfTargetDead` for a piglin (`scratch[0]` dancing,
/// `scratch[1]` ticks of celebration left, `scratch[2]` the hit seen, `home`
/// the `CELEBRATE_LOCATION`): a killed target starts a celebration at its
/// spot, and a hoglin's death makes the piglin dance one time in ten.
/// `PiglinAi.wasHurtBy` ends both. Runs before the target goals drop the
/// dead target.
pub fn celebrate(m: &mut Mob, w: &MobWorld) {
if m.extra.scratch[1] > 0 {
m.extra.scratch[1] -= 1;
if m.extra.scratch[1] == 0 {
m.extra.scratch[0] = 0;
m.extra.home = None;
}
}
if let Some(by) = m.ai.last_hurt_by
&& m.ai.last_hurt_by_time != m.extra.scratch[2]
{
m.extra.scratch[2] = m.ai.last_hurt_by_time;
// PiglinAi.wasHurtBy: another piglin's hit ends nothing.
let piglin =
matches!(by, Who::Mob(id) if w.seen(id).is_some_and(|s| s.species == Species::Piglin));
if !piglin {
m.extra.scratch[0] = 0;
m.extra.scratch[1] = 0;
m.extra.home = None;
}
}
if m.extra.scratch[1] > 0 {
return;
}
let Some(target) = m.ai.target else { return };
let Some(t) = w.target_info(target).filter(|t| !t.alive) else {
return;
};
let hoglin =
matches!(target, Who::Mob(id) if w.seen(id).is_some_and(|s| s.species == Species::Hoglin));
// PiglinAi.wantsToDance: a fresh random seeded with the game time, not
// the piglin's own.
if hoglin && LegacyRandom::new(w.game_time).next_float() < 0.1 {
m.extra.scratch[0] = 1;
}
m.extra.scratch[1] = CELEBRATION;
m.extra.home = Some([
t.position.x.floor() as i32,
t.position.y.floor() as i32,
t.position.z.floor() as i32,
]);
}
/// Per-tick piglin and hoglin state: admiring, anger, zombification and the
/// hoglin attack animation. Returns a conversion when it happens.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
@@ -420,7 +511,10 @@ pub fn ears(p: &mut Poses, s: &State) {
/// Piglins' and brutes' ears flap (`AbstractPiglinModel`), arms follow the
/// arm pose (`PiglinModel`).
pub fn piglin_anim(m: &Mob, s: &State, p: &mut Poses) {
let pose = if m.extra.using_item > 0 {
// Piglin.getArmPose: dancing comes first.
let pose = if m.extra.scratch[0] > 0 {
PiglinPose::Dancing
} else if m.extra.using_item > 0 {
PiglinPose::AdmiringItem
} else if m.extra.flag {
PiglinPose::CrossbowCharge
@@ -0,0 +1,191 @@
//! Polar bears (`PolarBear`): a neutral animal that defends its cubs,
//! fights back when hurt, and rears up before each swing. A cub flees what
//! hurts it and calls the adults.
//!
//! `extra.flag` is `DATA_STANDING_ID`, `extra.counter` the warning sound's
//! delay, `extra.anger` the `NeutralMob` anger left and `extra.flag2` whether
//! the player is who it is angry at. `anim.v[STAND]` is
//! `clientSideStandAnimation`.
use horizon_core::{geometry::Vec3, random::RandomSource};
use super::{
DamageKind, Mob, MobWorld, Species, Who,
goals::{
Cx, Float, FollowParent, Goal, HurtByTarget, LookAtPlayer, MOVE, MeleeAttack,
NearestTarget, Panic, RandomLookAround, Stroll, goal_clone,
},
world::TargetInfo,
};
/// `anim.v` slot of `clientSideStandAnimation`.
pub const STAND: usize = 0;
/// `STAND_ANIMATION_TICKS`.
const STAND_TICKS: f32 = 6.0;
pub fn register(m: &mut Mob) {
let g = &mut m.ai.goals;
g.add(0, Float);
g.add(1, MeleeAttack::new(1.25, true).polar_bear());
g.add(1, PolarBearPanic(Panic::new(2.0)));
g.add(4, FollowParent::new(1.25));
g.add(5, Stroll::new(1.0));
g.add(6, LookAtPlayer::new(6.0));
g.add(7, RandomLookAround::default());
let t = &mut m.ai.targets;
t.add(1, HurtByTarget::new().polar_bear());
// PolarBearAttackPlayersGoal: players near a cub, within half the
// follow range, never for a cub itself.
t.add(
2,
NearestTarget::player(true)
.interval(20)
.follow_scale(0.5)
.reach()
.gate(|m, _| !m.is_baby())
.when(cub_nearby)
.named("PolarBearAttackPlayersGoal"),
);
t.add(
3,
NearestTarget::player(true).when(|m, _| m.extra.anger > 0 && m.extra.flag2),
);
t.add(
4,
NearestTarget::species(&[Species::Fox], true)
.interval(10)
.reach()
.when(|m, _| !m.is_baby()),
);
}
/// `PolarBearAttackPlayersGoal.canUse`: a polar bear cub within 8 blocks
/// (4 up and down).
fn cub_nearby(m: &Mob, w: &MobWorld) -> bool {
let area = m.bounds().inflate(Vec3::new(8.0, 4.0, 8.0));
w.others_near(area)
.any(|s| s.species == Species::PolarBear && s.baby && s.bounds.intersects(area))
}
/// `PanicGoal` with a function of the bear's age for the damage tag: a cub
/// panics at `PANIC_CAUSES`, an adult only at `PANIC_ENVIRONMENTAL_CAUSES`.
#[derive(Clone, Debug)]
struct PolarBearPanic(Panic);
impl Goal for PolarBearPanic {
goal_clone!();
fn flags(&self) -> u8 {
MOVE
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
(m.is_baby() || environmental_panic(m)) && self.0.can_use(m, cx)
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
self.0.can_continue(m, cx)
}
fn start(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.start(m, cx)
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.0.stop(m, cx)
}
}
/// `DamageTypeTags.PANIC_ENVIRONMENTAL_CAUSES` (fire, lava, cactus), or
/// burning.
fn environmental_panic(m: &Mob) -> bool {
m.fire_ticks > 0 && !m.kind.flags.fire_immune
|| m.last_damage.is_some_and(|(d, t)| {
matches!(
d.kind,
DamageKind::OnFire | DamageKind::InFire | DamageKind::Lava | DamageKind::Cactus
) && i64::from(m.age) - t <= 40
})
}
/// `PolarBear.setStanding`.
pub fn set_standing(m: &mut Mob, standing: bool) {
m.extra.flag = standing;
}
/// `PolarBear.playWarningSound`.
fn play_warning_sound(m: &mut Mob, cx: &mut Cx) {
if m.extra.counter <= 0 {
m.voice("entity.polar_bear.warning", cx.events);
m.extra.counter = 40;
}
}
/// `PolarBearMeleeAttackGoal.checkAndPerformAttack`: strike when ready and
/// in reach, rear up and growl in the last 10 ticks while close.
pub fn check_and_perform_attack(
until_attack: &mut i32,
cooldown: i32,
m: &mut Mob,
who: Who,
t: &TargetInfo,
cx: &mut Cx,
) {
if *until_attack <= 0 && m.within_melee_range(t) && m.sees(cx.w, who) {
*until_attack = cooldown;
super::ai::do_hurt_target(m, who, cx);
set_standing(m, false);
return;
}
let reach = t.bounds.size().x as f32 + 3.0;
if m.distance_sqr(t.position) < f64::from(reach * reach) {
if *until_attack <= 0 {
set_standing(m, false);
*until_attack = cooldown;
}
if *until_attack <= 10 {
set_standing(m, true);
play_warning_sound(m, cx);
}
} else {
*until_attack = cooldown;
set_standing(m, false);
}
}
/// `PolarBear.tick`'s client half: `clientSideStandAnimation` follows the
/// synched standing flag by one tick a tick. A client of a server runs it
/// too.
pub fn client_tick(m: &mut Mob) {
let v = m.anim.v[STAND];
m.anim.vo[STAND] = v;
let step = if m.extra.flag { 1.0 } else { -1.0 };
m.anim.v[STAND] = (v + step).clamp(0.0, STAND_TICKS);
}
/// `PolarBear.getStandingAnimationScale`.
pub fn standing_scale(m: &Mob, partial: f32) -> f32 {
m.anim.lerp(STAND, partial) / STAND_TICKS
}
/// `PolarBear.tick`: the stand animation, the warning sound's delay and
/// `NeutralMob.updatePersistentAnger`.
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
client_tick(m);
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
if m.ai.target.is_some() {
// Anger follows the target and starts over every tick it is held:
// 20 to 39 seconds (`PERSISTENT_ANGER_TIME`).
m.extra.flag2 = m.ai.target == Some(Who::Player);
m.extra.anger = 400 + m.random.next_int_bound(381).unwrap_or(0);
} else if m.extra.anger > 0 {
m.extra.anger -= 1;
if m.extra.anger == 0 {
stop_being_angry(m);
}
}
// A player in creative or spectator mode is forgiven at once.
if m.extra.flag2 && w.player.as_ref().is_some_and(|p| p.creative || p.spectator) {
stop_being_angry(m);
}
}
/// `NeutralMob.stopBeingAngry`.
fn stop_being_angry(m: &mut Mob) {
m.ai.last_hurt_by = None;
m.ai.target = None;
m.extra.anger = 0;
m.extra.flag2 = false;
}
+16 -11
View File
@@ -232,8 +232,10 @@ pub fn drowned_travel(
m.velocity = Vec3::new(m.velocity.x * 0.9, m.velocity.y * 0.9, m.velocity.z * 0.9);
true
}
/// The trident throw of `Drowned.performRangedAttack`, until thrown tridents
/// exist: an arrow-like shot with the trident's damage.
/// The trident throw of `Drowned.performRangedAttack`: a `ThrownTrident` of
/// the held trident (a plain one when it holds none), owned by the drowned,
/// which cannot be picked up and does not return (`AbstractArrow` constructor:
/// only a player's projectile is `Pickup.ALLOWED`).
fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let Some(t) = cx.w.target_info(who) else {
return;
@@ -243,15 +245,18 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let yd = t.position.y + (t.bounds.max_position().y - t.bounds.min_position().y) / 3.0 - start.y;
let zd = t.position.z - m.position.z;
let dist = (xd * xd + zd * zd).sqrt();
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 8.0,
critical: false,
effect: None,
},
start,
Some(Who::Mob(m.id)),
);
let mut trident = crate::items::ItemStack::new("minecraft:trident", 1);
if m.extra.main_hand.as_deref() == Some("minecraft:trident")
&& let Some(g) = super::gear::gear(m)
{
for (e, l) in &g.slots[super::gear::MAIN] {
trident.enchant(e, *l);
}
}
let mut p = Projectile::new(ProjectileKind::Trident, start, Some(Who::Mob(m.id)));
if let Some(a) = p.arrow.as_mut() {
a.set_item(&trident);
}
p.shoot(
&mut crate::projectiles::own_random(m),
[xd, yd + dist * f64::from(0.2_f32), zd],
+55
View File
@@ -226,6 +226,14 @@ pub enum MotionEvent {
bubble: bool,
position: Vec3,
},
/// `HoneyBlock.maybeDoSlideEffects` for a player sliding down the side of
/// the honey block at `pos`: its slide sound, and the server's entity
/// event 53 (the five block particles at the player).
HoneySlide {
pos: BlockPos,
sound: bool,
particles: bool,
},
/// Entity event 67 (`LivingEntity.makeDrownParticles`): eight bubbles
/// around a drowning player.
Drown,
@@ -1449,6 +1457,9 @@ impl MotionWorld<'_, '_, '_> {
"minecraft:sweet_berry_bush" if damage_only => {
self.sweet_berry_bush_damage(state)?;
}
// HoneyBlock.entityInside: a server's player slides too, so
// its fall distance resets (its client moves it).
"minecraft:honey_block" => self.honey_block_inside(p)?,
// PowderSnowBlock.entityInside's FREEZE.
"minecraft:powder_snow" if damage_only => entered.push((step, p)),
_ => {}
@@ -1479,6 +1490,50 @@ impl MotionWorld<'_, '_, '_> {
}
Ok(iterations + 1)
}
/// `HoneyBlock.entityInside`: a falling player touching a side of the block
/// slides down it (`isSlidingDown`, `doSlideMovement`, `maybeDoSlideEffects`).
fn honey_block_inside(&mut self, pos: BlockPos) -> Result<()> {
let old_delta_y = |dy: f64| dy / f64::from(0.98_f32) + 0.08;
let new_delta_y = |dy: f64| (dy - 0.08) * f64::from(0.98_f32);
let position = self.player.position;
let velocity = self.player.velocity;
if self.player.ground.on_ground
|| position.y > f64::from(pos.y) + 0.9375 - 1.0e-7
|| old_delta_y(velocity.y) >= -0.08
{
return Ok(());
}
let dx = (f64::from(pos.x) + 0.5 - position.x).abs();
let dz = (f64::from(pos.z) + 0.5 - position.z).abs();
let overlap = 0.4375 + f64::from(self.player.dimensions.width / 2.0);
if !(dx + 1.0e-7 > overlap || dz + 1.0e-7 > overlap) {
return Ok(());
}
// doSlideMovement
let old = old_delta_y(velocity.y);
if old < -0.13 {
let factor = -0.05 / old;
self.player.velocity =
Vec3::new(velocity.x * factor, new_delta_y(-0.05), velocity.z * factor);
} else {
self.player.velocity = Vec3::new(velocity.x, new_delta_y(-0.05), velocity.z);
}
self.player.fall_distance = 0.0;
// maybeDoSlideEffects: the sound, then the server's event 53.
let random = &mut self.scene.random;
let sound = random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0;
// Only the server broadcasts event 53 (`!level.isClientSide()`).
let particles = !self.player.client_side
&& random.next_int_bound(5).map_err(|e| format!("{e:?}"))? == 0;
if sound || particles {
self.player.events.push(MotionEvent::HoneySlide {
pos,
sound,
particles,
});
}
Ok(())
}
/// `Player.makeStuckInBlock`: the next move is scaled by `multiplier`
/// (and the fall forgotten) unless flying; the impulse context resets
/// either way.
+17 -4
View File
@@ -1176,16 +1176,17 @@ impl ParticleEngine {
}
}
/// `ClientPacketListener.handleExplosion` + `ClientExplosionTracker`: the
/// explosion particle (emitter for radius >= 2) and up to 512 poof/smoke
/// particles thrown from air inside the radius.
/// explosion particle (the emitter unless `ServerExplosion.isSmall`) and up
/// to 512 poof/smoke particles thrown from air inside the radius.
pub fn explosion(
&mut self,
center: [f64; 3],
radius: f32,
small: bool,
block_count: usize,
radius: f32,
is_air: &dyn Fn([i32; 3]) -> bool,
) {
let ty = if radius < 2.0 {
let ty = if small {
ParticleType::Explosion
} else {
ParticleType::ExplosionEmitter
@@ -1193,6 +1194,18 @@ impl ParticleEngine {
self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
self.explosion_blocks(center, radius, block_count, is_air);
}
/// `LlamaSpit.recreateFromPacket`: seven spit particles thrown ahead of a
/// new spit, each faster than the last along x and z.
pub fn llama_spit_burst(&mut self, at: [f64; 3], movement: [f64; 3]) {
for i in 0..7 {
let k = 0.4 + 0.1 * f64::from(i);
self.add(
&ParticleOptions::simple(ParticleType::Spit),
at,
[movement[0] * k, movement[1], movement[2] * k],
);
}
}
/// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`.
pub fn explosion_blocks(
&mut self,
+11 -5
View File
@@ -28,6 +28,10 @@ pub enum ProjectileKind {
critical: bool,
effect: Option<(&'static str, i32)>,
},
/// `ThrownTrident` of a mob (a drowned's): flies and sticks with
/// `AbstractArrow`'s tick; the trident itself is the arrow state's
/// `pickup_item`.
Trident,
Snowball,
Egg,
EnderPearl,
@@ -66,6 +70,7 @@ impl ProjectileKind {
pub fn id(&self) -> &'static str {
match self {
Self::Arrow { .. } => "minecraft:arrow",
Self::Trident => "minecraft:trident",
Self::Snowball => "minecraft:snowball",
Self::Egg => "minecraft:egg",
Self::EnderPearl => "minecraft:ender_pearl",
@@ -152,7 +157,7 @@ fn axis_of(d: u8) -> u8 {
}
impl Projectile {
pub fn new(kind: ProjectileKind, position: Vec3, owner: Option<Who>) -> Self {
let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. });
let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. } | ProjectileKind::Trident);
Self {
id: 0,
kind,
@@ -253,7 +258,7 @@ impl Projectile {
}
fn gravity(&self) -> f64 {
match self.kind {
ProjectileKind::Arrow { .. } => 0.05,
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => 0.05,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
@@ -604,8 +609,8 @@ impl Projectile {
| ProjectileKind::WitherSkull { .. } => {
self.apply_hurting_inertia(water);
}
// Arrows tick with `arrows::tick`.
ProjectileKind::Arrow { .. } => {}
// Arrows and tridents tick with `arrows::tick`.
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => {}
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => {}
// LlamaSpit.tick: gone in water; drag and gravity follow the move.
ProjectileKind::LlamaSpit => {
@@ -746,7 +751,8 @@ impl Projectile {
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => return None,
ProjectileKind::WitherSkull { dangerous: false } => "entity/wither/wither",
ProjectileKind::WitherSkull { dangerous: true } => "entity/wither/wither_invulnerable",
ProjectileKind::Arrow { .. } => return None,
// Drawn as their models by the game.
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => return None,
})
}
/// Render scale of the item billboard (fireball renderers).
+19 -15
View File
@@ -196,23 +196,27 @@ fn text(nbt: &Compound, key: &str) -> Option<String> {
/// The client block entity for one of a chunk's generated block entities,
/// before a container's loot is unpacked: `StructureTemplate.placeInWorld`
/// hands the template's block entity NBT to the level's block entity.
/// hands the template's block entity NBT to `loadWithComponents`, which is
/// what a chunk load does with a saved one.
fn generated_block_entity(block: &str, nbt: &Compound) -> Option<BlockEntity> {
let mut entity = BlockEntity::for_block(block)?;
match &mut entity {
// MonsterRoomFeature and the structures' setEntityId.
BlockEntity::Spawner(s) => {
**s = crate::mobs::spawner::Spawner::load(&Tag::Compound(nbt.clone()));
}
BlockEntity::TrialSpawner(s) => {
**s = crate::mobs::trial_spawner::TrialSpawner::load(&Tag::Compound(nbt.clone()));
}
BlockEntity::Vault(v) => **v = crate::vault::Vault::load(&Tag::Compound(nbt.clone())),
// BannerBlockEntity.loadAdditional: the End cities' black triangles.
BlockEntity::Banner(b) => *b = crate::save::decode_banner(&Tag::Compound(nbt.clone())),
_ => {}
let fallback = BlockEntity::for_block(block)?;
let mut tag = nbt.clone();
// RandomizableContainer.tryLoadLootTable: with a table the items are not read.
if text(&tag, "LootTable").is_some() {
tag.remove(&JavaString::from("Items"));
}
Some(entity)
// Programmatic block entities (and a template that saved none) carry no id.
if !tag.contains_key(&JavaString::from("id")) {
tag.insert("id".into(), Tag::String(fallback.id().into()));
}
for axis in ["x", "y", "z"] {
tag.insert(axis.into(), Tag::Int(0));
}
// A saved kind the client cannot decode keeps the block's own entity.
Some(
crate::save::decode_block_entity(&Tag::Compound(tag))
.map_or(fallback, |(_, entity)| entity),
)
}
/// Client block entities for the generated block entities of a chunk, and the
+1
View File
@@ -186,6 +186,7 @@ impl Server {
attributes: Vec::new(),
bed: None,
living_flags: 0,
body: Default::default(),
};
let mut ctx = TickCtx {
world: &mut self.world,
@@ -2249,6 +2249,11 @@ packet!(
/// Input flags: forward 1, backward 2, left 4, right 8, jump 16, shift 32, sprint 64.
PlayerInput { flags: u8 => UByte }
);
packet!(
/// ServerboundSelectBundleItemPacket: the menu slot of a bundle and the
/// index of its selected item (-1: none).
SelectBundleItem { slot: i32 => VarInt, index: i32 => VarInt }
);
packet!(SetCarriedItem { slot: i16 => Short });
packet!(SetCreativeModeSlot { slot: i16 => Short, item: Option<Stack> => Slot });
packet!(UseItem { hand: i32 => VarInt, sequence: i32 => VarInt, yaw: f32 => Float, pitch: f32 => Float });
@@ -2317,6 +2322,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
ContainerSlotStateChanged(ContainerSlotStateChanged) = CONTAINER_SLOT_STATE_CHANGED,
Punch(Empty) = PUNCH,
ResourcePack(ResourcePackResponse) = RESOURCE_PACK,
SelectBundleItem(SelectBundleItem) = BUNDLE_ITEM_SELECTED,
SelectTrade(SelectTrade) = SELECT_TRADE,
SetBeacon(SetBeacon) = SET_BEACON,
SetCarriedItem(SetCarriedItem) = SET_CARRIED_ITEM,
+8 -1
View File
@@ -1174,7 +1174,8 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.game_event(pos, "minecraft:note_block_play");
}
}
/// `NoteBlock.triggerEvent`: the note's sound.
/// `NoteBlock.triggerEvent`: the note's particle (tunable instruments)
/// and sound.
pub(crate) fn note_trigger_event(&mut self, pos: BlockPos, state: BlockStateId) {
let b = self.b;
let instrument = b.table.text(state, b.props.instrument).to_owned();
@@ -1186,6 +1187,12 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
let pitch = if mob_head {
1.0
} else {
// `isTunable`: the client adds the NOTE particle above the block.
self.emit(LevelEvent::BlockEvent {
pos,
state,
kind: "note_particle",
});
horizon_core::jmath::pow(2.0, f64::from(note - 12) / 12.0) as f32
};
let sound = match instrument.as_str() {
@@ -275,16 +275,15 @@ fn terrain(name: Option<&str>) -> TerrainAdjustment {
_ => TerrainAdjustment::None,
}
}
fn pair(value: Option<&Value>, a: &str, b: &str, default: i32) -> [i32; 2] {
/// A jigsaw field given as one number or as an object of two. In the object a
/// missing `a` is 0 and a missing `b` is `missing_b` (`JigsawStructure.MaxDistance`
/// and `DimensionPadding` default their second value, not copy the first).
fn pair(value: Option<&Value>, a: &str, b: &str, default: i32, missing_b: i32) -> [i32; 2] {
match value {
Some(Value::Number(n)) => [n.as_i64().unwrap_or(0) as i32; 2],
Some(Value::Object(o)) => {
let get = |k: &str| o.get(k).and_then(Value::as_i64).unwrap_or(0) as i32;
let first = get(a);
[
first,
o.get(b).and_then(Value::as_i64).map_or(first, |v| v as i32),
]
let get = |k: &str| o.get(k).and_then(Value::as_i64).map(|v| v as i32);
[get(a).unwrap_or(0), get(b).unwrap_or(missing_b)]
}
_ => [default; 2],
}
@@ -326,9 +325,11 @@ impl StructureRegistry {
"horizontal",
"vertical",
80,
// DimensionType.Y_SIZE.
4064,
),
aliases: v["pool_aliases"].as_array().cloned().unwrap_or_default(),
padding: pair(v.get("dimension_padding"), "bottom", "top", 0),
padding: pair(v.get("dimension_padding"), "bottom", "top", 0, 0),
waterlogging: v["liquid_settings"].as_str() != Some("ignore_waterlogging"),
checks: super::start_checks::StartChecks::decode(v.get("horizon:start_checks")),
piece_limited: v
+128 -3
View File
@@ -331,6 +331,30 @@ Keep an instance on one installed Horizon version, or "latest" to follow updates
MCP: `instance_version`.
## `horizon-launcher instance export`
Export an instance (or a port) to share: its settings, packs, Horizon mods and the worlds named, as a .mrpack (resource packs from Modrinth listed by download) or a zip. Never logs, screenshots or account data; worlds go without their per-player files.
horizon-launcher instance export [INSTANCE]
- `INSTANCE`: The instance (default: the selected one)
- `--format` (mrpack, zip)
- `--world`: A world to put in it (its name or folder); repeat for more
- `--out`: Where to write it (default: "<name>.mrpack" in the current folder)
MCP: `instance_export`.
## `horizon-launcher instance import`
Import what a file is, as dropping it on the window does: a shared instance (a .mrpack without mod loaders, such as `instance export` writes, or a game folder zip) becomes a new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into the instance (default: the selected one).
horizon-launcher instance import <FILE>
- `FILE`
- `--instance`: The instance a pack goes into
MCP: `instance_import`.
## `horizon-launcher worlds list`
The worlds: name, mode, version, last played and size (the default).
@@ -374,6 +398,99 @@ Move a world to the system's trash.
MCP: `worlds_delete`, needs `confirm: true`.
## `horizon-launcher worlds datapack-list`
A world's data packs, and which are on.
horizon-launcher worlds datapack-list <WORLD>
- `WORLD`
- `--instance`
MCP: `worlds_datapack_list`.
## `horizon-launcher worlds datapack-add`
Copy a data pack zip into a world (the game turns it on when the world opens).
horizon-launcher worlds datapack-add <WORLD> <FILE>
- `WORLD`
- `FILE`
- `--instance`
MCP: `worlds_datapack_add`.
## `horizon-launcher worlds datapack-remove`
Move a world's data pack to the system's trash.
horizon-launcher worlds datapack-remove <WORLD> <PACK>
- `WORLD`
- `PACK`: Its file name, with or without .zip
- `--instance`
MCP: `worlds_datapack_remove`, needs `confirm: true`.
## `horizon-launcher packs list`
The resource and shader packs, and which are on (the default).
horizon-launcher packs list
- `--kind` (resource, shader): Only this kind
- `--instance`: The instance (default: the selected one)
MCP: `packs_list`.
## `horizon-launcher packs add`
Copy a pack file in: a resource pack .zip, a shader pack .zip or a .hshader.
horizon-launcher packs add <FILE>
- `FILE`
- `--instance`
MCP: `packs_add`.
## `horizon-launcher packs on`
Turn a pack on (a resource pack goes on top; a shader pack replaces the one that was on).
horizon-launcher packs on <PACK>
- `PACK`: Its file name or title
- `--kind` (resource, shader)
- `--instance`
MCP: `packs_on`.
## `horizon-launcher packs off`
Turn a pack off.
horizon-launcher packs off <PACK>
- `PACK`
- `--kind` (resource, shader)
- `--instance`
MCP: `packs_off`.
## `horizon-launcher packs remove`
Turn a pack off and move it to the system's trash.
horizon-launcher packs remove <PACK>
- `PACK`
- `--kind` (resource, shader)
- `--instance`
MCP: `packs_remove`, needs `confirm: true`.
## `horizon-launcher screenshots list`
The screenshots, the newest first, with their files (the default).
@@ -479,10 +596,12 @@ MCP: `modpack_remove`, needs `confirm: true`.
## `horizon-launcher mods list`
What the browser installed (the default).
What the browser installed in an instance (the default).
horizon-launcher mods list
- `--instance`: The instance (default: the selected one)
MCP: `mods_list`.
## `horizon-launcher mods search`
@@ -492,7 +611,7 @@ Search for packs Horizon loads.
horizon-launcher mods search [TEXT]...
- `TEXT`
- `--kind` (mod, resource, modpack, shader)
- `--kind` (mod, resource, modpack, shader, datapack)
- `--everything`: Also what was made for other versions or runs only partly, each marked
- `--source` (modrinth, curseforge, horizon)
@@ -512,11 +631,13 @@ MCP: `mods_check`.
## `horizon-launcher mods install`
Install a pack and what it needs, by its id or slug (checksums checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported.
Install a pack and what it needs into an instance, by its id or slug (checksums checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported as an instance of its own; a data pack goes into the world `--world` names.
horizon-launcher mods install <PROJECT>
- `PROJECT`
- `--world`: Install the project's data pack into this world of the instance (its name or folder)
- `--instance`: The instance (default: the selected one)
- `--everything`: Allow a version made for another Minecraft version
- `--source` (modrinth, curseforge, horizon)
@@ -530,6 +651,7 @@ Update what the browser installed (or one pack).
- `PROJECT`
- `--check`: Only say what is newer
- `--instance`: The instance (default: the selected one)
MCP: `mods_update`.
@@ -540,6 +662,7 @@ Remove a pack the browser installed.
horizon-launcher mods remove <PROJECT>
- `PROJECT`
- `--instance`: The instance (default: the selected one)
MCP: `mods_remove`, needs `confirm: true`.
@@ -550,6 +673,7 @@ Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > M
horizon-launcher mods enable <PROJECT>
- `PROJECT`
- `--instance`: The instance (default: the selected one)
MCP: `mods_enable`.
@@ -560,6 +684,7 @@ Turn off a mod the browser installed; it stays installed.
horizon-launcher mods disable <PROJECT>
- `PROJECT`
- `--instance`: The instance (default: the selected one)
MCP: `mods_disable`.
+10 -2
View File
@@ -1,6 +1,6 @@
# The launcher's mod browser
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks and resource packs, and Horizon's
Search Modrinth (CurseForge support is coming soon) for Java mods, modpacks, resource packs and data packs, and Horizon's
registry for Horizon's own mods, modpacks, resource packs and shader packs; see how well each one runs before installing it, and install it with its checksum checked. This is item 16 of the roadmap and issue #577. The code is in
`launcher/src/browser.rs` and `launcher/src/browser/`.
@@ -17,6 +17,12 @@ yet (Modrinth has no `horizon` loader).
| Resource packs | `resourcepacks/` beside `options.txt` | **Works** when made for 26.3 ([resource-packs.md](resource-packs.md) lists what Horizon reads). Older packs are **Partly**: they load with vanilla's compatibility warning |
| Modpacks (`.mrpack`, CurseForge zip) | the Modpacks page's import | **Partly**: Horizon runs the mods it can port, from the player's own downloads ([modpacks.md](modpacks.md)) |
| Shader packs (`.hshader`, Horizon's registry only) | `shaderpacks/` beside `options.txt` | **Works** when made for 26.3: checked by the game's own check before install ([shaders.md](shaders.md)). The `shaders/`-format packs on Modrinth and CurseForge are not offered |
| Data packs (Modrinth) | `datapacks/` of the world picked on the **Data packs** tab (`mods install <pack> --world <world>`); not in the Installed list: each world's list on Home has them | **Partly**: Horizon runs a data pack's functions, function tags, predicates and item modifiers, not yet its recipes, loot tables, advancements, other tags or worldgen. The install reads the zip and says what of it runs; a pack with none of those (worldgen only, such as Tectonic) is refused |
Mods, resource packs and shader packs go into the game folder of the instance selected in the sidebar, and the
Installed list, updates and Remove are that instance's: picking another instance reads its list. The page says which
instance it installs into when there is more than one. The CLI's `mods list|install|update|remove|enable|disable` take
`--instance <name>` (default: the selected one). A modpack becomes an instance of its own.
By default a search shows only what runs: Java mods made for 26.3, resource packs made for 26.3, and every modpack. **Show everything** lists the rest of the kind too, each marked with how well it runs; what doesn't run is
never offered for install.
@@ -82,7 +88,8 @@ downloads, source and licence, how well it runs (and why), **Page** (the project
**Install** (**Import** for a modpack). The Modpacks tab lists the imported packs first, with their coverage
report, **Port** and **Play** ([modpacks.md](modpacks.md)). Installed packs are listed above the results with their version, source,
file and the checksum they were checked against, with **Update to …** when a newer version runs and **Remove**
(asked twice).
(asked twice). **Update all (N)** above the list updates every pack a newer version was found for, of every kind,
one after the other; one that fails is named and doesn't stop the others.
### Project pages, filters and icons
@@ -130,6 +137,7 @@ same name the player put there is never overwritten.
pack still has to be turned on in the game's Resource Packs screen, as in vanilla, and a shader pack picked in
the game's Shader Packs page.
- **Update** installs the newer version and removes the old file.
- **Update all** does the same for each pack with a newer version (`horizon-launcher mods update` with no pack name).
- **Remove** deletes the file and its entry. Packs installed as dependencies stay.
- **A modpack** is downloaded (checked the same way) into `ports/downloads/`, imported, and ported straight away
for the selected version when it is prepared (`modpacks::import_and_port`): the jars of the mods Horizon can carry
+2 -2
View File
@@ -40,13 +40,13 @@ The game logs into `HORIZON_HOME` and falls back to the player's own game folder
### Other machines
Harnesses can also run on the Mac mini, the desktop and playhorizon (`tools/parity_hosts.json` describes them; `desktop` and `playhorizon` use their CI runner's environment and vanilla data, and the desktop refuses work, or stops it, while someone uses it: docs/release-flow.md, "The desktop's runner"; its `default` list is empty, so pass `--hosts macmini,playhorizon` or set `PARITY_HOSTS`, after copying `shared-local/` to `~/code/horizon-parity/shared-local` on each host). The runner mirrors the worktree there with rsync (not `.git`, `.local`, `target` or ignored files) into `~/code/horizon-parity/remote/<host><path>`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 8, playhorizon 6. Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here.
Harnesses can also run on the Mac mini, the desktop and playhorizon (`tools/parity_hosts.json` describes them; `desktop` and `playhorizon` use their CI runner's environment and vanilla data, and the desktop refuses work, or stops it, while someone uses it: docs/release-flow.md, "The desktop's runner"; its `default` list is empty, so pass `--hosts macmini,playhorizon` or set `PARITY_HOSTS`, after copying `shared-local/` to `~/code/horizon-parity/shared-local` on each host). The runner mirrors the worktree there with rsync (not `.git`, `.local`, `target` or ignored files) into `~/code/horizon-parity/remote/<host><path>`, whose `.local` links to the vanilla data in `~/code/horizon-parity/shared-local`, and starts `tools/parity.py --worker` over ssh. The worker builds the probes (about 80 s the first time) and runs what it is sent under the same tracer, admitting harnesses against its own free memory. One queue, longest first, feeds every machine, remote ones first: the Mac takes up to 14 harnesses, the desktop 8, playhorizon 3 (on CPUs 0-3: its CI runner has 4-7). Each pass comes back with the content hash of every file it depended on, so it is keyed on exactly what ran, and logs come back to `.local/parity-runner/logs/`; state, sharing and `gate.py` work as before. If a host can't be reached, or drops out, its harnesses run here.
Harnesses a host can't run are listed in the file: `local_only` (`save_roundtrip` runs the game on a virtual screen) and each host's `exclude`. The Mac excludes nothing: vanilla's `Math.sin`, `pow`, `log` and the like don't round the same on an Apple silicon Mac as on Linux (HotSpot uses fdlibm there, Intel's stubs on x86-64), and Rust's float functions round like neither (they call the platform libm), so code that mirrors a vanilla `Math` call uses `horizon_core::jmath`, which computes what HotSpot computes on the host architecture. The ThinkPad no longer runs harnesses (2026-10-26): playhorizon is the Linux host that is always on.
### Any command on playhorizon: `tools/remote.sh`
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 4-7 (`--cpus`; 0-3 stay free for benchmarks), with scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
For Linux work that doesn't need a whole parity run (a crate's tests, a few harnesses, a server build), `tools/remote.sh <worktree> <command...>` runs the command on playhorizon at the worktree's commit and streams its output back; its exit status is the command's. Commit and push first: the host fetches the commit from GitHub, and nothing else leaves this machine (no files, no credentials, no environment). Each worktree keeps one checkout there, `~/remote-work/<name>` with its own `target/`, so the next run only rebuilds what changed, and one run per name goes at a time. The command runs niced in the host's CI slice, pinned to CPUs 0-3 (`--cpus`): the CI runner has 4-7, so these runs never slow a pull request's parity job. A benchmark asks for its CPUs with `--cpus 0-3 --exclusive` (and says so in its pull request): it starts only when no other run uses them, and later runs wait for it to end. The command gets scratch game and launcher homes and the vanilla data linked as CI links it; a command naming `parity`, `lan_e2e` or `horizon-prepare` also gets `.local/horizon-client`, prepared as CI prepares it. Whatever the command leaves running (a game, a daemon) is stopped when it ends, and stopping `remote.sh` (or losing the connection) stops the command on the host. Logs stay in `~/remote-work/<name>.logs/`; `--list` shows the checkouts, `--gc [DAYS]` removes old ones.
```sh
tools/remote.sh .claude/worktrees/my-fix cargo test -p horizon-world --lib # 33 s, warm
+7 -4
View File
@@ -108,12 +108,12 @@ and its failures bisect and comment on merges.
AMD EPYC threads, 30 GB, in Detroit; reachable only through the maintainer's tailnet). Nobody sits at it, so the
watcher never pauses it except by hand. It gets half the machine: `RUNNER_MEMORY=24`, `CARGO_BUILD_JOBS` and the
harness slots at 4, and a drop-in (`~/.config/systemd/user/horizon-runner.service.d/cpus.conf`) that starts it under
`taskset -c 4-7`, so CPUs 0-3 stay free for benchmarks pinned there (the user slice can't set `AllowedCPUs`: systemd
doesn't delegate the cpuset controller to it).
`taskset -c 4-7` (the user slice can't set `AllowedCPUs`: systemd doesn't delegate the cpuset controller to it).
Agents' `tools/remote.sh` runs and benchmarks share CPUs 0-3, so they never slow a pull request's job.
Without Nix, `linux-runner.sh` takes the build libraries, the JDK, sccache, Xvfb and Mesa's software Vulkan
(lavapipe; there is no GPU) from apt, and Python 3.13 from uv. The partner can reach the machine, so gh is not
logged in there: the registration token comes from another machine (`RUNNER_TOKEN`), and no job with secrets may
run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 6 jobs). Since
run there (parity has none). It is also a parity host (`playhorizon` in `tools/parity_hosts.json`, 3 jobs on CPUs 0-3). Since
2026-10-26 it has the `horizon-game` label: pull requests' parity jobs run there or on the Mac mini, and `full-parity`
ones only there.
@@ -432,6 +432,9 @@ Releases ship often, so nothing they leave behind may grow without bound.
(`~/.actions-runners/runner-gc.sh`, plist `dev.horizon.runner-gc`), never from a pull request's checkout. It skips a
runner in a job, removes a checkout's cargo build folders past 12 GB (`MAX_TARGET_GB`) and harness work folders
under `.local/` untouched for 3 days (`STALE_DAYS`), and keeps the 2 newest shared preparations (`KEEP_PREPARED`)
while no runner works. Runner builds go through the machine's sccache (`tools/ci/runner-env.sh`), so what it removes
while no runner works. It also stops harness JVMs (vanilla servers, oracles) left orphaned for 3 hours
(`ORPHAN_HOURS`): parent gone, command line under a runner's checkout or a worktree's `.local/`. `tools/parity.py`
stops its own harnesses on SIGTERM and SIGHUP (a cancelled job), so these come only from a SIGKILL or a harness run
by hand. Runner builds go through the machine's sccache (`tools/ci/runner-env.sh`), so what it removes
comes back from the cache. Update the copy after changing the script:
`ssh macmini 'curl -fsSL https://raw.githubusercontent.com/ludovic111/horizon/main/tools/ci/runner-gc.sh -o ~/.actions-runners/runner-gc.sh'`.
+1
View File
@@ -2764,6 +2764,7 @@ dependencies = [
"sha1",
"sha2 0.10.9",
"tar",
"toml 1.1.8+spec-1.1.0",
"trash",
"ureq",
"winresource",
+1
View File
@@ -19,6 +19,7 @@ sha2 = "0.10"
pulldown-cmark = { version = "0.12", default-features = false }
flate2 = "1"
tar = { version = "0.4", default-features = false }
toml = "1.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
dirs = "5"
self-replace = "1"
+39 -4
View File
@@ -28,8 +28,8 @@ The opening, then Play ([clip](docs/opening.webm)):
packs and modpacks, each marked Works, Partly or Doesn't run in
Horizon; Java mod jars are never offered. Install puts a pack and what it needs in
`resourcepacks/` only after its SHA-512 matches, and a
modpack goes to the Modpacks page; installed packs show their source and checksum, with Update
and Remove ([docs/mod-browser.md](../docs/mod-browser.md)).
modpack goes to the Modpacks page; installed packs show their source and checksum, with Update,
**Update all** and Remove ([docs/mod-browser.md](../docs/mod-browser.md)).
- **Modpacks**: import a Java modpack (a Modrinth `.mrpack`, a CurseForge zip, or a Prism,
MultiMC or CurseForge instance folder) and see its coverage report: per mod, ported, partial,
not yet or not needed, with the reason ([docs/modpacks.md](../docs/modpacks.md)). **Port**
@@ -83,12 +83,42 @@ The opening, then Play ([clip](docs/opening.webm)):
instance plays the launcher's version, which follows updates, unless the Play page's version
picker keeps it on another installed one (to keep an old world on the version it was made
with); picking the version marked "follows updates" lets it follow again.
- **Export**: Home's Instance row has **Export…**: the selected instance (or a port) as a `.mrpack`
(Modrinth's modpack format: resource packs the browser installed from Modrinth are listed by
their download, the rest is in `overrides/`) or a `.zip` of the game folder's layout, with the
worlds switched on. It is written to `exports/` in the launcher's data folder (**Show in
folder**). Only settings (options.txt, horizon-*.txt, servers.dat), resource and shader packs,
`horizon-mods/` and `config/` go in: never logs, screenshots, backups, caches or account files;
a world goes without `playerdata/`, `stats/` and `advancements/`, and its level.dat without the
player's UUID. `horizon-launcher instance export [<instance>] [--format mrpack|zip] [--world
<w>]… [--out <file>]`.
- **Import by dropping a file**: a file dropped anywhere on the window is imported for what it is
(`horizon_launcher::import`). A shared instance (a `.mrpack` that needs no mod loader, such as
Export writes, or a game folder `.zip`) becomes a new instance, opened on Home. Its `.mrpack`
downloads come from Modrinth with their SHA-512 checked, and only what an instance keeps is
taken, inside its folder. A Java modpack goes to the Modpacks import, and a resource or shader
pack into the selected instance. A data pack is pointed at the world rows, and the Packs section
and the world rows keep taking their own drops. A note in the corner says what happened.
`horizon-launcher instance import <file>`.
- **Worlds**: Home lists the selected instance's worlds (picture, mode, version, last played,
size). **Back up** zips one into the instance's `backups/` as the game's Make Backup does
(`<date>_<time>_<folder>.zip`); **Copy** puts "<name> (1)" beside it; **Delete** moves it to the
system's trash after a confirmation; **Open folder**. The same from the command line:
`horizon-launcher worlds [list|backup|copy|delete] [--instance <name>]`.
**Play** on a world starts the game straight into it.
**Play** on a world starts the game straight into it. **Data packs** opens the world's
`datapacks/` under its row: what is there (and which the game has turned off), **Add data pack…**,
and **Remove** (to the trash); a data pack `.zip` dropped on a world goes into it. The game turns
a new one on when the world opens. From the command line: `horizon-launcher worlds
datapack-list|datapack-add|datapack-remove <world> …`. The Mods page's **Data packs** tab finds
them on Modrinth and installs one into the world picked there (`mods search --kind datapack`,
`mods install <pack> --world <world>`), saying what of it runs in Horizon.
- **Packs**: Home lists the selected instance's resource packs and shader packs, each with a switch.
The choice is the game's own: `resourcePacks` in options.txt (a pack turned on goes on top, as
the game's Resource Packs screen does) and `pickedShaderPack` in `horizon-graphics.txt` (one shader
pack at a time). **Add pack…** or a `.zip` / `.hshader` dropped on the section copies it into the
right folder; **Remove** turns it off and moves it to the trash after a confirmation. From the
command line: `horizon-launcher packs [list|add|on|off|remove] [--kind resource|shader]
[--instance <name>]`.
- **Screenshots**: Home shows the selected instance's newest screenshots (what F2 saves in its
`screenshots/` folder) and all of them on request. Clicking one opens it over the window (← and →
step through) with **Copy** (the picture, to the clipboard), **Open**, **Show in folder** and
@@ -202,6 +232,8 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
horizon-launcher instance version <instance> <tag|latest> keep it on one installed version, or follow updates
horizon-launcher worlds [list] [--instance <name>] an instance's worlds
horizon-launcher worlds backup|copy|delete <world> [--instance <name>] delete moves it to the trash
horizon-launcher packs [list] [--kind resource|shader] [--instance <name>] resource and shader packs, on or off
horizon-launcher packs add <file> | on|off|remove <pack> [--kind shader]
horizon-launcher screenshots [list] [--instance <name>] an instance's screenshots, the newest first
horizon-launcher screenshots delete <name> [--instance <name>] moves it to the trash
horizon-launcher multiplayer [list] [--instance <name>] the servers in an instance's Multiplayer list
@@ -233,7 +265,10 @@ and don't set `%ERRORLEVEL%` / `$LASTEXITCODE`. Scripts that need the exit code
`--json` prints JSON where there is something to report (`versions`, `install`, `notes`,
`next`, `links`, `game-args`, `update --check`, `logs`, `account`, `benchmark`, `data`, `modpack`). Errors go to stderr with exit code 1. `server` runs in the terminal,
so lines you type reach its console. `HORIZON_LAUNCHER_HOME` replaces the data folder (for
scripts and tests).
scripts and tests). Test runs also set `HORIZON_LAUNCHER_TRASH=<folder>` and
`HORIZON_LAUNCHER_CLIPBOARD=<file>` inside their scratch folders: what Delete would move to the
system's trash goes into that folder, and what Copy would put on the clipboard is written to that
file, so a test never touches the real trash or clipboard.
## AI agents (MCP)
+1 -1
View File
@@ -77,7 +77,7 @@ impl Launcher {
&t,
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
copy_to_clipboard(cx, gpui::ClipboardItem::new_string(text.clone()));
this.agents.copied = Some(id);
cx.notify();
}))
+52 -6
View File
@@ -3,8 +3,9 @@
//! matches (docs/mod-browser.md).
//!
//! Offered: Java mods (Fabric, NeoForge, Forge), whose content Horizon ports and whose jar is
//! checked before install ([`java`]), modpacks (imported and ported) and resource packs
//! (`resourcepacks/`). Java code itself doesn't run in Horizon (docs/mods.md).
//! checked before install ([`java`]), modpacks (imported and ported), resource packs
//! (`resourcepacks/`) and data packs (into a world, [`installed::install_datapack`]). Java code
//! itself doesn't run in Horizon (docs/mods.md).
pub mod curseforge;
pub mod details;
pub mod download;
@@ -31,10 +32,18 @@ pub enum Kind {
/// One of Horizon's own shader packs (`.hshader`, docs/shaders.md), from Horizon's registry
/// only.
Shader,
/// A data pack: it goes into one world's `datapacks/` (`crate::datapacks`).
DataPack,
}
impl Kind {
pub const ALL: [Kind; 4] = [Kind::Mod, Kind::Modpack, Kind::ResourcePack, Kind::Shader];
pub const ALL: [Kind; 5] = [
Kind::Mod,
Kind::Modpack,
Kind::ResourcePack,
Kind::Shader,
Kind::DataPack,
];
pub fn label(self) -> &'static str {
match self {
@@ -42,16 +51,17 @@ impl Kind {
Kind::ResourcePack => "Resource packs",
Kind::Modpack => "Modpacks",
Kind::Shader => "Shader packs",
Kind::DataPack => "Data packs",
}
}
/// The folder beside `options.txt` the file goes in; a modpack is imported and a mod
/// ported instead.
/// The folder beside `options.txt` the file goes in; a modpack is imported, a mod ported
/// and a data pack put in a world instead.
pub fn folder(self) -> Option<&'static str> {
match self {
Kind::ResourcePack => Some("resourcepacks"),
Kind::Shader => Some("shaderpacks"),
Kind::Modpack | Kind::Mod => None,
Kind::Modpack | Kind::Mod | Kind::DataPack => None,
}
}
}
@@ -132,6 +142,19 @@ impl Project {
pub fn support(&self) -> Support {
support(self.kind, &self.game_versions, &self.loaders)
}
/// The project as a data pack, for a search of data packs or an install into a world;
/// its page is Modrinth's data pack page.
pub fn as_datapack(self) -> Project {
Project {
page: match self.source {
SourceId::Modrinth => format!("https://modrinth.com/datapack/{}", self.slug),
_ => self.page,
},
kind: Kind::DataPack,
..self
}
}
}
/// One downloadable file of a version, with the checksums its source publishes.
@@ -286,6 +309,14 @@ pub fn support(kind: Kind, game_versions: &[String], loaders: &[String]) -> Supp
Support::Partial("Horizon runs the mods it can port, from your own downloads.".into())
}
Kind::Shader => Support::No("Not one of Horizon's own shader packs.".into()),
Kind::DataPack => {
let made_for = match newest_release(game_versions) {
_ if game_versions.iter().any(|v| v == GAME) => String::new(),
Some(newest) => format!("Made for {newest}: the game may refuse it. "),
None => "Lists no Minecraft version. ".into(),
};
Support::Partial(format!("{made_for}{DATAPACK_SUPPORT}"))
}
Kind::Mod => {
if !loaders.iter().any(|l| java::LOADERS.contains(&l.as_str())) {
Support::No("Not a Fabric, NeoForge or Forge mod.".into())
@@ -303,6 +334,12 @@ pub fn support(kind: Kind, game_versions: &[String], loaders: &[String]) -> Supp
}
}
/// What a data pack does in Horizon today, said before install; the install says it for the
/// pack's own files (`crate::datapacks::coverage`).
pub const DATAPACK_SUPPORT: &str = "Horizon runs a data pack's functions, function tags, \
predicates and item modifiers; not yet its recipes, loot tables, advancements, other tags \
or worldgen.";
/// A Minecraft version whose mods port-pack carries over: 1.21 and later (the data folders
/// took their current names in 1.21).
pub fn portable_version(v: &str) -> bool {
@@ -356,6 +393,15 @@ pub fn pick(kind: Kind, versions: &[Version], everything: bool) -> Option<&Versi
.find(made_for)
.or_else(|| usable.filter(java).find(|v| everything && ported(v)));
}
if kind == Kind::DataPack {
// Most data packs on Modrinth ship a mod build too: only the data pack files.
let mut packs = usable.filter(|v| v.loaders.iter().any(|l| l == "datapack"));
let made_for = |v: &&Version| v.game_versions.iter().any(|g| g == GAME);
return packs
.clone()
.find(made_for)
.or_else(|| packs.find(|_| everything));
}
let runs = |v: &&Version| match support(kind, &v.game_versions, &v.loaders) {
Support::Works => true,
// A modpack's version is for its own Minecraft.
+1
View File
@@ -25,6 +25,7 @@ fn class(kind: Kind) -> u64 {
Kind::Modpack => 4471,
Kind::Mod => 6,
Kind::Shader => 6552,
Kind::DataPack => 6945,
}
}
+1
View File
@@ -484,6 +484,7 @@ fn relative(kind: Kind, id: &str, file_name: &str) -> Result<String, String> {
Kind::ResourcePack => Ok(format!("resourcepacks/{file_name}")),
Kind::Shader => Ok(format!("shaderpacks/{file_name}")),
Kind::Modpack => Err("A modpack installs as its own profile (pack::install).".into()),
Kind::DataPack => Err("Horizon's registry has no data packs.".into()),
}
}
+62
View File
@@ -296,5 +296,67 @@ pub fn fetch_modpack(
source.fetch(file, folder, progress)
}
/// A data pack installed into a world.
pub struct DataPackInstall {
pub pack: crate::datapacks::DataPack,
/// Its version's number.
pub number: String,
/// What of it runs in Horizon.
pub coverage: crate::datapacks::Coverage,
}
/// Downloads a project's data pack (checksum checked) into the world folder `world`, under a
/// free name. Refused when nothing in it runs in Horizon yet. It isn't recorded with the
/// browser's installs: the world's data pack list (Home, `worlds datapack-list`) has it.
pub fn install_datapack(
source: &dyn Source,
project: &Project,
everything: bool,
world: &Path,
progress: &dyn Fn(f32),
) -> Result<DataPackInstall, String> {
if project.kind != Kind::DataPack && !project.loaders.iter().any(|l| l == "datapack") {
return Err(format!("{} isn't a data pack.", project.title));
}
let versions = source.versions(project)?;
let version = pick(Kind::DataPack, &versions, everything).ok_or_else(|| {
format!(
"{} has no data pack for {}{}.",
project.title,
super::GAME,
if everything {
" or any other version"
} else {
""
}
)
})?;
let file = version.file().expect("pick keeps versions with a file");
let folder = std::env::temp_dir().join(format!(
"horizon-launcher-datapack-{}-{}",
std::process::id(),
version.id
));
let result = source.fetch(file, &folder, progress).and_then(|zip| {
let coverage = crate::datapacks::coverage(&zip)?;
if coverage.read.is_empty() {
return Err(format!(
"{} would change nothing in Horizon yet: it has no functions, predicates or item \
modifiers, and Horizon doesn't read its {} yet.",
project.title,
coverage.not_read()
));
}
let pack = crate::datapacks::add(world, &zip)?;
Ok(DataPackInstall {
pack,
number: version.number.clone(),
coverage,
})
});
let _ = fs::remove_dir_all(&folder);
result
}
#[cfg(test)]
mod tests;
+88
View File
@@ -340,3 +340,91 @@ fn shader_packs_older_launchers_installed_are_left_alone() {
assert!(home.join("shaderpacks/old-pack.zip").is_file());
fs::remove_dir_all(&home).unwrap();
}
/// A data pack zip with these files.
fn datapack_zip(files: &[&str]) -> Vec<u8> {
use std::io::Write;
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default();
zip.start_file("pack.mcmeta", options).unwrap();
zip.write_all(br#"{"pack":{"pack_format":94,"description":"Test"}}"#)
.unwrap();
for name in files {
zip.start_file(*name, options).unwrap();
zip.write_all(b"{}").unwrap();
}
zip.finish().unwrap().into_inner()
}
#[test]
fn data_packs_go_into_a_world_when_something_in_them_runs() {
let world = home("datapack").join("saves/New World");
fs::create_dir_all(&world).unwrap();
let mut fake = Fake::default();
let mut tools = project("tools", Kind::DataPack);
tools.loaders = vec!["datapack".into()];
fake.add(tools);
let mut terrain = project("terrain", Kind::Mod);
terrain.loaders = vec!["datapack".into(), "fabric".into()];
fake.add(terrain);
let mut jar = project("jar", Kind::Mod);
jar.loaders = vec!["fabric".into()];
fake.add(jar);
let runs = datapack_zip(&[
"data/t/function/a.mcfunction",
"data/t/function/b.mcfunction",
"data/minecraft/tags/function/load.json",
"data/t/recipe/r.json",
]);
fake.release("tools", "2.0", &[], &runs, false);
fake.release(
"terrain",
"1.0",
&[],
&datapack_zip(&["data/t/worldgen/biome/b.json"]),
false,
);
let tools = fake.project("tools").unwrap();
let off = &|_: f32| {};
let done = install_datapack(&fake, &tools, false, &world, off).unwrap();
assert_eq!(done.pack.file, "tools-2.0.zip");
assert!(done.pack.enabled);
assert_eq!(done.number, "2.0");
assert_eq!(
done.coverage.summary(),
"Runs its 2 functions, 1 function tag. Not read yet: 1 recipe."
);
assert!(world.join("datapacks/tools-2.0.zip").is_file());
// Nothing of it in the browser's own list.
assert!(Installed::load(world.parent().unwrap().parent().unwrap())
.entries
.is_empty());
let again = install_datapack(&fake, &tools, false, &world, off).unwrap();
assert_eq!(again.pack.file, "tools-2.0 (2).zip");
let terrain = fake.project("terrain").unwrap();
let refused = install_datapack(&fake, &terrain, false, &world, off)
.err()
.unwrap();
assert_eq!(
refused,
"Pack terrain would change nothing in Horizon yet: it has no functions, predicates or \
item modifiers, and Horizon doesn't read its 1 worldgen yet."
);
let jar = fake.project("jar").unwrap();
assert!(install_datapack(&fake, &jar, false, &world, off).is_err());
let mut bad = project("bad", Kind::DataPack);
bad.loaders = vec!["datapack".into()];
fake.add(bad);
fake.release("bad", "1", &[], &runs, true);
let bad = fake.project("bad").unwrap();
assert!(install_datapack(&fake, &bad, false, &world, off).is_err());
let names: Vec<String> = fs::read_dir(world.join("datapacks"))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 2, "{names:?}");
fs::remove_dir_all(world.parent().unwrap().parent().unwrap()).unwrap();
}
+29 -4
View File
@@ -14,14 +14,17 @@ fn project_type(kind: Kind) -> &'static str {
Kind::Modpack => "modpack",
Kind::Mod => "mod",
Kind::Shader => "shader",
Kind::DataPack => "datapack",
}
}
/// The kind of a Modrinth project; its shader packs (the shaders/ format) are not offered.
/// The kind of a Modrinth project; its shader packs (the shaders/ format) are not offered. A
/// data pack says it is a `mod` (most also ship a mod build): the search for data packs marks
/// its hits, and an install into a world asks for its data pack files.
fn kind_of(project_type: &str) -> Option<Kind> {
Kind::ALL
.into_iter()
.filter(|k| *k != Kind::Shader)
.filter(|k| !matches!(k, Kind::Shader | Kind::DataPack))
.find(|k| self::project_type(*k) == project_type)
}
@@ -31,7 +34,9 @@ pub(super) fn facets(query: &Query) -> String {
let mut all = vec![vec![format!("project_type:{}", project_type(query.kind))]];
match &f.game_version {
Some(v) => all.push(vec![format!("versions:{v}")]),
None if !query.everything && matches!(query.kind, Kind::ResourcePack | Kind::Mod) => {
None if !query.everything
&& matches!(query.kind, Kind::ResourcePack | Kind::Mod | Kind::DataPack) =>
{
all.push(vec![format!("versions:{GAME}")])
}
None => {}
@@ -167,6 +172,14 @@ pub(super) fn parse_version(v: &Value) -> Option<Version> {
})
}
/// The files of one version (`GET /version/{id}`), with their downloads and checksums.
pub fn version_files(version: &str) -> Result<Vec<FileRef>, String> {
let v = get(&format!("{API}/version/{}", encode(version)))?;
parse_version(&v)
.map(|v| v.files)
.ok_or_else(|| format!("Modrinth: no version {version}."))
}
impl Source for Modrinth {
fn id(&self) -> SourceId {
SourceId::Modrinth
@@ -182,6 +195,10 @@ impl Source for Modrinth {
.into_iter()
.flatten()
.filter_map(parse_hit)
.map(|p| match query.kind {
Kind::DataPack => p.as_datapack(),
_ => p,
})
.collect())
}
@@ -222,7 +239,15 @@ impl Source for Modrinth {
));
}
let (c, l, v) = cached.as_ref().expect("filled above");
Ok(details::modrinth_tags(project_type(kind), c, l, v))
// Data packs share the mods' categories; their one loader is `datapack`.
let mut tags = match kind {
Kind::DataPack => details::modrinth_tags("mod", c, l, v),
_ => details::modrinth_tags(project_type(kind), c, l, v),
};
if kind == Kind::DataPack {
tags.loaders.clear();
}
Ok(tags)
}
}
+45
View File
@@ -72,6 +72,51 @@ fn picks_the_newest_version_that_runs() {
assert!(pick(Kind::ResourcePack, &[blocked, empty], true).is_none());
}
#[test]
fn data_packs_are_their_own_kind() {
// Most data packs on Modrinth also ship a mod build; only the data pack files are picked.
let versions = [
version("fabric", &[GAME], &["fabric", "neoforge"]),
version("pack", &[GAME], &["datapack"]),
version("old", &["1.21.4"], &["datapack"]),
];
assert_eq!(pick(Kind::DataPack, &versions, false).unwrap().id, "pack");
assert!(pick(Kind::DataPack, &versions[2..], false).is_none());
assert_eq!(
pick(Kind::DataPack, &versions[2..], true).unwrap().id,
"old"
);
assert!(pick(Kind::DataPack, &versions[..1], true).is_none());
let now = support(Kind::DataPack, &v(&[GAME]), &v(&["datapack"]));
assert_eq!(now.reason(), Some(DATAPACK_SUPPORT));
let old = support(Kind::DataPack, &v(&["1.21.4"]), &v(&["datapack"]));
assert!(
old.reason().unwrap().starts_with("Made for 1.21.4"),
"{old:?}"
);
assert!(old.installable());
assert_eq!(
modrinth::facets(&Query::new("", Kind::DataPack)),
r#"[["project_type:datapack"],["versions:26.3"]]"#
);
// A data pack's hit says `mod`: the search for data packs marks it.
let hit = serde_json::json!({
"project_id": "bVrj3hwG", "project_type": "mod", "slug": "terralith",
"title": "Terralith", "categories": ["datapack", "fabric", "worldgen"],
"versions": [GAME],
});
let p = modrinth::parse_hit(&hit).unwrap();
assert_eq!(p.kind, Kind::Mod);
let p = p.as_datapack();
assert_eq!(
(p.kind, p.page.as_str()),
(Kind::DataPack, "https://modrinth.com/datapack/terralith")
);
assert_eq!(Kind::DataPack.folder(), None);
}
#[test]
fn file_names_must_be_plain() {
for good in [
+480 -13
View File
@@ -183,6 +183,39 @@ enum InstanceCmd {
Select { instance: String },
/// Keep an instance on one installed Horizon version, or "latest" to follow updates again
Version { instance: String, version: String },
/// Export an instance (or a port) to share: its settings, packs, Horizon mods and the worlds
/// named, as a .mrpack (resource packs from Modrinth listed by download) or a zip. Never
/// logs, screenshots or account data; worlds go without their per-player files
Export {
/// The instance (default: the selected one)
instance: Option<String>,
#[arg(long, value_enum, default_value_t = ExportFormat::Mrpack)]
format: ExportFormat,
/// A world to put in it (its name or folder); repeat for more
#[arg(long)]
world: Vec<String>,
/// Where to write it (default: "<name>.mrpack" in the current folder)
#[arg(long)]
out: Option<PathBuf>,
},
/// Import what a file is, as dropping it on the window does: a shared instance (a .mrpack
/// without mod loaders, such as `instance export` writes, or a game folder zip) becomes a
/// new instance; a Java modpack goes to the Modpacks import; a resource or shader pack into
/// the instance (default: the selected one)
Import {
file: PathBuf,
/// The instance a pack goes into
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum ExportFormat {
/// Modrinth's modpack format
Mrpack,
/// The game folder's layout, zipped
Zip,
}
#[derive(Subcommand)]
@@ -212,6 +245,86 @@ enum WorldsCmd {
#[arg(long)]
instance: Option<String>,
},
/// A world's data packs, and which are on
DatapackList {
world: String,
#[arg(long)]
instance: Option<String>,
},
/// Copy a data pack zip into a world (the game turns it on when the world opens)
DatapackAdd {
world: String,
file: PathBuf,
#[arg(long)]
instance: Option<String>,
},
/// Move a world's data pack to the system's trash
DatapackRemove {
world: String,
/// Its file name, with or without .zip
pack: String,
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
enum PackKindArg {
Resource,
Shader,
}
impl From<PackKindArg> for horizon_launcher::packs::Kind {
fn from(k: PackKindArg) -> Self {
match k {
PackKindArg::Resource => Self::Resource,
PackKindArg::Shader => Self::Shader,
}
}
}
#[derive(Subcommand)]
enum PacksCmd {
/// The resource and shader packs, and which are on (the default)
List {
/// Only this kind
#[arg(long, value_enum)]
kind: Option<PackKindArg>,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Copy a pack file in: a resource pack .zip, a shader pack .zip or a .hshader
Add {
file: PathBuf,
#[arg(long)]
instance: Option<String>,
},
/// Turn a pack on (a resource pack goes on top; a shader pack replaces the one that was on)
On {
/// Its file name or title
pack: String,
#[arg(long, value_enum, default_value_t = PackKindArg::Resource)]
kind: PackKindArg,
#[arg(long)]
instance: Option<String>,
},
/// Turn a pack off
Off {
pack: String,
#[arg(long, value_enum, default_value_t = PackKindArg::Resource)]
kind: PackKindArg,
#[arg(long)]
instance: Option<String>,
},
/// Turn a pack off and move it to the system's trash
Remove {
pack: String,
#[arg(long, value_enum, default_value_t = PackKindArg::Resource)]
kind: PackKindArg,
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Subcommand)]
@@ -259,8 +372,12 @@ enum MultiplayerCmd {
#[derive(Subcommand)]
enum ModsCmd {
/// What the browser installed (the default)
List,
/// What the browser installed in an instance (the default)
List {
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Search for packs Horizon loads
Search {
text: Vec<String>,
@@ -282,10 +399,17 @@ enum ModsCmd {
#[arg(long, value_enum, default_value_t = SourceArg::Modrinth)]
source: SourceArg,
},
/// Install a pack and what it needs, by its id or slug (checksums checked); a Java mod is
/// ported into horizon-mods and turned on; a modpack is imported
/// Install a pack and what it needs into an instance, by its id or slug (checksums
/// checked); a Java mod is ported into horizon-mods and turned on; a modpack is imported as
/// an instance of its own; a data pack goes into the world `--world` names
Install {
project: String,
/// Install the project's data pack into this world of the instance (its name or folder)
#[arg(long)]
world: Option<String>,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
/// Allow a version made for another Minecraft version
#[arg(long)]
everything: bool,
@@ -298,13 +422,31 @@ enum ModsCmd {
/// Only say what is newer
#[arg(long)]
check: bool,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Remove a pack the browser installed
Remove { project: String },
Remove {
project: String,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Turn on a mod the browser installed (horizon-mods.txt, as the game's Options > Mods does)
Enable { project: String },
Enable {
project: String,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
/// Turn off a mod the browser installed; it stays installed
Disable { project: String },
Disable {
project: String,
/// The instance (default: the selected one)
#[arg(long)]
instance: Option<String>,
},
}
#[derive(Clone, Copy, clap::ValueEnum)]
@@ -314,6 +456,8 @@ enum KindArg {
Modpack,
/// Horizon's own shader packs (Horizon's registry only)
Shader,
/// Data packs (Modrinth): install one into a world with `mods install <pack> --world <name>`
Datapack,
}
#[derive(Clone, Copy, clap::ValueEnum)]
@@ -459,6 +603,11 @@ enum Cmd {
#[command(subcommand)]
action: Option<WorldsCmd>,
},
/// An instance's resource and shader packs: list, add, turn on or off, remove
Packs {
#[command(subcommand)]
action: Option<PacksCmd>,
},
/// The screenshots F2 took in an instance: list, delete
Screenshots {
#[command(subcommand)]
@@ -933,6 +1082,13 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
action.unwrap_or(WorldsCmd::List { instance: None }),
as_json,
)?,
Cmd::Packs { action } => game_packs(
action.unwrap_or(PacksCmd::List {
kind: None,
instance: None,
}),
as_json,
)?,
Cmd::Screenshots { action } => screenshots(
action.unwrap_or(ScreenshotsCmd::List { instance: None }),
as_json,
@@ -943,7 +1099,7 @@ fn execute(command: Cmd, as_json: bool) -> Result<(), String> {
)?,
Cmd::Instance { action } => instance(action.unwrap_or(InstanceCmd::List), as_json)?,
Cmd::Modpack { action } => modpack(action.unwrap_or(ModpackCmd::List), as_json)?,
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List), as_json)?,
Cmd::Mods { action } => mods(action.unwrap_or(ModsCmd::List { instance: None }), as_json)?,
Cmd::Servers { action } => servers(action.unwrap_or(ServersCmd::List), as_json)?,
Cmd::Mcp => unreachable!(),
}
@@ -1245,7 +1401,20 @@ fn servers(action: ServersCmd, as_json: bool) -> Result<(), String> {
fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::browser::{horizon, installed, Kind, Query, SourceId};
use horizon_launcher::modpacks;
let home = backend::game_home();
// What the browser installs goes into an instance's game folder; Java mods' checks are kept
// in the default one, shared by all.
let home = match &action {
ModsCmd::List { instance }
| ModsCmd::Install { instance, .. }
| ModsCmd::Update { instance, .. }
| ModsCmd::Remove { instance, .. }
| ModsCmd::Enable { instance, .. }
| ModsCmd::Disable { instance, .. } => match instance {
Some(name) => instances::find(name)?.home,
None => instances::selected().home,
},
ModsCmd::Search { .. } | ModsCmd::Check { .. } => backend::game_home(),
};
let entry_json = |e: &installed::Entry| serde_json::to_value(e).unwrap_or_default();
let line = |label: &str, f: f32| progress(label.to_string())(f);
let from_registry = installed::Installed::load(&home)
@@ -1261,7 +1430,7 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
}
let on = matches!(action, ModsCmd::Enable { .. });
match action {
ModsCmd::List => {
ModsCmd::List { .. } => {
let list = installed::Installed::load(&home).entries;
if as_json {
json(list.iter().map(entry_json).collect());
@@ -1293,12 +1462,14 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
KindArg::Modpack => Kind::Modpack,
KindArg::Mod => Kind::Mod,
KindArg::Shader => Kind::Shader,
KindArg::Datapack => Kind::DataPack,
};
let query = Query {
everything,
..Query::new(&text.join(" "), kind)
};
let found = source.get()?.search(&query)?;
let datapack = kind == Kind::DataPack;
if as_json {
json(found.iter().map(|p| {
let support = p.support();
@@ -1310,6 +1481,9 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
})
}).collect());
} else {
if datapack && !found.is_empty() {
println!("{}\n", horizon_launcher::browser::DATAPACK_SUPPORT);
}
for p in &found {
let support = p.support();
if p.source == SourceId::Horizon {
@@ -1334,6 +1508,8 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
);
if unchecked {
println!(" horizon-launcher mods check {}", p.slug);
} else if datapack {
println!(" horizon-launcher mods install {} --world <world>", p.slug);
} else if let Some(why) = support.reason() {
println!(" {why}");
}
@@ -1384,9 +1560,44 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
project,
everything,
source,
world,
..
} => {
let source = source.get()?;
let p = source.project(&project)?;
if let Some(world) = world {
let world = horizon_launcher::worlds::find(&home, &world)?;
let done = installed::install_datapack(
&*source,
&p,
everything,
&world.path,
&progress(p.title.clone()),
)?;
if as_json {
json(serde_json::json!({
"world": world.folder, "file": done.pack.file, "version": done.number,
"runs": done.coverage.read, "not_read": done.coverage.unread,
}));
} else {
println!(
"{} {} is in {}'s data packs as {}: the game turns it on when the world \
opens. {}",
p.title,
done.number,
world.name,
done.pack.file,
done.coverage.summary()
);
}
return Ok(());
}
if p.kind == Kind::DataPack {
return Err(format!(
"{} is a data pack: say which world with --world.",
p.title
));
}
if p.kind == Kind::Modpack && p.source == SourceId::Horizon {
let pack = horizon::pack::install(&p.id, &line)?;
if as_json {
@@ -1429,7 +1640,7 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
}
}
}
ModsCmd::Update { project, check } => {
ModsCmd::Update { project, check, .. } => {
let list = installed::Installed::load(&home).entries;
let chosen: Vec<_> = list
.iter()
@@ -1464,11 +1675,11 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> {
}
}
}
ModsCmd::Remove { project } => {
ModsCmd::Remove { project, .. } => {
let e = installed::remove(&home, &project)?;
println!("Removed {} ({}).", e.title, e.file);
}
ModsCmd::Enable { project } | ModsCmd::Disable { project } => {
ModsCmd::Enable { project, .. } | ModsCmd::Disable { project, .. } => {
let e = installed::Installed::load(&home)
.lookup(&project)
.cloned()
@@ -1564,10 +1775,190 @@ fn instance(action: InstanceCmd, as_json: bool) -> Result<(), String> {
};
changed(instances::edit(&instance, |m| m.version = pin)?)
}
InstanceCmd::Import { file, instance } => {
use horizon_launcher::import::{self, Dropped};
use horizon_launcher::modpacks;
match import::what(&file)? {
Dropped::Instance => {
let i = import::import_instance(&file, &|line, _| eprintln!("{line}"))?;
if as_json {
json(value(&i, false));
} else {
println!(
"Made the instance {} in {}: horizon-launcher instance select \"{}\"",
i.name,
i.home.display(),
i.name
);
}
}
Dropped::Modpack => {
let pack = modpacks::import(&file, &|line: String| eprintln!("{line}"))?;
println!("{} ({})", modpacks::outcome(&pack), pack.summary());
println!(
"Port and play it: horizon-launcher modpack port {}",
pack.slug
);
}
Dropped::Pack(_) => {
let i = match instance {
Some(name) => instances::find(&name)?,
None => instances::selected(),
};
let p = horizon_launcher::packs::add(&i.home, &file)?;
println!(
"Added {} to {}: turn it on with horizon-launcher packs on",
p.title, i.name
);
}
Dropped::DataPack => {
return Err(format!(
"{} is a data pack: put it into a world with horizon-launcher worlds \
datapack-add <world> <file>",
file.display()
))
}
}
}
InstanceCmd::Export {
instance,
format,
world,
out,
} => {
use horizon_launcher::export::{self, Format};
let i = match instance {
Some(name) => instances::find(&name)?,
None => instances::selected(),
};
let format = match format {
ExportFormat::Mrpack => Format::Mrpack,
ExportFormat::Zip => Format::Zip,
};
let worlds = world
.iter()
.map(|w| horizon_launcher::worlds::find(&i.home, w).map(|w| w.folder))
.collect::<Result<Vec<_>, _>>()?;
let linked = match format {
Format::Mrpack => export::linkable(&i.home, &export::from_modrinth),
Format::Zip => Vec::new(),
};
let out = match out {
Some(out) => out,
None => export::free_path(
&std::env::current_dir().map_err(|e| e.to_string())?,
&i.name,
format,
),
};
let done = export::export(&i.home, &i.name, format, &worlds, &linked, &out)?;
if as_json {
json(serde_json::json!({
"path": done.path, "copied": done.copied, "linked": done.linked,
"worlds": done.worlds,
}));
} else {
println!(
"Exported {} to {}: {} files, {} listed by their Modrinth download{}.",
i.name,
done.path.display(),
done.copied,
done.linked,
match done.worlds.is_empty() {
true => String::new(),
false => format!(", worlds: {}", done.worlds.join(", ")),
}
);
}
}
}
Ok(())
}
fn game_packs(action: PacksCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::packs::{self, Kind};
let home = |instance: Option<String>| -> Result<PathBuf, String> {
Ok(match instance {
Some(name) => instances::find(&name)?.home,
None => instances::selected().home,
})
};
let value = |p: &packs::Pack| {
serde_json::json!({
"kind": format!("{:?}", p.kind).to_lowercase(), "file": p.file, "title": p.title,
"description": p.description, "enabled": p.enabled, "path": p.path,
})
};
match action {
PacksCmd::List { kind, instance } => {
let home = home(instance)?;
let kinds = match kind {
Some(k) => vec![Kind::from(k)],
None => vec![Kind::Resource, Kind::Shader],
};
let all: Vec<packs::Pack> = kinds.iter().flat_map(|k| packs::list(&home, *k)).collect();
if as_json {
json(all.iter().map(value).collect());
}
for k in kinds.iter().filter(|_| !as_json) {
println!("{}:", k.folder());
let these: Vec<_> = all.iter().filter(|p| p.kind == *k).collect();
if these.is_empty() {
println!(" none");
}
for p in these {
let on = if p.enabled { "on " } else { "off" };
match p.title == p.file {
true => println!(" {on} {}", p.file),
false => println!(" {on} {} ({})", p.title, p.file),
}
}
}
}
PacksCmd::Add { file, instance } => {
let pack = packs::add(&home(instance)?, &file)?;
match as_json {
true => json(value(&pack)),
false => println!("Added {} to {} (off).", pack.file, pack.kind.folder()),
}
}
PacksCmd::On {
pack,
kind,
instance,
} => set_pack(&home(instance)?, kind.into(), &pack, true)?,
PacksCmd::Off {
pack,
kind,
instance,
} => set_pack(&home(instance)?, kind.into(), &pack, false)?,
PacksCmd::Remove {
pack,
kind,
instance,
} => {
let home = home(instance)?;
let p = packs::find(&home, kind.into(), &pack)?;
packs::remove(&home, &p)?;
println!("{} is in the trash.", p.file);
}
}
Ok(())
}
fn set_pack(
home: &std::path::Path,
kind: horizon_launcher::packs::Kind,
query: &str,
on: bool,
) -> Result<(), String> {
use horizon_launcher::packs;
let pack = packs::find(home, kind, query)?;
packs::set_enabled(home, &pack, on)?;
println!("{} is {}.", pack.title, if on { "on" } else { "off" });
Ok(())
}
fn screenshots(action: ScreenshotsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::screenshots;
let home = |instance: Option<String>| -> Result<PathBuf, String> {
@@ -1661,6 +2052,7 @@ fn multiplayer(action: MultiplayerCmd, as_json: bool) -> Result<(), String> {
}
fn worlds(action: WorldsCmd, as_json: bool) -> Result<(), String> {
use horizon_launcher::datapacks;
use horizon_launcher::worlds::{self, World};
let home = |instance: Option<String>| -> Result<PathBuf, String> {
Ok(match instance {
@@ -1718,6 +2110,46 @@ fn worlds(action: WorldsCmd, as_json: bool) -> Result<(), String> {
worlds::delete(&w)?;
println!("{} is in the trash.", w.name);
}
WorldsCmd::DatapackList { world, instance } => {
let w = worlds::find(&home(instance)?, &world)?;
let packs = datapacks::list(&w.path);
if as_json {
json(
packs
.iter()
.map(|p| serde_json::json!({ "file": p.file, "description": p.description, "enabled": p.enabled, "path": p.path }))
.collect(),
);
} else if packs.is_empty() {
println!("No data packs in {}", w.name);
}
for p in packs.iter().filter(|_| !as_json) {
let on = if p.enabled { "on " } else { "off" };
println!(" {on} {} {}", p.file, p.description);
}
}
WorldsCmd::DatapackAdd {
world,
file,
instance,
} => {
let w = worlds::find(&home(instance)?, &world)?;
let p = datapacks::add(&w.path, &file)?;
println!(
"Added {} to {}: the game turns it on when the world opens.",
p.file, w.name
);
}
WorldsCmd::DatapackRemove {
world,
pack,
instance,
} => {
let w = worlds::find(&home(instance)?, &world)?;
let p = datapacks::find(&w.path, &pack)?;
datapacks::remove(&w.path, &p)?;
println!("{} is in the trash.", p.file);
}
}
Ok(())
}
@@ -1853,4 +2285,39 @@ mod tests {
_ => panic!("expected game-args"),
}
}
#[test]
fn mods_commands_name_their_instance() {
let parse = |args: &[&str]| {
let cli = Cli::try_parse_from([&["horizon-launcher", "mods"], args].concat()).unwrap();
match cli.command {
Some(Cmd::Mods { action }) => action,
_ => panic!("expected mods"),
}
};
match parse(&["install", "fresh-animations", "--instance", "Skyblock"]) {
Some(ModsCmd::Install {
project, instance, ..
}) => assert_eq!(
(project.as_str(), instance.as_deref()),
("fresh-animations", Some("Skyblock"))
),
_ => panic!("expected mods install"),
}
assert!(matches!(
parse(&["list"]),
Some(ModsCmd::List { instance: None })
));
assert!(parse(&[]).is_none());
// Search and Check read no instance: checks are shared.
assert!(Cli::try_parse_from([
"horizon-launcher",
"mods",
"search",
"x",
"--instance",
"A"
])
.is_err());
}
}
+2
View File
@@ -29,6 +29,8 @@ pub const DESTRUCTIVE: &[&str] = &[
"worlds delete",
"multiplayer remove",
"screenshots delete",
"packs remove",
"worlds datapack-remove",
];
/// Commands that send something or reach other people: only when the player asks.
+232
View File
@@ -0,0 +1,232 @@
//! A world's data packs: `<world>/datapacks/`, each a `.zip` or a folder with a `pack.mcmeta`, as
//! the game discovers them (`datapack::discover`). The game turns on every pack it finds there
//! that `<world>/datapacks.json` doesn't list as disabled, when the world opens or on `/reload`,
//! so adding one is copying it in.
use crate::packs::{entry, plain};
use std::fs;
use std::path::{Path, PathBuf};
const SETTINGS: &str = "datapacks.json";
#[derive(Clone, Debug, PartialEq)]
pub struct DataPack {
/// Its file or folder name in `datapacks/`; the game's id is `file/<name>`.
pub file: String,
pub path: PathBuf,
/// `pack.mcmeta`'s description as plain text.
pub description: String,
/// Off when `datapacks.json` lists it as disabled (the game's `/datapack disable`).
pub enabled: bool,
}
fn settings(world: &Path) -> serde_json::Value {
fs::read(world.join(SETTINGS))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_else(|| serde_json::json!({}))
}
fn disabled(settings: &serde_json::Value) -> Vec<String> {
settings["disabled"]
.as_array()
.into_iter()
.flatten()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
}
/// The data packs of the world folder `world`, by name.
pub fn list(world: &Path) -> Vec<DataPack> {
let off = disabled(&settings(world));
let mut packs: Vec<DataPack> = fs::read_dir(world.join("datapacks"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
let file = e.file_name().to_str()?.to_string();
let zip = path.is_file() && file.to_ascii_lowercase().ends_with(".zip");
if !(zip || path.is_dir()) {
return None;
}
let meta: serde_json::Value =
serde_json::from_slice(&entry(&path, "pack.mcmeta")?).ok()?;
Some(DataPack {
enabled: !off.contains(&format!("file/{file}")),
description: plain(&meta["pack"]["description"]),
file,
path,
})
})
.collect();
packs.sort_by_key(|p| p.file.to_lowercase());
packs
}
/// Whether a zip is a data pack: a `pack.mcmeta` and a `data/` folder.
fn check(file: &Path) -> Result<(), String> {
let name = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let zip = fs::File::open(file)
.ok()
.and_then(|f| zip::ZipArchive::new(f).ok())
.ok_or_else(|| format!("{name} is not a zip file that can be read."))?;
if zip.index_for_name("pack.mcmeta").is_none() {
return Err(format!("{name} is not a pack: it has no pack.mcmeta."));
}
if !zip.file_names().any(|n| n.starts_with("data/")) {
return Err(format!(
"{name} has no data folder: it is a resource pack, not a data pack."
));
}
Ok(())
}
/// Copies a data pack zip into the world's `datapacks/` under a free name. The game turns it on
/// the next time the world opens (or on `/reload`).
pub fn add(world: &Path, file: &Path) -> Result<DataPack, String> {
check(file)?;
let dir = world.join("datapacks");
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = file.file_name().ok_or("A pack file has a name")?;
let stem = Path::new(name)
.file_stem()
.unwrap_or(name)
.to_string_lossy();
let target = (1..)
.map(|n| match n {
1 => dir.join(name),
n => dir.join(format!("{stem} ({n}).zip")),
})
.find(|p| !p.exists())
.unwrap();
fs::copy(file, &target).map_err(|e| format!("Could not copy {}: {e}", file.display()))?;
let file_name = target.file_name().unwrap().to_string_lossy().into_owned();
// A pack of the same name removed while it was disabled would stay off.
forget(world, &file_name)?;
list(world)
.into_iter()
.find(|p| p.file == file_name)
.ok_or_else(|| format!("{} is not a data pack the game reads", target.display()))
}
/// Drops `file/<name>` from both lists of `datapacks.json`, when it is there.
fn forget(world: &Path, file: &str) -> Result<(), String> {
let mut s = settings(world);
let id = format!("file/{file}");
let mut changed = false;
for key in ["enabled", "disabled"] {
if let Some(list) = s[key].as_array_mut() {
let before = list.len();
list.retain(|v| v.as_str() != Some(id.as_str()));
changed |= list.len() != before;
}
}
if !changed {
return Ok(());
}
let text = serde_json::to_string_pretty(&s).unwrap();
fs::write(world.join(SETTINGS), text).map_err(|e| format!("{SETTINGS}: {e}"))
}
/// The data pack a command names: its file name, with or without `.zip`.
pub fn find(world: &Path, query: &str) -> Result<DataPack, String> {
list(world)
.into_iter()
.find(|p| p.file == query || p.file.strip_suffix(".zip") == Some(query))
.ok_or_else(|| {
format!(
"No data pack {query} in {}",
world.join("datapacks").display()
)
})
}
/// Moves the data pack to the system's trash and forgets it in `datapacks.json`.
pub fn remove(world: &Path, pack: &DataPack) -> Result<(), String> {
crate::desktop_seams::to_trash(&pack.path)
.map_err(|e| format!("Could not move {} to the trash: {e}", pack.file))?;
forget(world, &pack.file)
}
/// What of a data pack the game reads today (its `DataStore`): `data/<namespace>/<kind>/`.
pub const READ: [&str; 4] = ["function", "function tag", "predicate", "item_modifier"];
/// A data pack's files by kind (`function`, `worldgen`, `item tag`...), with how many each.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Coverage {
/// What Horizon runs.
pub read: Vec<(String, usize)>,
/// What it doesn't read yet.
pub unread: Vec<(String, usize)>,
}
impl Coverage {
/// "Runs its 12 functions, 1 predicate. Not read yet: 40 worldgen, 3 recipes."
pub fn summary(&self) -> String {
let runs = match self.read.is_empty() {
true => "Nothing in it runs in Horizon yet.".to_string(),
false => format!("Runs its {}.", counts(&self.read)),
};
match self.unread.is_empty() {
true => runs,
false => format!("{runs} Not read yet: {}.", self.not_read()),
}
}
/// "1077 worldgen, 653 loot tables, 16 recipes": what Horizon doesn't read yet, the six
/// biggest kinds and how many other kinds.
pub fn not_read(&self) -> String {
let mut biggest = self.unread.clone();
biggest.sort_by_key(|(_, n)| std::cmp::Reverse(*n));
let others = biggest.len().saturating_sub(6);
biggest.truncate(6);
match others {
0 => counts(&biggest),
1 => format!("{} and 1 other kind", counts(&biggest)),
n => format!("{} and {n} other kinds", counts(&biggest)),
}
}
}
fn counts(list: &[(String, usize)]) -> String {
list.iter()
.map(|(k, n)| {
let k = k.replace('_', " ");
match *n == 1 || k == "worldgen" {
true => format!("{n} {k}"),
false => format!("{n} {k}s"),
}
})
.collect::<Vec<_>>()
.join(", ")
}
/// Counts a data pack zip's files by kind, and splits them into what the game reads and what
/// it doesn't yet.
pub fn coverage(file: &Path) -> Result<Coverage, String> {
let zip = fs::File::open(file)
.ok()
.and_then(|f| zip::ZipArchive::new(f).ok())
.ok_or_else(|| format!("{} is not a zip file that can be read.", file.display()))?;
let mut kinds = std::collections::BTreeMap::<String, usize>::new();
for name in zip.file_names().filter(|n| !n.ends_with('/')) {
let parts: Vec<&str> = name.split('/').collect();
let kind = match parts.as_slice() {
["data", _, "tags", tag, _, ..] => format!("{tag} tag"),
["data", _, kind, _, ..] => kind.to_string(),
_ => continue,
};
*kinds.entry(kind).or_default() += 1;
}
let (read, unread) = kinds
.into_iter()
.partition(|(k, _)| READ.contains(&k.as_str()));
Ok(Coverage { read, unread })
}
#[cfg(test)]
#[path = "datapacks/tests.rs"]
mod tests;
+138
View File
@@ -0,0 +1,138 @@
use super::*;
use std::io::Write;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"horizon-launcher-datapacks-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn zip_file(path: &Path, files: &[(&str, &str)]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap());
for (name, text) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
const META: &str = r#"{"pack":{"pack_format":88,"description":"More trades"}}"#;
#[test]
fn lists_adds_and_removes_as_the_game_reads_them() {
let root = scratch("all");
let world = root.join("saves/My World");
zip_file(
&world.join("datapacks/Trades.zip"),
&[
("pack.mcmeta", META),
("data/t/function/a.mcfunction", "say hi"),
],
);
fs::create_dir_all(world.join("datapacks/folder pack")).unwrap();
fs::write(
world.join("datapacks/folder pack/pack.mcmeta"),
r#"{"pack":{"description":{"text":"Mine"}}}"#,
)
.unwrap();
fs::write(world.join("datapacks/readme.txt"), "").unwrap();
fs::write(
world.join("datapacks.json"),
r#"{"enabled":[],"disabled":["file/folder pack","file/Gone.zip"]}"#,
)
.unwrap();
let packs = list(&world);
let seen: Vec<(&str, &str, bool)> = packs
.iter()
.map(|p| (p.file.as_str(), p.description.as_str(), p.enabled))
.collect();
assert_eq!(
seen,
[
("folder pack", "Mine", false),
("Trades.zip", "More trades", true)
]
);
let drops = root.join("drops");
zip_file(
&drops.join("Gone.zip"),
&[("pack.mcmeta", META), ("data/g/tags/x.json", "{}")],
);
zip_file(
&drops.join("Trades.zip"),
&[("pack.mcmeta", META), ("data/t/function/b.mcfunction", "")],
);
zip_file(
&drops.join("Textures.zip"),
&[
("pack.mcmeta", META),
("assets/minecraft/textures/a.png", ""),
],
);
// A pack disabled before it was taken out comes back on.
let gone = add(&world, &drops.join("Gone.zip")).unwrap();
assert!(gone.enabled);
assert!(!fs::read_to_string(world.join("datapacks.json"))
.unwrap()
.contains("Gone.zip"));
assert_eq!(
add(&world, &drops.join("Trades.zip")).unwrap().file,
"Trades (2).zip"
);
let refused = add(&world, &drops.join("Textures.zip")).unwrap_err();
assert!(refused.contains("resource pack"), "{refused}");
let folder = find(&world, "folder pack").unwrap();
let trash = root.join("trash");
std::env::set_var(crate::desktop_seams::TRASH_ENV, &trash);
remove(&world, &folder).unwrap();
std::env::remove_var(crate::desktop_seams::TRASH_ENV);
assert!(trash.join("folder pack/pack.mcmeta").is_file());
assert!(!fs::read_to_string(world.join("datapacks.json"))
.unwrap()
.contains("folder pack"));
assert_eq!(find(&world, "Trades").unwrap().file, "Trades.zip");
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn coverage_says_what_runs() {
let root = scratch("coverage");
let file = root.join("p.zip");
zip_file(
&file,
&[
("pack.mcmeta", META),
("data/a/function/x.mcfunction", ""),
("data/a/predicate/p.json", "{}"),
("data/a/item_modifier/m.json", "{}"),
("data/a/tags/item/i.json", "{}"),
("data/a/loot_table/l.json", "{}"),
("data/a/loot_table/k.json", "{}"),
("data/a/worldgen/biome/b.json", "{}"),
("assets/a/lang/en_us.json", "{}"),
],
);
let c = coverage(&file).unwrap();
assert_eq!(
c.summary(),
"Runs its 1 function, 1 item modifier, 1 predicate. Not read yet: 2 loot tables, 1 item \
tag, 1 worldgen."
);
let many = Coverage {
read: Vec::new(),
unread: (1..=8).map(|n| (format!("kind{n}"), n)).collect(),
};
assert_eq!(
many.not_read(),
"8 kind8s, 7 kind7s, 6 kind6s, 5 kind5s, 4 kind4s, 3 kind3s and 2 other kinds"
);
fs::remove_dir_all(&root).unwrap();
}
+266
View File
@@ -0,0 +1,266 @@
//! A world's data packs, opened under its row on Home (`horizon_launcher::datapacks`): what is
//! in `datapacks/`, Add (a file picker) or a zip dropped on the world, and Remove (to the trash,
//! after a confirmation).
use super::*;
use gpui::ExternalPaths;
use horizon_launcher::datapacks::{self, DataPack};
use horizon_launcher::worlds::World;
use std::path::PathBuf;
#[derive(Default)]
pub(super) struct WorldPacks {
/// The world whose packs are open, and its packs as last read.
open: Option<PathBuf>,
list: Vec<DataPack>,
confirm_remove: Option<String>,
note: Option<Result<String, String>>,
}
impl WorldPacks {
/// Reads the open world's packs again, after something else changed them.
pub(super) fn reread(&mut self) {
if let Some(world) = &self.open {
self.list = datapacks::list(world);
}
}
}
impl Launcher {
/// Opens or closes the packs of `world` under its row.
pub(super) fn toggle_world_packs(&mut self, world: &World) {
if self.world_packs.open.as_ref() == Some(&world.path) {
self.world_packs = WorldPacks::default();
} else {
self.world_packs = WorldPacks {
open: Some(world.path.clone()),
list: datapacks::list(&world.path),
..WorldPacks::default()
};
}
}
pub(super) fn world_packs_open(&self, world: &World) -> bool {
self.world_packs.open.as_ref() == Some(&world.path)
}
/// Copies dropped or picked zips into `world`, and opens its packs to show them.
pub(super) fn add_world_packs(&mut self, world: PathBuf, files: Vec<PathBuf>) {
let added: Result<Vec<String>, String> = files
.iter()
.map(|f| datapacks::add(&world, f).map(|p| p.file))
.collect();
self.world_packs = WorldPacks {
list: datapacks::list(&world),
note: Some(added.map(|names| {
format!(
"Added {}: the game turns it on when the world opens.",
names.join(", ")
)
})),
open: Some(world),
confirm_remove: None,
};
}
fn choose_world_packs(&mut self, world: PathBuf, cx: &mut Context<Self>) {
let paths = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: true,
prompt: Some("Add".into()),
});
cx.spawn(async move |this, cx| {
if let Ok(Ok(Some(paths))) = paths.await {
let _ = this.update(cx, |this, cx| {
this.add_world_packs(world, paths);
cx.notify();
});
}
})
.detach();
}
/// A world row that takes dropped data packs.
pub(super) fn world_drop_target(
&self,
i: usize,
world: &World,
row: Div,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let t = self.theme;
let path = world.path.clone();
div()
.id(SharedString::from(format!("world-drop-{i}")))
.border_2()
.mx(px(-4.))
.p(px(2.))
.border_color(gpui::transparent_black())
.drag_over::<ExternalPaths>(move |s, _, _, _| s.border_color(t.accent))
.on_drop(cx.listener(move |this, paths: &ExternalPaths, _, cx| {
this.add_world_packs(path.clone(), paths.paths().to_vec());
// Not the window's import as well.
cx.stop_propagation();
cx.notify();
}))
.child(row)
}
/// The open world's packs, under its row.
pub(super) fn world_packs_panel(
&mut self,
world: &World,
cx: &mut Context<Self>,
) -> Option<Div> {
if !self.world_packs_open(world) {
return None;
}
let t = self.theme;
let path = world.path.clone();
let mut panel = div()
.flex()
.flex_col()
.gap(px(6.))
.ml(px(62.))
.mb(px(6.))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(caps("Data packs").text_color(t.muted))
.child(
div()
.flex()
.gap(px(6.))
.child(
button("world-packs-add", "Add data pack…", Kind::Ghost, &t)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
this.choose_world_packs(path.clone(), cx);
cx.notify();
})),
)
.child({
let dir = world.path.join("datapacks");
button("world-packs-folder", "Open folder", Kind::Ghost, &t)
.on_click(move |_, _, cx| {
let _ = std::fs::create_dir_all(&dir);
cx.open_with_system(&dir);
})
}),
),
);
match &self.world_packs.note {
Some(Ok(m)) => {
panel = panel.child(text(theme::CAPTION).text_color(t.muted).child(m.clone()))
}
Some(Err(e)) => panel = panel.child(notice(e.clone(), &t)),
None => {}
}
if self.world_packs.list.is_empty() {
panel = panel.child(
text(theme::CAPTION)
.text_color(t.muted)
.child("None yet: drop a data pack .zip on the world, or use Add data pack…"),
);
}
for (i, pack) in self.world_packs.list.clone().into_iter().enumerate() {
let confirming = self.world_packs.confirm_remove.as_ref() == Some(&pack.file);
let mut actions = div().flex().items_center().gap(px(6.));
if confirming {
let (world, gone) = (world.path.clone(), pack.clone());
actions = actions
.child(
text(theme::CAPTION)
.text_color(t.danger)
.child("Move it to the trash?"),
)
.child(
button(
SharedString::from(format!("world-pack-keep-{i}")),
"Keep",
Kind::Ghost,
&t,
)
.on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.world_packs.confirm_remove = None;
cx.notify();
},
)),
)
.child(
button(
SharedString::from(format!("world-pack-trash-{i}")),
"Move to trash",
Kind::Secondary,
&t,
)
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
let note = datapacks::remove(&world, &gone)
.map(|()| format!("{} moved to the trash.", gone.file));
this.world_packs.list = datapacks::list(&world);
this.world_packs.note = Some(note);
this.world_packs.confirm_remove = None;
cx.notify();
},
)),
);
} else {
let file = pack.file.clone();
if !pack.enabled {
actions = actions.child(
text(theme::CAPTION)
.text_color(t.muted)
.child("Off in the game"),
);
}
actions = actions.child(
button(
SharedString::from(format!("world-pack-remove-{i}")),
"Remove",
Kind::Ghost,
&t,
)
.on_click(cx.listener(
move |this, _: &ClickEvent, _, cx| {
this.world_packs.confirm_remove = Some(file.clone());
cx.notify();
},
)),
);
}
panel = panel.child(
plate(&t)
.flex()
.items_center()
.gap(px(12.))
.px(px(12.))
.py(px(6.))
.child(blocks::icon("package", 16., t.muted))
.child(
div()
.flex()
.flex_col()
.flex_1()
.min_w_0()
.child(
text(theme::LABEL)
.text_color(t.ink)
.truncate()
.child(pack.file.clone()),
)
.child(
text(theme::CAPTION)
.text_color(t.muted)
.truncate()
.child(pack.description.clone()),
),
)
.child(actions),
);
}
Some(panel)
}
}
+80
View File
@@ -0,0 +1,80 @@
//! The two things the launcher does to the player's desktop that a test run must never do to the
//! real one: moving files to the system's trash, and writing to the clipboard.
//!
//! - `HORIZON_LAUNCHER_TRASH=<folder>`: what would go to the trash is moved into that folder
//! instead (under a free name).
//! - `HORIZON_LAUNCHER_CLIPBOARD=<file>`: what would be copied is written to that file instead
//! (text as UTF-8, a picture as its PNG or JPEG bytes).
//!
//! Test runs point both into their scratch folders, beside `HORIZON_HOME` and
//! `HORIZON_LAUNCHER_HOME`.
use std::path::{Path, PathBuf};
pub const TRASH_ENV: &str = "HORIZON_LAUNCHER_TRASH";
pub const CLIPBOARD_ENV: &str = "HORIZON_LAUNCHER_CLIPBOARD";
/// Moves a file or folder to the system's trash, where the player can still get it back, or
/// into `HORIZON_LAUNCHER_TRASH` when it is set.
pub fn to_trash(path: &Path) -> Result<(), String> {
match std::env::var_os(TRASH_ENV).filter(|v| !v.is_empty()) {
Some(dir) => to_folder(path, Path::new(&dir)),
None => system_trash(path),
}
}
fn to_folder(path: &Path, dir: &Path) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let name = path.file_name().ok_or("Nothing to move")?.to_string_lossy();
let target = (1..)
.map(|n| match n {
1 => dir.join(&*name),
n => dir.join(format!("{name} {n}")),
})
.find(|p| !p.exists())
.unwrap();
std::fs::rename(path, &target).map_err(|e| e.to_string())
}
fn system_trash(path: &Path) -> Result<(), String> {
#[allow(unused_mut)]
let mut trash = trash::TrashContext::default();
// The crate's default on macOS asks Finder through AppleScript, which needs the player to
// allow the launcher to control Finder first; the file manager call needs nothing.
#[cfg(target_os = "macos")]
trash::macos::TrashContextExtMacos::set_delete_method(
&mut trash,
trash::macos::DeleteMethod::NsFileManager,
);
trash.delete(path).map_err(|e| e.to_string())
}
/// The file copies go to instead of the clipboard, when `HORIZON_LAUNCHER_CLIPBOARD` is set.
pub fn clipboard_file() -> Option<PathBuf> {
std::env::var_os(CLIPBOARD_ENV)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_trash_folder_takes_what_would_go_to_the_trash() {
let root = std::env::temp_dir().join(format!("horizon-seams-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (home, bin) = (root.join("home"), root.join("trash"));
std::fs::create_dir_all(home.join("World/region")).unwrap();
std::fs::write(home.join("World/level.dat"), b"x").unwrap();
std::fs::write(home.join("shot.png"), b"1").unwrap();
to_folder(&home.join("World"), &bin).unwrap();
to_folder(&home.join("shot.png"), &bin).unwrap();
std::fs::write(home.join("shot.png"), b"2").unwrap();
to_folder(&home.join("shot.png"), &bin).unwrap();
assert!(bin.join("World/level.dat").is_file());
assert_eq!(std::fs::read(bin.join("shot.png")).unwrap(), b"1");
assert_eq!(std::fs::read(bin.join("shot.png 2")).unwrap(), b"2");
assert!(std::fs::read_dir(&home).unwrap().next().is_none());
std::fs::remove_dir_all(&root).unwrap();
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Sharing an instance (or a port): its settings, packs and Horizon mods, and the worlds the
//! player picks, as a `.mrpack` (Modrinth's modpack format, from its published spec) or a plain
//! `.zip` of the game folder's layout.
//!
//! Only what makes the instance goes in, from a fixed list ([`FILES`], [`FOLDERS`]): never logs,
//! screenshots, backups, caches, or anything tied to the player's account. A world goes without
//! its per-player files (`playerdata/`, `stats/`, `advancements/`) and its level.dat without the
//! player's UUID.
//!
//! In a `.mrpack`, a resource pack the browser installed from Modrinth, still the same file, is
//! listed by its download (`files`, with its SHA-1 and SHA-512) instead of being copied; the
//! rest goes under `overrides/`, which a launcher copies into the game folder.
use crate::browser::installed::{Entry, Installed};
use crate::browser::{FileRef, Kind, SourceId, GAME};
use horizon_world::nbt::{self, Limits, Tag};
use sha2::Digest;
use std::fs;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
/// The game folder's own files that are the instance's settings.
pub const FILES: [&str; 6] = [
"options.txt",
"horizon-graphics.txt",
"horizon-extras.txt",
"horizon-controller.txt",
"horizon-mods.txt",
"servers.dat",
];
/// Its folders of packs, mods and their settings.
pub const FOLDERS: [&str; 4] = ["resourcepacks", "shaderpacks", "horizon-mods", "config"];
/// What a world keeps per player: left out.
const PER_PLAYER: [&str; 4] = ["playerdata", "stats", "advancements", "session.lock"];
const INDEX: &str = "modrinth.index.json";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Mrpack,
Zip,
}
impl Format {
pub fn extension(self) -> &'static str {
match self {
Format::Mrpack => "mrpack",
Format::Zip => "zip",
}
}
}
/// A file of the instance that its source serves, listed in a `.mrpack` rather than copied.
#[derive(Clone, Debug, PartialEq)]
pub struct Linked {
/// Relative to the game folder: `resourcepacks/<name>`.
pub path: String,
pub file: FileRef,
}
/// What an export wrote.
#[derive(Debug, PartialEq)]
pub struct Exported {
pub path: PathBuf,
/// Files copied in (overrides, for a `.mrpack`).
pub copied: usize,
/// Files listed by their download.
pub linked: usize,
/// The worlds in it, by folder.
pub worlds: Vec<String>,
}
/// Writes the instance whose game folder is `home` to `out`. `worlds` are folders of its
/// `saves/`; `linked` comes from [`linkable`] (empty for a zip).
pub fn export(
home: &Path,
name: &str,
format: Format,
worlds: &[String],
linked: &[Linked],
out: &Path,
) -> Result<Exported, String> {
let mut files: Vec<(String, PathBuf)> = Vec::new();
for f in FILES {
if home.join(f).is_file() {
files.push((f.to_string(), home.join(f)));
}
}
for folder in FOLDERS {
walk(home, &home.join(folder), &[], &mut files);
}
for world in worlds {
let dir = home.join("saves").join(world);
if world.contains(['/', '\\']) || !dir.join("level.dat").is_file() {
return Err(format!("{name} has no world {world}."));
}
walk(home, &dir, &PER_PLAYER, &mut files);
}
let linked: Vec<&Linked> = match format {
Format::Mrpack => linked.iter().collect(),
Format::Zip => Vec::new(),
};
files.retain(|(rel, _)| !linked.iter().any(|l| l.path == *rel));
if files.is_empty() && linked.is_empty() {
return Err(format!(
"{name} has nothing to share yet: no settings, packs, mods or worlds."
));
}
if let Some(dir) = out.parent() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let part = out.with_extension("part");
let written = write(&part, name, format, &files, &linked);
let done = written.and_then(|()| fs::rename(&part, out).map_err(|e| e.to_string()));
if let Err(e) = done {
let _ = fs::remove_file(&part);
return Err(format!("Could not export {name}: {e}"));
}
Ok(Exported {
path: out.to_path_buf(),
copied: files.len(),
linked: linked.len(),
worlds: worlds.to_vec(),
})
}
fn write(
path: &Path,
name: &str,
format: Format,
files: &[(String, PathBuf)],
linked: &[&Linked],
) -> Result<(), String> {
let mut zip = zip::ZipWriter::new(fs::File::create(path).map_err(|e| e.to_string())?);
let options = zip::write::SimpleFileOptions::default();
let prefix = match format {
Format::Mrpack => {
zip.start_file(INDEX, options).map_err(|e| e.to_string())?;
let index = serde_json::to_vec_pretty(&index(name, linked)).unwrap();
zip.write_all(&index).map_err(|e| e.to_string())?;
"overrides/"
}
Format::Zip => "",
};
for (rel, file) in files {
let bytes = match rel.ends_with("/level.dat") {
true => shareable_level(file)?,
false => fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?,
};
zip.start_file(format!("{prefix}{rel}"), options)
.map_err(|e| e.to_string())?;
zip.write_all(&bytes).map_err(|e| e.to_string())?;
}
zip.finish().map_err(|e| e.to_string())?;
Ok(())
}
/// `modrinth.index.json`: format 1, the files by their downloads, and the Minecraft version.
fn index(name: &str, linked: &[&Linked]) -> serde_json::Value {
let files: Vec<serde_json::Value> = linked
.iter()
.map(|l| {
serde_json::json!({
"path": l.path,
"hashes": { "sha1": l.file.sha1, "sha512": l.file.sha512 },
"env": { "client": "required", "server": "unsupported" },
"downloads": [l.file.url],
"fileSize": l.file.size,
})
})
.collect();
serde_json::json!({
"formatVersion": 1,
"game": "minecraft",
"versionId": "1",
"name": name,
"summary": format!("{name}, exported from Horizon."),
"files": files,
"dependencies": { "minecraft": GAME },
})
}
/// Every file under `dir`, relative to `home` with `/`, leaving out the names in `skip` at
/// `dir`'s top and dot files.
fn walk(home: &Path, dir: &Path, skip: &[&str], out: &mut Vec<(String, PathBuf)>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') || skip.contains(&name.as_str()) {
continue;
}
let path = e.path();
if path.is_dir() {
walk(home, &path, &[], out);
} else if let Ok(rel) = path.strip_prefix(home) {
out.push((rel.to_string_lossy().replace('\\', "/"), path));
}
}
}
/// A world's level.dat without the player's UUID; unreadable, it goes as it is.
fn shareable_level(file: &Path) -> Result<Vec<u8>, String> {
let bytes = fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
let mut raw = Vec::new();
if flate2::read::GzDecoder::new(&bytes[..])
.take(64 << 20)
.read_to_end(&mut raw)
.is_err()
{
return Ok(bytes);
}
let Ok((mut root, _)) = nbt::read_named(&raw, Limits::default()) else {
return Ok(bytes);
};
if let Some(Tag::Compound(data)) = root.tag.get_mut("Data") {
if let Some(Tag::Compound(player)) = data.get_mut(&"Player".into()) {
player.remove(&"UUID".into());
}
}
let raw = nbt::write_named(&root, Limits::default()).map_err(|e| e.to_string())?;
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&raw).map_err(|e| e.to_string())?;
gz.finish().map_err(|e| e.to_string())
}
/// The resource packs the browser installed from Modrinth that are still the file it checked:
/// with `lookup` (the source's file for an entry), what a `.mrpack` can list by download.
pub fn linkable(home: &Path, lookup: &dyn Fn(&Entry) -> Option<FileRef>) -> Vec<Linked> {
Installed::load(home)
.entries
.iter()
.filter(|e| e.source == SourceId::Modrinth && e.kind == Kind::ResourcePack)
.filter(|e| {
let Ok(bytes) = fs::read(home.join(&e.file)) else {
return false;
};
let sum: String = sha2::Sha512::digest(&bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect();
e.checksum.strip_prefix("SHA-512:") == Some(sum.as_str())
})
.filter_map(|e| {
let file = lookup(e)?;
let same = file.sha512.as_deref() == e.checksum.strip_prefix("SHA-512:");
(same && file.sha1.is_some()).then(|| Linked {
path: e.file.clone(),
file,
})
})
.collect()
}
/// An entry's file as Modrinth serves it (asks Modrinth: for [`linkable`]).
pub fn from_modrinth(e: &Entry) -> Option<FileRef> {
let sum = e.checksum.strip_prefix("SHA-512:")?;
crate::browser::modrinth::version_files(&e.version)
.ok()?
.into_iter()
.find(|f| f.sha512.as_deref() == Some(sum))
}
/// A free path for `name.<extension>` in `dir`: "Name (2).mrpack" when taken.
pub fn free_path(dir: &Path, name: &str, format: Format) -> PathBuf {
let ext = format.extension();
let clean: String = name
.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-',
c => c,
})
.collect();
(1..)
.map(|n| match n {
1 => dir.join(format!("{clean}.{ext}")),
n => dir.join(format!("{clean} ({n}).{ext}")),
})
.find(|p| !p.exists())
.unwrap()
}
#[cfg(test)]
#[path = "export/tests.rs"]
mod tests;
+211
View File
@@ -0,0 +1,211 @@
use super::*;
use std::collections::BTreeMap;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"horizon-launcher-export-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn put(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// A gzipped level.dat whose player has a UUID.
fn level_dat() -> Vec<u8> {
let mut player = BTreeMap::new();
player.insert("UUID".into(), Tag::IntArray(vec![1, 2, 3, 4]));
player.insert("XpLevel".into(), Tag::Int(7));
let mut data = BTreeMap::new();
data.insert("LevelName".into(), Tag::String("Base".into()));
data.insert("Player".into(), Tag::Compound(player));
let mut root = BTreeMap::new();
root.insert("Data".into(), Tag::Compound(data));
let raw = nbt::write_named(
&nbt::NamedTag {
name: "".into(),
tag: Tag::Compound(root),
},
Limits::default(),
)
.unwrap();
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&raw).unwrap();
gz.finish().unwrap()
}
fn sha512(bytes: &[u8]) -> String {
sha2::Sha512::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// A game folder with settings, packs, a mod, two worlds, and what never goes out.
fn home(name: &str) -> PathBuf {
let home = scratch(name);
put(&home.join("options.txt"), b"lang:fr_ch\n");
put(&home.join("horizon-graphics.txt"), b"preset:high\n");
put(&home.join("servers.dat"), b"\x0a\x00\x00\x00");
put(&home.join("resourcepacks/Glass.zip"), b"glass pack");
put(&home.join("resourcepacks/Own.zip"), b"own pack");
put(
&home.join("horizon-mods/coords/horizon-mod.toml"),
b"id = \"coords\"\n",
);
put(&home.join("saves/Base/level.dat"), &level_dat());
put(&home.join("saves/Base/region/r.0.0.mca"), b"region");
put(&home.join("saves/Base/playerdata/0001.dat"), b"me");
put(&home.join("saves/Base/stats/0001.json"), b"{}");
put(&home.join("saves/Base/session.lock"), b"");
put(&home.join("saves/Other/level.dat"), &level_dat());
for private in [
"logs/latest.log",
"screenshots/a.png",
"backups/b.zip",
"mod-browser/checks-1/x/check.json",
"horizon-player.txt",
"session.json",
"usercache.json",
"resourcepacks/.DS_Store",
] {
put(&home.join(private), b"private");
}
let entry = serde_json::json!({ "entries": [{
"source": "modrinth", "project": "P1", "slug": "glass", "title": "Glass",
"kind": "resource-pack", "version": "V1", "number": "1.0",
"file": "resourcepacks/Glass.zip",
"checksum": format!("SHA-512:{}", sha512(b"glass pack")),
"page": "https://modrinth.com/resourcepack/glass",
}]});
put(
&home.join("mod-browser.json"),
&serde_json::to_vec(&entry).unwrap(),
);
home
}
fn names(zip: &Path) -> Vec<String> {
let zip = zip::ZipArchive::new(fs::File::open(zip).unwrap()).unwrap();
let mut names: Vec<String> = zip.file_names().map(str::to_string).collect();
names.sort();
names
}
fn read(zip: &Path, name: &str) -> Vec<u8> {
let mut zip = zip::ZipArchive::new(fs::File::open(zip).unwrap()).unwrap();
let mut out = Vec::new();
zip.by_name(name).unwrap().read_to_end(&mut out).unwrap();
out
}
fn served(e: &Entry) -> Option<FileRef> {
(e.version == "V1").then(|| FileRef {
url: "https://cdn.modrinth.com/data/P1/versions/V1/Glass.zip".into(),
name: "Glass.zip".into(),
size: 10,
sha512: Some(sha512(b"glass pack")),
sha256: None,
sha1: Some("ab".repeat(20)),
primary: true,
})
}
#[test]
fn a_zip_has_the_instance_and_the_picked_world_and_nothing_private() {
let home = home("zip");
let out = home.join("out/Hardcore.zip");
let done = export(&home, "Hardcore", Format::Zip, &["Base".into()], &[], &out).unwrap();
assert_eq!((done.copied, done.linked), (8, 0));
// Settings, packs, the mod and the picked world, without its per-player files; no logs,
// screenshots, backups, caches, account files or the other world.
assert_eq!(
names(&out),
[
"horizon-graphics.txt",
"horizon-mods/coords/horizon-mod.toml",
"options.txt",
"resourcepacks/Glass.zip",
"resourcepacks/Own.zip",
"saves/Base/level.dat",
"saves/Base/region/r.0.0.mca",
"servers.dat",
]
);
// The world's level.dat keeps the player, without the UUID.
let mut raw = Vec::new();
flate2::read::GzDecoder::new(&read(&out, "saves/Base/level.dat")[..])
.read_to_end(&mut raw)
.unwrap();
let (root, _) = nbt::read_named(&raw, Limits::default()).unwrap();
let Some(Tag::Compound(data)) = root.tag.get("Data") else {
panic!("no Data")
};
let Some(Tag::Compound(player)) = data.get(&"Player".into()) else {
panic!("no Player")
};
assert!(player.get(&"UUID".into()).is_none());
assert_eq!(player.get(&"XpLevel".into()), Some(&Tag::Int(7)));
assert!(!out.with_extension("part").exists());
assert!(export(&home, "Hardcore", Format::Zip, &["../x".into()], &[], &out).is_err());
let empty = scratch("empty");
let refused = export(&empty, "New", Format::Zip, &[], &[], &empty.join("New.zip"));
assert_eq!(
refused.unwrap_err(),
"New has nothing to share yet: no settings, packs, mods or worlds."
);
assert!(!empty.join("New.zip").exists());
fs::remove_dir_all(&empty).unwrap();
fs::remove_dir_all(&home).unwrap();
}
#[test]
fn an_mrpack_lists_what_modrinth_serves_and_carries_the_rest() {
let home = home("mrpack");
let linked = linkable(&home, &served);
assert_eq!(linked.len(), 1);
let out = free_path(&home, "Hard: core", Format::Mrpack);
assert_eq!(out.file_name().unwrap(), "Hard- core.mrpack");
let done = export(&home, "Hardcore", Format::Mrpack, &[], &linked, &out).unwrap();
assert_eq!((done.copied, done.linked), (5, 1));
assert_eq!(
names(&out),
[
"modrinth.index.json",
"overrides/horizon-graphics.txt",
"overrides/horizon-mods/coords/horizon-mod.toml",
"overrides/options.txt",
"overrides/resourcepacks/Own.zip",
"overrides/servers.dat",
]
);
let index: serde_json::Value =
serde_json::from_slice(&read(&out, "modrinth.index.json")).unwrap();
assert_eq!(index["formatVersion"], 1);
assert_eq!(index["game"], "minecraft");
assert_eq!(index["dependencies"]["minecraft"], GAME);
let file = &index["files"][0];
assert_eq!(file["path"], "resourcepacks/Glass.zip");
assert_eq!(
file["downloads"][0],
"https://cdn.modrinth.com/data/P1/versions/V1/Glass.zip"
);
assert_eq!(file["hashes"]["sha512"], sha512(b"glass pack"));
assert_eq!(
free_path(&home, "Hard: core", Format::Mrpack)
.file_name()
.unwrap(),
"Hard- core (2).mrpack"
);
// A pack changed since its install is carried, not linked.
put(&home.join("resourcepacks/Glass.zip"), b"edited");
assert!(linkable(&home, &served).is_empty());
fs::remove_dir_all(&home).unwrap();
}
+213
View File
@@ -0,0 +1,213 @@
//! Export… on Home's Instance row: the selected instance as a `.mrpack` or a `.zip`, with the
//! worlds the player switches on, written to the launcher's `exports/` folder
//! (`horizon_launcher::export`).
use super::*;
use horizon_launcher::export::{self, Format};
use std::path::PathBuf;
pub(super) struct ExportPanel {
/// The instance it is for: picking another closes it.
instance: String,
format: Format,
/// The instance's worlds (folder, name) and whether each goes in.
worlds: Vec<(String, String, bool)>,
busy: bool,
/// The file written, or why not.
done: Option<Result<(String, PathBuf), String>>,
}
impl Launcher {
pub(super) fn toggle_export(&mut self) {
if self.export.take().is_some() {
return;
}
let Some(i) = self.selected_instance() else {
return;
};
let worlds = horizon_launcher::worlds::list(&i.home)
.into_iter()
.map(|w| (w.folder, w.name, false))
.collect();
self.export = Some(ExportPanel {
instance: i.id.clone(),
format: Format::Mrpack,
worlds,
busy: false,
done: None,
});
}
fn run_export(&mut self, cx: &mut Context<Self>) {
let Some(i) = self.selected_instance().cloned() else {
return;
};
let Some(panel) = self.export.as_mut().filter(|p| !p.busy) else {
return;
};
panel.busy = true;
panel.done = None;
let format = panel.format;
let worlds: Vec<String> = panel
.worlds
.iter()
.filter(|w| w.2)
.map(|w| w.0.clone())
.collect();
let task = cx.background_executor().spawn(async move {
let linked = match format {
Format::Mrpack => export::linkable(&i.home, &export::from_modrinth),
Format::Zip => Vec::new(),
};
let out = export::free_path(&backend::data_dir().join("exports"), &i.name, format);
export::export(&i.home, &i.name, format, &worlds, &linked, &out).map(|done| {
let linked = match done.linked {
0 => String::new(),
n => format!(", {n} listed by their Modrinth download"),
};
let file = done.path.file_name().unwrap_or_default().to_string_lossy();
(
format!("Exported {file}: {} files{linked}.", done.copied),
done.path,
)
})
});
cx.spawn(async move |this, cx| {
let done = task.await;
let _ = this.update(cx, |this, cx| {
if let Some(panel) = &mut this.export {
panel.busy = false;
panel.done = Some(done);
}
cx.notify();
});
})
.detach();
}
/// The panel under the Instance row, while it is open.
pub(super) fn export_panel(&mut self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let selected = self.selected_instance()?.id.clone();
if self.export.as_ref()?.instance != selected {
self.export = None;
return None;
}
let panel = self.export.as_ref()?;
let formats = [(Format::Mrpack, ".mrpack"), (Format::Zip, ".zip")];
let mut pick = div().flex().items_center().gap(px(6.));
for (format, label) in formats {
let on = panel.format == format;
pick = pick.child(
button(
SharedString::from(format!("export-{}", format.extension())),
label,
if on { Kind::Secondary } else { Kind::Ghost },
&t,
)
.when(on, |b| b.border_color(t.accent))
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if let Some(p) = &mut this.export {
p.format = format;
}
cx.notify();
})),
);
}
let about = match panel.format {
Format::Mrpack => {
"Modrinth's modpack format: resource packs from Modrinth are listed by their \
download, the rest is carried in it."
}
Format::Zip => "The game folder's own layout, zipped.",
};
let mut worlds = div().flex().flex_col().gap(px(6.));
if panel.worlds.is_empty() {
worlds = worlds.child(
text(theme::CAPTION)
.text_color(t.muted)
.child("No worlds to put in."),
);
}
for (n, (_, name, on)) in panel.worlds.iter().enumerate() {
worlds = worlds.child(
div()
.id(SharedString::from(format!("export-world-{n}")))
.flex()
.items_center()
.gap(px(10.))
.cursor_pointer()
.child(switch(
SharedString::from(format!("export-world-switch-{n}")),
*on,
&t,
))
.child(
text(theme::LABEL)
.text_color(t.ink_soft)
.child(name.clone()),
)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if let Some(w) = this.export.as_mut().and_then(|p| p.worlds.get_mut(n)) {
w.2 = !w.2;
}
cx.notify();
})),
);
}
let go = button("export-run", "Export", Kind::Secondary, &t);
let go = match panel.busy {
true => disabled(go),
false => go.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.run_export(cx);
cx.notify();
})),
};
let result = match &panel.done {
Some(Ok((said, path))) => {
let path = path.clone();
Some(
div()
.flex()
.items_center()
.gap(px(10.))
.child(text(theme::CAPTION).text_color(t.muted).child(said.clone()))
.child(
button("export-show", "Show in folder", Kind::Ghost, &t)
.on_click(move |_, _, cx| cx.reveal_path(&path)),
),
)
}
Some(Err(e)) => Some(div().child(notice(e.clone(), &t))),
None => None,
};
Some(
plate(&t)
.flex()
.flex_col()
.gap(px(12.))
.px(px(14.))
.py(px(12.))
.ml(px(40.))
.child(caps("Export to share").text_color(t.muted))
.child(pick)
.child(text(theme::CAPTION).text_color(t.muted).child(about))
.child(caps("Worlds").text_color(t.muted))
.child(worlds)
.child(text(theme::CAPTION).text_color(t.muted).child(
"Settings, packs and Horizon mods go in; logs, screenshots, backups and \
your account never do. Worlds go without their per-player files.",
))
.child(
div()
.flex()
.items_center()
.gap(px(10.))
.child(go)
.when(panel.busy, |d| {
d.child(text(theme::CAPTION).text_color(t.muted).child("Exporting…"))
}),
)
.children(result),
)
}
}
+232
View File
@@ -0,0 +1,232 @@
//! A file dropped on the window (or given to `instance import`): what it is, and an instance
//! made from a shared one.
//!
//! - A `.mrpack` that needs no mod loader (only `minecraft` in its dependencies), such as
//! [`crate::export`] writes, or a `.zip` laid out as a game folder: a new instance. Its
//! `overrides/` (and `client-overrides/`) go into the instance's folder; each of its `files`
//! is downloaded from Modrinth and checked against its SHA-512 (`browser::download`).
//! - A `.mrpack` for a mod loader, or a CurseForge modpack zip (`manifest.json`): a Java
//! modpack, for the Modpacks import (`modpacks::import`).
//! - A zip with `pack.mcmeta`: a data pack (`data/`) or a resource pack; with `shaders/`, or a
//! `.hshader`: a shader pack.
//!
//! Only what an instance holds is taken from a shared zip ([`crate::export::FILES`],
//! [`crate::export::FOLDERS`], `saves/`), and every path is checked to stay inside its folder.
use crate::browser::{download, FileRef};
use crate::export::{FILES, FOLDERS};
use crate::instances::{self, Instance};
use crate::packs;
use std::fs;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
const INDEX: &str = "modrinth.index.json";
/// The `.mrpack` folders copied into the game folder; `server-overrides/` is a server's.
const OVERRIDES: [&str; 2] = ["overrides/", "client-overrides/"];
/// What a dropped file is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dropped {
/// A shared instance: a new instance is made from it.
Instance,
/// A Java modpack, for the Modpacks import.
Modpack,
/// A resource or shader pack, for the selected instance.
Pack(packs::Kind),
/// A data pack: it goes into a world.
DataPack,
}
fn open(file: &Path) -> Result<zip::ZipArchive<fs::File>, String> {
fs::File::open(file)
.ok()
.and_then(|f| zip::ZipArchive::new(f).ok())
.ok_or_else(|| format!("{} is not a zip file that can be read.", name_of(file)))
}
fn name_of(file: &Path) -> String {
file.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn read_index(zip: &mut zip::ZipArchive<fs::File>) -> Result<serde_json::Value, String> {
let mut text = Vec::new();
zip.by_name(INDEX)
.map_err(|_| format!("It has no {INDEX}: not a .mrpack."))?
.take(16 << 20)
.read_to_end(&mut text)
.map_err(|e| e.to_string())?;
serde_json::from_slice(&text).map_err(|e| format!("{INDEX}: {e}"))
}
/// What `file` is, from its name and what is inside.
pub fn what(file: &Path) -> Result<Dropped, String> {
let name = name_of(file);
let ext = file
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase());
match ext.as_deref() {
Some("hshader") => return Ok(Dropped::Pack(packs::Kind::Shader)),
Some("mrpack") => {
let index = read_index(&mut open(file)?)?;
let loaders = index["dependencies"]
.as_object()
.is_some_and(|d| d.keys().any(|k| k != "minecraft"));
return Ok(match loaders {
true => Dropped::Modpack,
false => Dropped::Instance,
});
}
Some("zip") => {}
_ => {
return Err(format!(
"{name} can't be imported: drop a .mrpack, a .zip or a .hshader."
))
}
}
let zip = open(file)?;
let has = |n: &str| zip.index_for_name(n).is_some();
let under = |p: &str| zip.file_names().any(|n| n.starts_with(p));
if has("manifest.json") && !has("pack.mcmeta") {
return Ok(Dropped::Modpack);
}
if has("pack.mcmeta") {
return Ok(match under("data/") {
true => Dropped::DataPack,
false => Dropped::Pack(packs::Kind::Resource),
});
}
if under("shaders/") {
return Ok(Dropped::Pack(packs::Kind::Shader));
}
if FILES.iter().any(|f| has(f))
|| under("saves/")
|| FOLDERS.iter().any(|f| under(&format!("{f}/")))
{
return Ok(Dropped::Instance);
}
Err(format!(
"{name} is neither a pack nor a shared instance: nothing in it that Horizon reads."
))
}
/// Where an archive entry goes in the instance's folder, when it is one an instance holds and
/// stays inside it.
fn target(home: &Path, relative: &str) -> Option<PathBuf> {
let path = Path::new(relative);
if relative.ends_with('/') || !path.components().all(|c| matches!(c, Component::Normal(_))) {
return None;
}
let top = relative.split('/').next()?;
let kept = FILES.contains(&relative)
|| (relative.contains('/') && (FOLDERS.contains(&top) || top == "saves"));
kept.then(|| home.join(path))
}
/// Makes a new instance from a shared one: `file` is a `.mrpack` without mod loaders or a game
/// folder `.zip`. Named after the pack (or the file), with "(2)" when the name is taken.
pub fn import_instance(file: &Path, progress: &dyn Fn(&str, f32)) -> Result<Instance, String> {
import_into(file, progress, &|name| instances::create(name, None))
}
/// [`import_instance`] with the instance made by `create`.
pub fn import_into(
file: &Path,
progress: &dyn Fn(&str, f32),
create: &dyn Fn(&str) -> Result<Instance, String>,
) -> Result<Instance, String> {
if what(file)? != Dropped::Instance {
return Err(format!("{} is not a shared instance.", name_of(file)));
}
let mut zip = open(file)?;
let mrpack = zip.index_for_name(INDEX).is_some();
let index = match mrpack {
true => Some(read_index(&mut zip)?),
false => None,
};
let stem = file
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Imported".into());
let base = index
.as_ref()
.and_then(|i| i["name"].as_str())
.map(str::trim)
.filter(|n| !n.is_empty())
.map(str::to_string)
.unwrap_or(stem);
let instance = (1..20)
.map(|n| match n {
1 => base.clone(),
n => format!("{base} ({n})"),
})
.find_map(|name| create(&name).ok())
.ok_or_else(|| format!("Could not make an instance called {base}."))?;
let home = instance.home.clone();
let filled = fill(&mut zip, index.as_ref(), &home, progress);
if let Err(e) = filled {
let _ = fs::remove_dir_all(&home);
return Err(e);
}
Ok(instance)
}
fn fill(
zip: &mut zip::ZipArchive<fs::File>,
index: Option<&serde_json::Value>,
home: &Path,
progress: &dyn Fn(&str, f32),
) -> Result<(), String> {
let count = zip.len();
for i in 0..count {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
let name = entry.name().to_string();
let relative = match index {
Some(_) => match OVERRIDES.iter().find_map(|o| name.strip_prefix(o)) {
Some(r) => r.to_string(),
None => continue,
},
None => name.clone(),
};
let Some(to) = target(home, &relative) else {
continue;
};
fs::create_dir_all(to.parent().expect("inside home")).map_err(|e| e.to_string())?;
let mut out = fs::File::create(&to).map_err(|e| format!("{}: {e}", to.display()))?;
std::io::copy(&mut entry, &mut out).map_err(|e| format!("{}: {e}", to.display()))?;
progress(
&format!("Copying {relative}"),
(i + 1) as f32 / count as f32,
);
}
let files = index
.and_then(|i| i["files"].as_array())
.cloned()
.unwrap_or_default();
for f in files {
if f["env"]["client"] == "unsupported" {
continue;
}
let relative = f["path"].as_str().unwrap_or_default();
let to = target(home, relative)
.ok_or_else(|| format!("{relative} isn't a place an instance keeps files."))?;
let file = FileRef {
url: f["downloads"][0].as_str().unwrap_or_default().to_string(),
name: to.file_name().unwrap().to_string_lossy().into_owned(),
size: f["fileSize"].as_u64().unwrap_or(0),
sha512: f["hashes"]["sha512"].as_str().map(str::to_ascii_lowercase),
sha256: None,
sha1: f["hashes"]["sha1"].as_str().map(str::to_ascii_lowercase),
primary: true,
};
let folder = to.parent().expect("inside home");
download::fetch(&file, folder, &|p| progress(&file.name, p))
.map_err(|e| format!("{relative}: {e}"))?;
}
Ok(())
}
#[cfg(test)]
#[path = "import/tests.rs"]
mod tests;
+207
View File
@@ -0,0 +1,207 @@
use super::*;
use crate::export::{export, Format};
use std::io::Write;
use std::time::Duration;
fn scratch(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"horizon-launcher-import-{name}-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).unwrap();
root
}
fn zip_file(path: &Path, files: &[(&str, &[u8])]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap());
for (name, bytes) in files {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(bytes).unwrap();
}
zip.finish().unwrap();
}
fn put(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
/// Makes instances as folders of `root`, refusing a name already taken, as the launcher does.
fn maker(root: &Path) -> impl Fn(&str) -> Result<Instance, String> + '_ {
move |name: &str| {
let home = root.join(crate::modpacks::slug(name));
if home.exists() {
return Err(format!("There is already an instance called {name}."));
}
fs::create_dir_all(&home).unwrap();
Ok(Instance {
id: crate::modpacks::slug(name),
name: name.into(),
kind: instances::Kind::Own,
work: home.clone(),
home,
icon: None,
group: None,
version: None,
played: Duration::ZERO,
last_played: None,
})
}
}
const NONE: &dyn Fn(&str, f32) = &|_, _| {};
#[test]
fn says_what_a_dropped_file_is() {
let dir = scratch("what");
let mrpack = |name: &str, deps: &str| {
let path = dir.join(name);
let index = format!(r#"{{"formatVersion":1,"name":"P","files":[],"dependencies":{deps}}}"#);
zip_file(&path, &[("modrinth.index.json", index.as_bytes())]);
path
};
let cases: Vec<(PathBuf, Result<Dropped, ()>)> = vec![
(
mrpack("shared.mrpack", r#"{"minecraft":"26.3"}"#),
Ok(Dropped::Instance),
),
(
mrpack(
"java.mrpack",
r#"{"minecraft":"1.21.1","fabric-loader":"0.16"}"#,
),
Ok(Dropped::Modpack),
),
{
let p = dir.join("cf.zip");
zip_file(
&p,
&[("manifest.json", b"{}"), ("overrides/config/a.toml", b"")],
);
(p, Ok(Dropped::Modpack))
},
{
let p = dir.join("rp.zip");
zip_file(&p, &[("pack.mcmeta", b"{}"), ("assets/a.png", b"")]);
(p, Ok(Dropped::Pack(packs::Kind::Resource)))
},
{
let p = dir.join("dp.zip");
zip_file(
&p,
&[
("pack.mcmeta", b"{}"),
("data/a/function/f.mcfunction", b""),
],
);
(p, Ok(Dropped::DataPack))
},
{
let p = dir.join("sp.zip");
zip_file(&p, &[("shaders/final.fsh", b"")]);
(p, Ok(Dropped::Pack(packs::Kind::Shader)))
},
{
let p = dir.join("glow.hshader");
put(&p, b"PK");
(p, Ok(Dropped::Pack(packs::Kind::Shader)))
},
{
let p = dir.join("folder.zip");
zip_file(
&p,
&[("options.txt", b"lang:fr_ch\n"), ("saves/W/level.dat", b"")],
);
(p, Ok(Dropped::Instance))
},
{
let p = dir.join("random.zip");
zip_file(&p, &[("readme.txt", b"")]);
(p, Err(()))
},
{
let p = dir.join("picture.png");
put(&p, b"png");
(p, Err(()))
},
];
for (file, want) in cases {
assert_eq!(what(&file).map_err(|_| ()), want, "{}", file.display());
}
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn an_export_comes_back_as_an_instance_and_nothing_escapes_its_folder() {
let dir = scratch("roundtrip");
let home = dir.join("from");
put(&home.join("options.txt"), b"lang:fr_ch\n");
put(&home.join("resourcepacks/Glass.zip"), b"glass");
put(
&home.join("horizon-mods/coords/horizon-mod.toml"),
b"id = \"coords\"\n",
);
put(&home.join("saves/Base/region/r.0.0.mca"), b"region");
put(&home.join("saves/Base/level.dat"), b"not nbt");
let made = dir.join("instances");
for format in [Format::Mrpack, Format::Zip] {
let out = dir.join(format!("Shared.{}", format.extension()));
export(&home, "Shared", format, &["Base".into()], &[], &out).unwrap();
let i = import_into(&out, NONE, &maker(&made)).unwrap();
for f in [
"options.txt",
"resourcepacks/Glass.zip",
"horizon-mods/coords/horizon-mod.toml",
"saves/Base/region/r.0.0.mca",
] {
assert_eq!(
fs::read(i.home.join(f)).unwrap(),
fs::read(home.join(f)).unwrap(),
"{f}"
);
}
}
// The .mrpack took the pack's name; the zip came second and got "(2)".
assert!(made.join("shared").is_dir() && made.join("shared-2").is_dir());
// A zip that reaches out of its folder, or carries what an instance doesn't keep.
let evil = dir.join("evil.zip");
zip_file(
&evil,
&[
("options.txt", b"ok"),
("../outside.txt", b"no"),
("saves/../../up.txt", b"no"),
("logs/latest.log", b"no"),
("session.json", b"no"),
],
);
let i = import_into(&evil, NONE, &maker(&made)).unwrap();
assert!(i.home.join("options.txt").is_file());
assert!(!dir.join("outside.txt").exists() && !made.join("up.txt").exists());
assert!(!i.home.join("logs").exists() && !i.home.join("session.json").exists());
// A file to download from anywhere but Modrinth is refused, and the half-made folder goes.
let index = r#"{"formatVersion":1,"name":"Far","dependencies":{"minecraft":"26.3"},
"files":[{"path":"resourcepacks/x.zip","hashes":{"sha1":"00","sha512":"00"},
"downloads":["https://example.com/x.zip"],"fileSize":1}]}"#;
let far = dir.join("far.mrpack");
zip_file(&far, &[("modrinth.index.json", index.as_bytes())]);
let refused = import_into(&far, NONE, &maker(&made)).unwrap_err();
assert!(refused.contains("resourcepacks/x.zip"), "{refused}");
assert!(!made.join("far").exists());
let java = dir.join("java.mrpack");
zip_file(
&java,
&[(
"modrinth.index.json",
br#"{"name":"J","files":[],"dependencies":{"neoforge":"21"}}"#,
)],
);
assert!(import_into(&java, NONE, &maker(&made)).is_err());
fs::remove_dir_all(&dir).unwrap();
}
+132
View File
@@ -0,0 +1,132 @@
//! Files dropped anywhere on the window (`horizon_launcher::import`): a shared instance becomes
//! a new instance, a Java modpack goes to the Modpacks import, a resource or shader pack into
//! the selected instance. The Packs section and the world rows take their own drops first.
use super::*;
use gpui::ExternalPaths;
use horizon_launcher::import::{self, Dropped};
use std::path::PathBuf;
#[derive(Default)]
pub(super) struct Importing {
/// Shared instances being made.
busy: usize,
/// What the last drop did, or why not.
note: Option<Result<String, String>>,
}
impl Launcher {
pub(super) fn import_dropped(&mut self, files: Vec<PathBuf>, cx: &mut Context<Self>) {
for file in files {
let name = file
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
match import::what(&file) {
Err(e) => self.importing.note = Some(Err(e)),
Ok(Dropped::Instance) => self.import_instance(file, cx),
Ok(Dropped::Modpack) => {
self.show_modpack(None);
self.pack_job(move |log| horizon_launcher::modpacks::import(&file, log));
self.importing.note = Some(Ok(format!(
"{name} is a Java modpack: importing it on the Modpacks page."
)));
}
Ok(Dropped::Pack(_)) => {
let Some(i) = self.selected_instance().cloned() else {
continue;
};
self.importing.note =
Some(horizon_launcher::packs::add(&i.home, &file).map(|p| {
format!(
"Added {} to {}'s packs: switch it on under Packs.",
p.title, i.name
)
}));
self.game_packs.stale();
}
Ok(Dropped::DataPack) => {
self.importing.note = Some(Err(format!(
"{name} is a data pack: drop it on a world on Home, or install it into \
one from there."
)))
}
}
}
}
fn import_instance(&mut self, file: PathBuf, cx: &mut Context<Self>) {
self.importing.busy += 1;
self.importing.note = None;
let task = cx
.background_executor()
.spawn(async move { import::import_instance(&file, &|_, _| {}) });
cx.spawn(async move |this, cx| {
let made = task.await;
let _ = this.update(cx, |this, cx| {
this.importing.busy -= 1;
this.importing.note = Some(made.map(|i| {
this.reload_instances();
this.select_instance(&i);
this.go(Page::Play);
format!("Made the instance {} from the shared file.", i.name)
}));
cx.notify();
});
})
.detach();
}
/// The root's drop target: every drop the sections don't take.
pub(super) fn import_drop_target(
&self,
root: Stateful<Div>,
cx: &mut Context<Self>,
) -> Stateful<Div> {
root.on_drop(cx.listener(|this, paths: &ExternalPaths, _, cx| {
this.import_dropped(paths.paths().to_vec(), cx);
cx.notify();
}))
}
/// What the last drop did, in a corner over the page until closed.
pub(super) fn import_note(&self, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let line = match (&self.importing.note, self.importing.busy) {
(_, n) if n > 0 => text(theme::LABEL)
.text_color(t.ink_soft)
.child("Making the instance from the shared file…"),
(Some(Ok(said)), _) => text(theme::LABEL)
.text_color(t.ink_soft)
.child(said.clone()),
(Some(Err(e)), _) => text(theme::LABEL).text_color(t.danger).child(e.clone()),
(None, _) => return None,
};
Some(
div()
.absolute()
.bottom(px(24.))
.right(px(24.))
.w(px(440.))
.child(
plate(&t)
.w_full()
.flex()
.items_center()
.gap(px(12.))
.px(px(16.))
.py(px(12.))
.border_1()
.border_color(t.rule)
.bg(t.page)
.child(blocks::icon("download", 18., t.accent))
.child(div().flex_1().min_w_0().child(line))
.child(button("import-close", "Close", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.importing.note = None;
cx.notify();
}),
)),
),
)
}
}
+19 -2
View File
@@ -96,7 +96,7 @@ impl Launcher {
}
}
fn select_instance(&mut self, instance: &Instance) {
pub(super) fn select_instance(&mut self, instance: &Instance) {
self.settings.instance =
(instance.kind != InstanceKind::Default).then(|| instance.id.clone());
self.settings.save();
@@ -541,7 +541,8 @@ impl Launcher {
)
}
/// The Play page's row for the selected instance: its folder, and changing or copying it.
/// The Play page's row for the selected instance: its folder, and changing, copying or
/// exporting it.
pub(super) fn instance_row(&mut self, n: usize, cx: &mut Context<Self>) -> Option<Div> {
let t = self.theme;
let instance = self.selected_instance()?.clone();
@@ -573,6 +574,22 @@ impl Launcher {
},
)),
)
.child(
button(
"instance-export",
if self.export.is_some() {
"Close export"
} else {
"Export…"
},
Kind::Ghost,
&t,
)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.toggle_export();
cx.notify();
})),
)
.child(
button("instance-folder", "Open folder", Kind::Ghost, &t).on_click(
move |_, _, cx| {
+1 -1
View File
@@ -267,7 +267,7 @@ impl Launcher {
)
.bg(self.theme.plate)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
copy_to_clipboard(cx, gpui::ClipboardItem::new_string(text.clone()));
this.internet.copied = Some((text.clone(), Instant::now()));
cx.notify();
}))
Loaded 100 of 118 files, more files were not shown because too many files have changed in this diff. Show more