Compare commits

...
Author SHA1 Message Date
Ludovic Marie 9033a9c3b1 Merge #2450 2026-10-27 02:12:33 +01:00
Ludovic Marie 19d1b2a741 Merge #2449 2026-10-27 02:12:33 +01:00
Ludovic Marie 9903e742f6 Merge #2439 2026-10-27 02:12:33 +01:00
Ludovic Marie 991db79d15 Merge #2438 2026-10-27 02:12:33 +01:00
Ludovic Marie 4845b77e06 Merge #2437 2026-10-27 02:12:33 +01:00
Ludovic Marie e2568161af Merge #2436 2026-10-27 02:12:32 +01:00
Ludovic Marie 9af5fb2f12 Merge #2434 2026-10-27 02:12:32 +01:00
Ludovic Marie 280eb89eed Merge #2484 2026-10-27 02:12:32 +01:00
Ludovic Marie 7a82db6937 Merge #2483 2026-10-27 02:12:32 +01:00
Ludovic Marie 03f0885752 Merge #2477 2026-10-27 02:12:32 +01:00
Ludovic Marie 60f05f309b Merge #2476 2026-10-27 02:12:32 +01:00
Ludovic Marie 22c184dcd8 Merge #2474 2026-10-27 02:12:32 +01:00
Ludovic Marie d14af43a1c Merge #2473 2026-10-27 02:12:32 +01:00
Ludovic Marie fdd1f3e958 Merge #2472 2026-10-27 02:12:32 +01:00
Ludovic Marie 691652b099 Merge #2470 2026-10-27 02:12:31 +01:00
Ludovic Marie 1eca57ed9b Merge #2469 2026-10-27 02:12:31 +01:00
Ludovic Marie 7da27831df Merge #2468 2026-10-27 02:12:31 +01:00
Ludovic Marie 42f655380a Merge #2467 2026-10-27 02:12:31 +01:00
Ludovic Marie 730a6e9a48 Merge #2466 2026-10-27 02:12:31 +01:00
Ludovic Marie f6b1a26448 Merge #2465 2026-10-27 02:12:31 +01:00
Ludovic Marie 5bd0362e86 Merge #2464 2026-10-27 02:12:31 +01:00
Ludovic Marie 216fd751f1 Merge #2462 2026-10-27 02:12:31 +01:00
Ludovic Marie 21c3498abc Merge #2461 2026-10-27 02:12:31 +01:00
Ludovic Marie 98ce2e54dd Merge #2460 2026-10-27 02:12:31 +01:00
Ludovic Marie d928c97862 Merge #2459 2026-10-27 02:12:31 +01:00
Ludovic Marie 5b8e344b5b Merge #2458 2026-10-27 02:12:30 +01:00
Ludovic Marie 254f828565 Merge #2457 2026-10-27 02:12:30 +01:00
Ludovic Marie 19f9b60093 Merge #2456 2026-10-27 02:12:30 +01:00
Ludovic Marie 3b1df392b6 Merge #2455 2026-10-27 02:12:30 +01:00
Ludovic Marie 5ba4740cbb Merge #2454 2026-10-27 02:12:30 +01:00
Ludovic Marie ae6a5029de Merge #2453 2026-10-27 02:12:30 +01:00
Ludovic Marie 8732145923 Merge #2452 2026-10-27 02:12:30 +01:00
Ludovic Marie c1ed16f410 Merge #2445 2026-10-27 02:12:30 +01:00
Ludovic Marie c9e627325b Merge #2444 2026-10-27 02:12:30 +01:00
Ludovic Marie 8bca5abc51 Merge #2443 2026-10-27 02:12:29 +01:00
Ludovic Marie f0e04f78f3 Merge #2442 2026-10-27 02:12:29 +01:00
Ludovic Marie d3523be123 Merge #2441 2026-10-27 02:12:29 +01:00
Ludovic Marie 3bd5ae069c Merge #2440 2026-10-27 02:12:29 +01:00
Ludovic Marie 8ada413ed3 Merge #2435 2026-10-27 02:12:29 +01:00
Ludovic Marie 5205766d60 Merge #2433 2026-10-27 02:12:29 +01:00
Ludovic Marie 2ec0a6fdd4 Merge #2432 2026-10-27 02:12:29 +01:00
Ludovic Marie d8f74a578a Merge #2431 2026-10-27 02:12:29 +01:00
Ludovic Marie 87881a9b1d Merge #2430 2026-10-27 02:12:29 +01:00
Ludovic Marie e0d829890c Merge #2429 2026-10-27 02:12:29 +01:00
Ludovic Marie 76f74a8e67 Merge #2427 2026-10-27 02:12:28 +01:00
Ludovic Marie 0f0e9623bc Merge #2424 2026-10-27 02:12:28 +01:00
Ludovic Marie c0ef6a78be Merge #2423 2026-10-27 02:12:28 +01:00
Ludovic Marie 60feb3db3c Merge #2422 2026-10-27 02:12:28 +01:00
Ludovic Marie eb04a80563 Merge #2421 2026-10-27 02:12:28 +01:00
Ludovic Marie b51f755607 Merge #2419 2026-10-27 02:12:28 +01:00
Ludovic Marie fd6fdcbf75 Merge pull request #2587 from ludovic111/feat/launcher-instances
🤖 Agent: launcher instances core (item 1 of launcher-features-1). Verify and the three-platform launcher build passed; fmt, clippy -D warnings and all launcher tests pass locally, including the migration test (no existing file moves); CLI and play checked in scratch homes.
2026-10-27 02:10:10 +01:00
Ludovic Marie fd1d6a2762 Merge pull request #2595 from ludovic111/feat/game-quick-play
🤖 Agent: game quick-play flags (item 4 of launcher-features-1): pr_ready --quick passed (1281 horizon-client tests, clippy, fmt); verify passed and CI's parity scope selected no harness for this argument-parsing change; checked by hand with the real game (missing world → title screen, existing world opens).
2026-10-27 02:03:26 +01:00
Ludovic Marie a0ef14f1e2 Merge pull request #2582 from ludovic111/perf/parity-one-client-profile
🤖 Agent: harnesses build horizon-client in one profile; 6 harnesses checked fresh on playhorizon and the Mac mini.
2026-10-27 01:58:38 +01:00
Ludovic Marie 0a75323caa Merge pull request #2584 from ludovic111/perf/parity-manifest-aware
🤖 Agent: crate splits' workspace-only manifest changes no longer select every harness (354 to 209 on #2160 and #2215); other PRs select as before.
2026-10-27 01:49:27 +01:00
Ludovic Marie 4f5ed6a19b Merge pull request #2588 from ludovic111/chore/workspace-members-per-line
🤖 Agent: merging. Formatting only: Cargo.toml's workspace members one per line (38, including #2160's), so new crates stop conflicting. tomllib parses it identical to main and Cargo.lock is unchanged, so no parity run is needed; verify and launcher pass.
2026-10-27 01:45:25 +01:00
Ludovic MarieandClaude Opus 5.5 d12dc5d1e1 Game: vanilla's --quickPlaySingleplayer and --quickPlayMultiplayer
The launcher starts the game straight into a world or a server. Vanilla's
names for that (client Main, QuickPlay) now work too: --quickPlayMultiplayer
is --server; --quickPlaySingleplayer opens a world of saves/ only if it
exists, as QuickPlay.joinSingleplayerWorld does, and shows the title screen
otherwise (--world also creates one).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:43:25 +01:00
Ludovic MarieandClaude Opus 5.5 64f9ff4529 Merge origin/main into chore/workspace-members-per-line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:37:20 +01:00
Ludovic Marie f5b7bdba1d Merge pull request #2160 from ludovic111/refactor/horizon-command
🤖 Agent: merging the first crate split (horizon-command + horizon-client-core, pure move, re-exported under the old paths). CI parity passed at 17f8419ac (head merged into main), verify and launcher pass, approved by rdbt's review agent; landed by hand while the REST pool refills.
2026-10-27 01:36:58 +01:00
Ludovic MarieandClaude Opus 5.5 1d0d827f3a Launcher: instances, each with its own game folder
The game's folder stays the default instance, "Horizon", where it is; imported
and registry modpacks are listed as instances in their own folders; new ones
go to instances/<slug>/ in the game's folder, empty (the player's options and
offline name) or a copy of another (never sessions, logs or telemetry). Names,
icons and groups live in the launcher's instances.json, so nothing moves.
Play, `play --instance` and MCP play start the selected instance, which
follows the launcher's data collection answer and No AI. Crash reports are
read from the run's own folder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:13:35 +01:00
Ludovic MarieandClaude Opus 5.5 844441874a docs: workspace crate features in a split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:11:27 +01:00
Ludovic Marie b6dd6c35ae Merge remote-tracking branch 'origin/main' into perf/parity-manifest-aware 2026-10-27 01:10:36 +01:00
Ludovic MarieandClaude Opus 5.5 1af4d0e432 parity: a workspace crate's features count through the sources they gate
A manifest line defining or turning on a workspace crate's feature no longer
makes the manifests count, if every source naming the feature (or one it turns
on) changed too, as in a split, where the new crate's files are all new.
#2215 (horizon-items with a test-support feature) now selects 209 harnesses
instead of 354.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:10:36 +01:00
Ludovic Marie 68c9597237 Merge remote-tracking branch 'origin/main' into perf/parity-one-client-profile 2026-10-27 01:09:18 +01:00
Ludovic MarieandClaude Opus 5.5 0db65d7a61 Cargo.toml: one workspace member per line
Each new crate then adds one line instead of rewriting the whole list, so
PRs that add crates at the same time stop conflicting (the crate split's
#2160 conflicted on it three times).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 01:06:57 +01:00
Ludovic Marie aef652c8af Merge pull request #2552 from ludovic111/ci/remote-fireball-not-flaky
🤖 Agent: remote_fireball passed 4 of 4 under load on playhorizon since #2499; off the known-flaky list.
2026-10-27 00:52:39 +01:00
Ludovic Marie 4be5c8eb53 Merge pull request #2196 from kacpherek/fix/axolotl-squid-water-rules
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Axolotl.handleAirSupply` (6000 air, refilled by water or rain, `dry_out` damage at -20) and `Squid.hurtServer`'s `spawnInk`. I pushed two fixes: `makeSound`'s voice pitch is drawn even when the squid is silent, so the ink's random draws stay in vanilla's order; and rain now uses the biome's height-adjusted temperature (`Biome.getTemperature` with the frozen modifier and the drop above sea level, `Level.isRainingAt`), not the base temperature, so an axolotl high up a mountain is not refilled by snow. On main with this head: clippy `-D warnings` clean and the 1186 horizon-client lib tests pass. CI parity runs the harnesses this head reaches. Thanks @kacpherek!
2026-10-27 00:09:19 +01:00
Ludovic Marie 6ed9e9632d Merge pull request #2557 from ludovic111/ci/full-parity-playhorizon
🤖 Agent: playhorizon takes PR parity jobs and full-parity ones; the desktop's runner is retired (maintainer's request).
2026-10-27 00:05:55 +01:00
Ludovic MarieandClaude Opus 5.5 111db2827b ci: full-parity and PR parity jobs on playhorizon, not the desktop
The maintainer asked that CI stop using his desktop (2026-10-26). playhorizon's
runner now carries horizon-game and the desktop's runner none of the job labels;
full-parity jobs go to playhorizon only, with a 6 h timeout on its 4 CPUs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 17f8419acd Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:40:59 +01:00
Ludovic MarieandClaude Opus 5.5 9555f375d4 parity: remote_fireball is no longer known flaky (#2499)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:31:29 +01:00
Ludovic MarieandClaude Opus 5.5 1b1571ed6a parity: a crate split's manifest changes don't select every harness
Cargo.lock is a shared input and every harness depends on its crates'
Cargo.toml, so a crate split (#2160) selected all 352 harnesses although
its moved code reaches 123. With --changed, Cargo.lock and Cargo.toml
changes that only add, remove or move workspace crates (no registry or git
package, version, feature, dependency or build setting changed across them,
compared with the merge base) are left out; the moved code counts through
its own sources. Any other manifest change still counts, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 a891037c9f parity: harnesses build horizon-client in the fast profile only
Under parity.py the release and fast profiles have identical settings
(PROFILE: no LTO, 16 codegen units, incremental), yet 38 harnesses built
horizon-client in release and 16 others in fast: every run where the client
changed compiled it twice. They all use fast now. Release stays for
horizon-world's probes, which no fast harness builds.

Measured on playhorizon (4 CPUs), all 13 probe builds after 78 merges:
1578 s, of which the release client (bin + client_probe) took 485 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:24:54 +01:00
FusselTVandClaude Opus 5.5 266c7a2cd9 Worldgen: finalize structure mobs at placement, as vanilla
Structure mobs (bastion piglins, brutes and hoglins, ocean ruin drowned,
village and mansion mobs, witch huts, monuments) were tagged and finalized
at load by the cut-down spawning routine: no STRUCTURE reason (piglin
weapons rerolled), no Mob step (follow range bonus, left-handedness), no
baby hoglins, no brain memories, no drowned nautilus mounts, and draws
from each mob's own random. The decoration region now calls a hook the
game installs in the generator workers, which runs mobs::finalize with
Reason::Structure, the region random in placement order and the level's
difficulty with no inhabited time; jockeys come back as the vehicle with
its Passengers. A mob the hook cannot finalize keeps its tag and is
finalized on load as before, as are mobs of older saves; other entities
drop the tag. The generator gets the level's difficulty as soon as the
level opens, so the spawn area's mobs use it too. mob_to_nbt writes Invulnerable and LeftHanded always, IsBaby
for zombies and piglins, Age for hoglins, the finalized attributes and
memories, and drops the template keys vanilla never saves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 23:12:22 +01:00
Ludovic Marie a727f617f0 Merge pull request #2532 from ludovic111/feat/sculk-sidebar
🤖 Agent: merging. Ludovic asked for the Sculk banner visible on open without scrolling, hideable after a while. Launcher run with isolated homes: banner at the sidebar's foot, HIDE after 3 min seen; clippy -D warnings and 137 launcher tests pass.
2026-10-26 23:05:17 +01:00
Ludovic MarieandClaude Opus 5.5 12bf15cfd7 launcher: the sidebar's partner banner can be hidden once it has been seen
A "Hide" shows beside its label after the banner has been on screen for
3 minutes, counted over every start (settings: partner_seen_secs, saved
each minute). Hiding it is kept (partner_hidden); the Servers page keeps
its own banner.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:58:09 +01:00
Ludovic MarieandClaude Opus 5.5 b0783dac38 launcher: Sculk's banner at the foot of the sidebar, seen without scrolling
On Home it sat under every row, so nobody saw it without scrolling. It now
sits small (228 px wide) above the Discord and Website links on every page
but Servers, which keeps the large one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:54:33 +01:00
Ludovic Marie 840d11a3b2 Merge pull request #2499 from ludovic111/fix/remote-fireball-ticks
🤖 Agent: test-health-1: remote_fireball flake fix (slow fireball, one ctl script; 4/4 on playhorizon); checks green.
2026-10-26 22:44:46 +01:00
Ludovic Marie 4504223265 Merge pull request #2502 from ludovic111/ci/full-parity-desktop
🤖 Agent: test-health-1: full-parity label routes a PR's parity job to the desktop runner (verified on this PR); checks green.
2026-10-26 22:39:32 +01:00
Ludovic Marie 774fa9d897 Merge pull request #2186 from kacpherek/fix/soul-speed
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3 Soul Speed: the speed on soul blocks, no soul sand slowdown, the particles and sound, and boot wear on 4% of ticks, now also for each player on a server (`guest_server_tick`), with the particle sent to the players around. I pushed one fix: the random draws come in vanilla's order (the 0.04 wear roll first, then the particles, then the 0.35 sound roll and its pitch). It then conflicted with main (#2190 added the elytra glide wear in the same tick), so I merged main in: Soul Speed comes first, as `LivingEntity.baseTick` runs before `aiStep`, then the glide wear, so both draw from the level random in vanilla order. On that merge: clippy `-D warnings` clean, horizon-client tests pass, the fast build links, and `lan_e2e.py` elytra, damage and breath pass with the host in-frame and on its server thread. CI parity runs the harnesses this head reaches. Thanks @kacpherek!
2026-10-26 22:39:29 +01:00
Ludovic Marie 0c7f6b7f0b Merge pull request #2446 from ludovic111/feat/official-recipes-mod
🤖 Agent: #2318 has merged and this PR's base is main. The official Recipes mod (needs API level 6): pr_ready --quick green; the e2e passes 25/25 in-frame and on a dedicated server on this head (61 of 63 in five modes before; the 2 failures were #2501's bug, now merged). Queued at 92e3f496f.
2026-10-26 22:33:36 +01:00
Ludovic Marie c1a811c4bb Merge pull request #2318 from ludovic111/feat/mod-recipe-actions
🤖 Agent: #2306 has merged and this PR's base is main. recipes.place and recipes.give at API level 6, give cut to one stack. Sandbox (31), api_reference and clippy -D warnings pass; the actions e2e passed 58/58 in five modes. Queued at bd1436f16.
2026-10-26 22:31:39 +01:00
Ludovic Marie 19bb6c1262 Merge pull request #2306 from ludovic111/feat/mod-recipe-pages-game
🤖 Agent: #2305 has merged and this PR's base is main. Recipe pages with station tabs and an ellipsis on a long title; clippy -D warnings with --features mods and --no-default-features is clean, and the browser e2e passed 98/98 in five modes. Queued at 0cba2edbd.
2026-10-26 22:29:19 +01:00
Ludovic Marie 9bf853804e Merge pull request #2481 from ludovic111/perf/prefetch-world-data
🤖 Agent: verify passed (incl. Linux clippy for the glibc malloc_trim path); chunk_generation, worldgen, worldgen_random, save_roundtrip and world_reopen pass on this head; pr_ready --quick green. Preload starts on world select per the orchestrator's decision. Perf fix, no label.
2026-10-26 22:29:16 +01:00
Ludovic Marie 7e4ddee8b1 Merge pull request #2305 from ludovic111/feat/mod-recipe-pages
🤖 Agent: #2301 has merged and this PR's base is main. browser.set-recipes (with station tabs) at API level 6; sandbox (31), api_reference and clippy -D warnings pass. Queued at a373eea18.
2026-10-26 22:27:04 +01:00
Ludovic MarieandClaude Opus 5.5 f8ba650559 ci: full-parity label runs a pull request's parity job on the desktop
A crate split reaches every harness (#2160: 352 harnesses, 71 min on a Mac
mini runner) and held the Mac mini's runners while other pull requests'
parity jobs queued for over an hour. With the full-parity label, the job
runs on the desktop's runner only (8 at a time) and waits for it while the
desktop is in use; its timeout is 300 min, since a job under way when
someone comes back goes on with 2 threads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:25:11 +01:00
Ludovic Marie 92b99c58aa Merge pull request #2301 from ludovic111/feat/mod-item-panel
🤖 Agent: #2300 has merged and this PR's base is main. Merged up the renumbered stack (browser at API level 6); clippy -D warnings with --features mods and --no-default-features is clean, and the browser e2e passed 98/98 in five modes before. Queued at a2cd70e8e.
2026-10-26 22:24:13 +01:00
Ludovic Marie 7e82902d15 Merge pull request #2300 from ludovic111/feat/mod-item-panel-api
🤖 Agent: #2284 has merged and this PR's base is main. browser is at API level 6 (recipes took 5). Sandbox (31, the UI guest at level 6), api_reference and clippy -D warnings pass. Queued at b836eea06.
2026-10-26 22:21:58 +01:00
Ludovic Marie d090686ffa Merge pull request #2500 from ludovic111/tools/remote-cleanup
🤖 Agent: test-health-1: remote.sh stops the host command with its caller and cleans what it leaves; checks green.
2026-10-26 22:21:54 +01:00
Ludovic Marie aba4f19a81 Merge pull request #2284 from ludovic111/feat/mod-recipes-read
🤖 Agent: #1895 has merged and this PR's base is main. Merged main: recipes moves to API level 5 (world took 4). Sandbox (30, including the level assertion), api_reference and clippy -D warnings on horizon-mods pass. Queued at b360e3c26.
2026-10-26 22:19:22 +01:00
Ludovic Marie 5a1d1c38ed Merge pull request #2501 from ludovic111/fix/lan-guest-stations
🤖 Agent: LAN guests (host on its server thread) and a dedicated server's new players joined inside each other on the exact spawn, so they clicked the other player instead of a table or chest. New players now get PlayerSpawnFinder.findSpawn as in vanilla. lan_e2e spawned/crafting/chest pass in both host modes, a dedicated two-guest check passes, and the 32 affected harnesses pass.
2026-10-26 22:19:19 +01:00
Ludovic MarieandClaude Opus 5.5 cc58083574 Give the dropped preload's memory back to the OS
glibc keeps the pages the preload thread freed in that thread's arena, and
macOS keeps them in its zones until memory runs short, so dropping the
preload didn't lower the footprint: horizon-threads now asks the allocator to
return free pages (malloc_trim, malloc_zone_pressure_relief).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:14:56 +01:00
Ludovic Marie aa0eb406d7 Merge pull request #2496 from ludovic111/fix/dedicated-server-steps
🤖 Agent: test-health-1: dedicated_server steps wait for the server's reply (flake fix, passes under injected stalls); checks green.
2026-10-26 22:14:45 +01:00
Ludovic MarieandClaude Opus 5.5 8f02822a9a remote.sh: stop the command on the host with its caller, and what it leaves running
The command ran on after remote.sh was stopped or its connection dropped,
holding the name's lock, and a game or daemon it left behind outlived the
run. It now runs in a scope named after the run (remote-<name>.scope),
stopped when the command ends, when the ssh session ends first, and before
the next run under that name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:09:44 +01:00
Ludovic MarieandClaude Opus 5.5 031f1e58a3 remote_fireball: a slow fireball, sampled from one ctl process
The harness sampled the fireball from a new horizon-ctl process each time,
10-16 ticks apart on a loaded runner: the fireball (~2 blocks a tick) left
the cleared corridor or the guest's tracking range before the sixth sample,
and the harness failed. It now flies slowly (acceleration_power 0.02, under
0.4 blocks a tick) down a longer corridor, and the guest takes 16 samples
in one ctl script (lan_e2e's Game.script returns every reply with every=True).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:07:12 +01:00
Ludovic MarieandClaude Opus 5.5 d3a662b760 Merge origin/main into refactor/horizon-command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 22:06:35 +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 Marie 92e3f496f0 Merge branch 'feat/mod-recipe-actions' into feat/official-recipes-mod
# Conflicts:
#	Cargo.toml
2026-10-26 22:01:02 +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 Marie bd1436f162 Merge branch 'feat/mod-recipe-pages-game' into feat/mod-recipe-actions
# Conflicts:
#	crates/horizon-mods/src/api_level.rs
#	crates/horizon-mods/src/manifest.rs
#	docs/api/mods-reference.md
#	docs/api/stability.md
2026-10-26 22:00:44 +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 1fe0f5fcb9 Sound options: Music Frequency starts its own row, as in SoundOptionsScreen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:59:39 +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 c4d0cb1ec0 A small armor stand's eyes are at 0.9875, and eye heights are floats
ArmorStand.BABY_DIMENSIONS keeps its own eye height rather than half the
type's, and EntityDimensions.eyeHeight is a float widened by getEyeY.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:55:51 +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
Ludovic Marie 0cba2edbd4 Merge branch 'feat/mod-recipe-pages' into feat/mod-recipe-pages-game 2026-10-26 21:52:49 +01:00
Ludovic Marie d6a80c5193 Merge pull request #2489 from ludovic111/tools/remote-linux
🤖 Agent: test-health-1: tools/remote.sh runs agents' Linux work on playhorizon (fetch from GitHub, no secrets sent); verify green.
2026-10-26 21:52:49 +01:00
Ludovic Marie a373eea189 Merge branch 'feat/mod-item-panel' into feat/mod-recipe-pages
# Conflicts:
#	crates/horizon-mods/src/api_level.rs
#	docs/api/stability.md
2026-10-26 21:52:43 +01:00
Ludovic Marie a2cd70e8e1 Merge branch 'feat/mod-item-panel-api' into feat/mod-item-panel
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-26 21:51:29 +01:00
Ludovic MarieandClaude Opus 5.5 331e3a7202 dedicated_server: steps wait for the server's reply, not a 1.5 s timer
The parity client's chat, villager and furnace steps went on 1.5 s after
sending, so a busy server's late reply landed in the next step (or a click
carried a stale state id). Each step now waits until the server answered:
its chat line, both slot packets of a shift-click, and the furnace totals
the click changes (up to the same 30 s patience). The respawn window keeps
only the player's own entity packets: a squid's swim event or a mob coming
into view falls into it as each server's ticks fall.

Off the known-flaky list: it passes under 2.5 s server stalls every 5 s,
where main's client fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:49:24 +01:00
Ludovic MarieandClaude Opus 5.5 b836eea062 Merge #2284 (recipes now at API level 5): browser moves to level 6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:47:46 +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
Ludovic Marie effb29e571 Merge pull request #2480 from ludovic111/perf/requeue-on-centre-change
🤖 Agent: verify passed; chunk_generation, worldgen, worldgen_random, save_roundtrip and world_reopen pass locally on this head plus #2481; pr_ready --quick green. Perf fix, no label.
2026-10-26 21:28:37 +01:00
Ludovic MarieandClaude Opus 5.5 26561d315c Preload the world data while the player chooses a world
The worldgen bundle, the loot tables and the registries a server sends are
read on a helper thread from the moment the player opens Singleplayer or
Create World, so a world opening finds them ready. A world opened meanwhile
waits for the part being read. If the player goes back to the title or to
Multiplayer and opens no world for a minute, the preload is dropped: the three
caches can now let go of what they hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:26:47 +01:00
Kacpherek 65c60ab996 Play remote sounds in the category the packet names 2026-10-26 21:23:38 +01:00
Kacpherek 56bf45cee5 Keep the pack screen selection in step with High Contrast 2026-10-26 21:23:03 +01:00
Ludovic MarieandClaude Opus 5.5 4afd517c22 tools/remote.sh: run a command on playhorizon at a worktree's commit
The host fetches the commit from GitHub (nothing else is sent), keeps one
checkout and target per worktree, runs the command niced in its CI slice on
CPUs 4-7 with scratch homes, prepares vanilla data for harness commands as
CI does, and streams the output back. One run per name at a time; no lock
leaks into daemons the run starts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:22:46 +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 0e96fb6d2a Disable unavailable options in the Graphics Advanced screen 2026-10-26 21:21:06 +01:00
Kacpherek f4a235e027 Record a witch potion source only when it deals damage 2026-10-26 21:20:45 +01:00
Ludovic Marie cb43e32787 Merge pull request #2479 from ludovic111/tools/pr-ready-like-ci
🤖 Agent: test-health-1: pr_ready now runs CI's clippy targets and features, the server clippy pass and the Python tests; verify green.
2026-10-26 21:16:48 +01:00
Ludovic MarieandClaude Opus 5.5 67c27701e9 Server: new players join around the spawn, not all on one spot
The server put every player new to the world exactly on the world spawn.
On the server thread the host's own player joins that way too, so the host
and each new guest stood inside each other: the guest's client targets
the host's player (its eye inside that box, as vanilla's getEntityHitResult
does), so it could open no crafting table, chest or furnace, and Entity.push
never parts two players with the same centre. A dedicated server stacked
its new players the same way.

Vanilla places each new player with ServerPlayer.adjustSpawnLocation:
PlayerSpawnFinder.findSpawn, a free column at a random offset within the
respawn radius. Game::server_player_spawn does it for a server's new
players with the window's own finder, passing over the candidates whose
chunk isn't loaded yet (vanilla waits for them; a server tick can't).

lan_e2e's new check `spawned` asserts that the host and the guest join on
different spots, then has the guest open a crafting table, a chest, a
furnace and a stonecutter where it joined.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:16:43 +01:00
Ludovic MarieandClaude Opus 5.5 69bb813128 game: cut a recipe page's title with an ellipsis
A title wider than the panel ended at its edge mid-word ("Recipes: Oak P");
it now ends with vanilla's "..." (CommonComponents.ELLIPSIS).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:16:34 +01:00
Ludovic MarieandClaude Opus 5.5 b360e3c26c Merge main (#1895: world takes level 4): recipes moves to API level 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 21:14:13 +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
Ludovic Marie 9312411e53 Merge pull request #2451 from ludovic111/ci/parity-known-flaky
🤖 Agent: merging #2451 (test-health-1): verify and parity passed at e82fa3d95.
2026-10-26 21:00:51 +01:00
Ludovic Marie 9cc9792b28 Merge pull request #2448 from ludovic111/ci/parity-merge-main
🤖 Agent: merging #2448 (test-health-1): verify and parity passed at df045f1e9.
2026-10-26 21:00:47 +01:00
Ludovic MarieandClaude Opus 5.5 37559dae82 pr_ready: run CI's clippy, CI's test features, Python tests and launcher rule
pr_ready --quick passed #1895 while CI's clippy failed on an unused import in horizon-mods' tests: clippy on the
touched crates and their dependents gives them other features than CI's workspace-wide run (Cargo unifies
features over the packages it builds).
- clippy: CI's two commands as they are. The whole workspace runs with the game's code mods, and the server build
  (horizon-client --no-default-features) runs when horizon-client is affected. Warm on the Mac mini, a change to
  hud.rs took 14-16 s against 17 s before, and food.rs 22-32 s against 14 s. Each worktree's first run costs
  about 3 min once.
- tests stay on the touched crates, with the features CI's workspace run gives them: horizon-client's code mods,
  and horizon-mods' runtime, pack and index.
- The Python tests always run, as in CI: some read the crates, the roadmap or the workflows.
- The launcher steps run when CI's launcher job would (tools/ci/rust_changes.py's rule, which also covers the
  root manifests and the check itself).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:58:06 +01:00
Ludovic Marie fff9cd1580 Merge pull request #1895 from ludovic111/feat/server-mods-world
🤖 Agent: merged main for #2316 (the campfire fix: this head had #2015 without it, so dispenser and block_removal would fail parity). Clippy -D warnings on horizon-mods (all targets, all features) and on horizon-client --features mods --bins is clean. Requeued at 8ec47d641.
2026-10-26 20:55:03 +01:00
Ludovic MarieandClaude Opus 5.5 bb9b0424b4 Axolotl rain: the biome's height-adjusted temperature
Level.isRainingAt takes Biome.getHeightAdjustedTemperature, which applies
the frozen oceans' temperature modifier and the height noise above the snow
level; reuse horizon-world's version instead of a simplified copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:52:11 +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
Ludovic Marie 565cf6af4e Merge pull request #2205 from kacpherek/fix/turtle-eggs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: turtles lay (`TurtleLayEggGoal` with the digging effect), `TurtleEggBlock` hatches, and a growing baby drops a scute. I pushed one fix: the home is set at spawn and kept on load (`finalizeSpawn`, `home_pos`), and breeding drops one orb of 1 to 7, drawn from the mother's random and only with `mob_drops`. After #2137 the gift loot event names its mob. I merged main into your branch (6ef61ac13): main's `Reinforcements` stays beside your `BlockBreakEffect`, and the grown scute now goes through #2137's `drop_gift_loot` (the turtle's id added to both `GiftLoot` events) behind your mob_drops check; clippy clean, same code as the tested tree. Gate: this wave's 55 PRs merged on main in one tree: clippy `-D warnings` clean, 1186 horizon-client and 163 horizon-world tests pass, and of the 101 harnesses `parity.py --changed-files` picked, 100 pass (`block_removal` failed on one spawner case from #2199's orb merging, fixed there). `lan_e2e.py` passes 24 checks with the host on its server thread and in-frame (chest, xp, pvp, doors, villagers, beds, frames, projectiles, arrows, fishing, damage, mobs, blast, drops, elytra, breath, predict, cracks, barter, kills, hurt, contacts, underwater, plates), plus forced-levels on the server thread. Thanks @kacpherek!
2026-10-26 20:43:59 +01:00
Kacpherek 34dabe4afd Grey out Chunk Fade-in, Improved Transparency and Chunk Builder 2026-10-26 20:41:49 +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 ad6f2320b0 Wire the Music Frequency option to the music manager 2026-10-26 20:40:06 +01:00
Kacpherek 4201fd23c0 Use real eye heights in /execute and keep the anchor on positioned as 2026-10-26 20:39:05 +01:00
Ludovic MarieandClaude Opus 5.5 8920f08fd9 Streaming: rank queued jobs again when the centre or the loading screen changes
Jobs queued while the loading screen was up carried ring-order priorities,
far above the plain distances used once it closed, so they waited behind
every later job. The proto chunks they generated held back the decoration
of the chunks around the player: at render distance 32 in-frame, nothing
was decorated until every proto chunk had been generated. Queued jobs now
keep their column and bias and are ranked again from the current centre,
extra areas, band and loading screen when those change, as vanilla's
chunk task queue follows the current ticket levels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:39:04 +01:00
Kacpherek 46b71954e1 Make the High Contrast option turn its resource pack on and off 2026-10-26 20:38:38 +01:00
Kacpherek b92a53e47c Swing and pose the main arm of a left-handed mob 2026-10-26 20:37:52 +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
Ludovic MarieandClaude Opus 5.5 6ef61ac13b Merge origin/main into the turtle branch
Keeps main's Reinforcements beside BlockBreakEffect, and the turtle's grown
scute goes through #2137's drop_gift_loot (the mob's id added to its two
GiftLoot events) behind the mob_drops check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:31:43 +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
Ludovic MarieandClaude Opus 5.5 e82fa3d957 parity: retry a known flaky harness once, and say so
tools/parity_hosts.json's new `known_flaky` lists the harnesses that still flake for a cause not fixed yet,
each with its reason: dedicated_server (its Java client's 1.5 s step timer), remote_fireball (wall-clock
sampling) and world_reopen. One of them that fails runs once more from scratch, and a pass then counts.

Every retry prints a RETRIED line at the end of the run and in the CI job's summary, so a harness that
passes only on its retry stays visible on PR parity and parity-main alike. The existing fresh rerun of a
failure that reused oracle results is reported the same way. A known flaky harness skips that rerun and keeps
reusing oracle results, because flaking doesn't mean its cached oracle runs were wrong. Repeated runs
(--repeat, --flake-check) are never retried.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:27:34 +01:00
Ludovic MarieandClaude Opus 5.5 d026c6db3e Merge origin/main into fix/soul-speed
Keeps #2184's glide wear with Unbreaking after Soul Speed's tick, the order
of LivingEntity.baseTick and aiStep (both draw from the level random), and
both the can_glide_through and soul_speed_blocks tags.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:26:50 +01:00
Ludovic MarieandClaude Opus 5.5 df045f1e9a CI parity: test the head merged into main
A head tested alone keeps failing harnesses that main already fixed. Since 10-25 evening, 9 of 21 failed PR
parity jobs failed only on such fixes: server_knockback (heads without #2114, 7 times), mob_ai's oracle heap
(without #2270), save_roundtrip (without #2212). #2160 needed 4 parity runs this way.

The job now merges main's current tip into the head and selects harnesses against that main commit, so only
the pull request's own changes count. A branch that doesn't change the runner scripts or the selection gets
main's, which replaces the two steps that copied main's vanilla-data.sh and parity.py in by hand. A head that
conflicts with main is tested alone, with a warning. The job summary names the main commit, and the check is
still `parity / parity` on the head.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 20:22:13 +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 efd6c60a27 Axolotl air: refill only where it rains locally, not in deserts or snowy biomes 2026-10-26 20:07:36 +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
Ludovic MarieandClaude Opus 5.5 d241c54e05 Official mod: Recipes, a recipe viewer
Every item sits in the item panel beside the inventory and container
screens, with a search box (words in the name or id, @namespace, #tag).
R or a click shows an item's recipes, U or a right click its uses, with a
tab per station; Back returns through the pages. + beside a crafting
recipe the player knows fills the grid as the recipe book does. In
creative mode, with "Click gives items" on, a click gives one item and a
right click a stack. The keys are rebindable in Controls.

Written from vanilla 26.3's recipe book and screens and Horizon's design
system only. On servers that are not Horizon it shows the game's own
26.3 recipes; known recipes and + follow the server's recipe book.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:58:45 +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
Ludovic MarieandClaude Opus 5.5 b28b499135 mods: recipes.give hands out one stack at most
As the creative inventory does: a count over the item's stack size (64, 16
or 1) is cut to it, so a mod can ask for a full stack without knowing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:41:35 +01:00
FusselTVandClaude Opus 5.5 61df807b33 Structures: a chunk saves its starts also when no piece reaches it
A chunk's STRUCTURE_STARTS are the starts made in it
(StructureManager.getAllStarts), whether or not one of their pieces
reaches it. own_starts filtered the chunk's references, which only hold
starts with a piece in the chunk, so a start whose pieces all lie next
door (seed 8675309's giant ruined portal in chunk -26, 23) was not saved.
The proto chunk now keeps the starts made at its position.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:32:44 +01:00
FusselTVandClaude Opus 5.5 ec81bea5bf Worldgen: blocks with a block entity get vanilla's default one
WorldGenRegion.setBlock gives every block that has a block entity a new
one, so vanilla saves the sculk sensors, catalysts and shriekers of the
deep dark and potent sulfur with their block entities. Horizon only
saved block entities that a structure or feature placed data for, and
these kinds have none, so a Horizon-made world had none of them (3,035
sculk sensors, 753 catalysts, 329 shriekers and 21 potent sulfur over
three 1,024-block seeds). A finished column now adds vanilla's defaults
(block_entity_nbt::canonical) for every such block: as the modelled
block entity where Horizon has one (sculk), as raw NBT otherwise.
Potent sulfur gets its block entity type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:53 +01:00
FusselTVandClaude Opus 5.5 1eb7a04a4e End: no exit portal while nobody is in the fight
With force-loaded levels ticking without players (#1525), Horizon placed
the exit portal as soon as the End's main island was loaded. Vanilla
only places it from scanState and the dragon's death, both with players
in the fight, so a pre-generated End has no portal until someone
arrives.

test_forced_end_parity force-loads the End's arena on vanilla's and
Horizon's dedicated servers with nobody online and compares what is
around 0, 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:44 +01:00
FusselTVandClaude Opus 5.5 55a066e88a End: spike crystals are saved with the chunk that generates them
SpikeFeature.placeSpike adds each crystal to the WorldGenRegion, so a
pre-generated End holds its ten crystals before anyone ticks it.
Horizon's decoration dropped spawn_end_crystal and the game placed the
crystals later, once the arena was loaded and ticking. An End generated
with ticks frozen, as a seed generator does, had none. The decoration
region now writes the crystal like any placed entity; the game's own
placement still skips a spike that has its crystal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 8c2ef15171 Falling suspicious sand and gravel break where they land
BrushableBlock.tick makes the falling block entity with disableDrop, so
vanilla breaks it on landing (onBrokenAfterFall: level event 2001 and
the block_destroy game event) and
places nothing. Horizon landed it as a plain block without its
archaeology block entity. Seen in pre-generated worlds: suspicious gravel
of cold ocean ruins that sits over water fell during the first ticks and
landed one block lower without its block entity, so with no loot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
FusselTVandClaude Opus 5.5 705e3d10cf Force-loaded chunks decorate in vanilla's order
Chunks no player ticket reached were decorated nearest the centre first.
Vanilla's ForceLoadCommand adds a FORCED ticket per chunk, x-outer and
z-inner, and the FULL tasks run in that order, so a chunk decorates
before its neighbour at higher x or z. Both orders agree only in the +x,
+z quarter of the map. Elsewhere features crossing chunk borders
overwrote each other the other way round: a geode next door ate a trial
chamber's dispensers, sculk or a clay pool closed a dungeon so it was
never placed, and ore, stone and tree blobs swapped.

The forceload command now ranks the chunks it adds in vanilla's order
(decoration_order::forced_order, which shares its scheduler with
vanilla_order); chunks without a rank still go last, nearest first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:31:06 +01:00
Ludovic MarieandClaude Opus 5.5 edc4a58397 Merge origin/main into refactor/horizon-command
Brings #2316 (the campfire block entity fix): CI's parity checks out this
branch's head, so dispenser and block_removal need it here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:22:41 +01:00
Ludovic Marie f6ff2319a5 Merge branch 'feat/mod-recipe-pages-game' into feat/mod-recipe-actions 2026-10-26 19:19:55 +01:00
Ludovic MarieandClaude Opus 5.5 5d7097877b game: station tabs on the recipe pages
A row of tabs above the recipe pages shows `set-recipes`' stations (their
item in a slot, a white frame on the selected one). A click sends `tab(i)`
to the mod, R/U over a tab works like over any slot, and horizon-ctl lists
them as "<name> tab". Changing tab resets the page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 19:19:30 +01:00
Ludovic Marie 8ec47d6419 Merge remote-tracking branch 'origin/main' into feat/server-mods-world 2026-10-26 19:14:31 +01:00
Kacpherek e2b1dc6691 Soul Speed: run its effects for each server-side player 2026-10-26 18:49:12 +01:00
Ludovic MarieandClaude Opus 5.5 eb66e42e38 Merge feat/mod-recipe-pages into feat/mod-recipe-pages-game
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:39:55 +01:00
Ludovic MarieandClaude Opus 5.5 6a62ad67cc mods: set-recipes takes a row of tabs, and the tab event says which one the player picked
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:39:46 +01:00
Ludovic MarieandClaude Opus 5.5 a4764f5b0d Merge origin/main into refactor/horizon-command
Brings #2212 (namespaced furnace RecipesUsed), which save_roundtrip needs:
CI's parity checks out this branch's head, not a merge with main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:36:14 +01:00
Ludovic MarieandClaude Opus 5.5 db13c68585 mods: recipes.place and recipes.give, a recipe viewer's actions
recipes.place(book-id, all) is the recipe book's click: in the open
inventory's or crafting table's grid it sends ServerboundPlaceRecipePacket
on a server, or places it in this world, refused as handlePlaceRecipe
refuses it (no crafting screen, a recipe the player's book doesn't hold, an
impossible one); a recipe missing items shows as a ghost. recipes.give(stack)
gives a creative player the stack as the creative inventory does; a server
hears it as SetCreativeModeSlot through the inventory's sync. Both answer at
once from the tick's snapshot (placing, creative, known) and take at most 36
calls a hook call.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:28:26 +01:00
Ludovic MarieandClaude Opus 5.5 61ff050927 mods: drop the sandbox test's unused LEVEL import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:11:56 +01:00
Ludovic MarieandClaude Opus 5.5 ad557fef47 game: a mod's recipe pages in the item panel
browser.set-recipes replaces the panel's items with the mod's recipes,
laid out with the game's slot, furnace arrow and flame sprites and small
buttons: a crafting grid, one input over a flame, one input, three inputs
or two, then an arrow and the result, a note under it and the action
button beside the result (under it when the panel is narrow). Slots cycle
through their stacks once a second. As many recipes as fit the panel's
height make a page, with the recipe book's arrows; a title row holds a
Back button and the title. The same title sent again keeps the page.

Clicks on the slots, the action button, the arrows and Back reach the mod;
tooltips and the mods' keys work on the recipes' stacks as on the items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 fc9305bb15 mods: browser.set-recipes, recipes in the item panel's place
A mod gives the recipes to show (a layout: crafting grid, cooking, single
input, smithing or brewing; the slots, each cycling through its stacks;
the results; a note; and an optional action button, enabled or greyed)
under a title; an empty list shows the items again. The game pages them
and sends the slot-clicked, action, page and back events.

At most 256 recipes, 9 slots each and 64 stacks a slot. Same level as the
rest of browser (5): none of it has shipped yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 18:03:42 +01:00
Ludovic MarieandClaude Opus 5.5 763fc284ed Merge feat/mod-item-panel-api into feat/mod-item-panel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:51:36 +01:00
Ludovic MarieandClaude Opus 5.5 b8da45f5ef mods: browser's page event names the first recipe shown, set-recipes' indexes are its list's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:51:32 +01:00
Ludovic MarieandClaude Opus 5.5 487a461a8a game: the item panel of a mod's browser beside container screens
The panel sits right of the inventory or container window, from the top of
the screen to a search box at its foot, in up to 12 columns of the game's
own item slots, with the recipe book's page arrows. It shows on the
survival inventory and every container screen (not the creative one) while
a running mod has items in it, and hides when there is no room for two
columns. Clicks on it never throw the carried stack; the carried stack and
tooltips are drawn again above it.

The mod hears clicks on items, the search text as it changes, and its own
keys pressed with the pointer on an item, the panel's or a slot's. The
search box takes every key but Escape while focused, as the recipe book's.
Each set-items is turned into stacks once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 aa839fa99a mods: browser, a recipe viewer's item panel (API level 5)
A new interface under capability ui. browser.set-items lists the items of
a panel the game shows right of the inventory and container screens (an
empty list hides it); browser.poll returns what the player did since the
last poll: a click on an item, the search text, or one of the mod's own
keys pressed over an item (a slot's or the panel's), with that stack. The
slot-clicked, action, page and back events are there now for the recipe
pages to come, since adding variant cases later would break mods.

At most 8192 items and 256 waiting events. The game side is the next PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:46:29 +01:00
Ludovic MarieandClaude Opus 5.5 96984300d3 mods: the UI guest's level is 3 now that world is level 4
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 17:07:20 +01:00
Ludovic MarieandClaude Opus 5.5 8bb27b44ca Merge origin/feat/mod-run-quiet into feat/server-mods-world; world at API level 4
#2171 takes level 3 (command.run-quiet), so the world interface's functions
take level 4. The workspace members keep main's horizon-hdr and this branch's
beacons example.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:51:29 +01:00
Ludovic Marie c3f5e9f79c Merge remote-tracking branch 'origin/feat/mod-run-quiet' into feat/server-mods-world
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-26 16:49:04 +01:00
Ludovic MarieandClaude Opus 5.5 409a828128 mods: capability recipes (API level 4), the game's items and recipes for recipe viewers
recipes.items() lists every item in registry order, with its name in the
player's language and its tags. recipes.all() lists every recipe: crafting
and furnace ones from the recipe book's own entries (vanilla's displays and
display ids), then campfire, stonecutting, smithing and brewing (display
only). Stacks point into the item table by index. recipes.known() gives the
book ids the player's recipe book holds, from the client book in every mode,
so on a server it follows the server's book.

The game builds the tables once, the first time a running mod reads them.
Experimental, new functions only. Smithing::Transform now keeps its recipe
id.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:11:29 +01:00
Ludovic MarieandClaude Opus 5.5 608a6d9bee Turtles: a fixed home from spawn or load, one breeding orb
Turtle.finalizeSpawn sets homePos to where it spawns, and loading falls
back to the block it stands on; the home no longer moves with a turtle
that had none, which let it lay eggs anywhere. TurtleBreedGoal.breed adds
one ExperienceOrb of 1 to 7 drawn from the mother's random.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:50:47 +01:00
Ludovic MarieandClaude Opus 5.5 75f7029d01 Squid ink: draw the squirt's voice pitch even when silent
Mob.makeSound calls getVoicePitch before Entity.playSound checks
isSilent, so the squid's random advances either way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:48:37 +01:00
Ludovic MarieandClaude Opus 5.5 1d51f9d873 Soul Speed: draw the random chances in vanilla's order
Both random_chance terms come first in their all_of conditions, so the
level random is drawn before the block and movement checks: the boots'
4% on every location change, and the sound's 35% on every tick the
boots are worn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:48:30 +01:00
Ludovic Marie aed8b571c4 Merge remote-tracking branch 'origin/main' into feat/server-mods-world 2026-10-26 15:46:47 +01:00
Kacpherek bd59cdc25c Let turtles lay eggs that hatch into baby turtles
A bred turtle now carries an egg instead of the baby appearing at once.
She walks home, digs into sand for a while and lays a turtle egg there.
A hatching egg gives one baby turtle per egg with the egg as its home,
and a baby that grows up drops a scute.
2026-10-26 14:39:16 +01:00
Kacpherek fd0cb00296 Axolotls dry out on land, squid squirt ink when a living entity hurts them 2026-10-26 14:31:58 +01:00
Kacpherek 3e78846975 Soul Speed: speed, no soul sand slowdown, boots wear, soul particles
The enchantment's location_changed effects now run for the player: the
movement_speed and movement_efficiency modifiers turn on over soul sand and
soul soil and stay on while airborne, the boots wear with a 4% chance per
block change, and walking on those blocks spawns soul particles and the
soul escape sound.
2026-10-26 14:31:28 +01:00
Ludovic MarieandClaude Opus 5.5 0a116d9523 Split horizon-command and horizon-client-core out of horizon-client
A pure move, the first slice of splitting the client lib into crates:

- horizon-command: the command/ module (Brigadier port, vanilla tree, argument types, selectors), as is.
- horizon-client-core: what command needs from the lib, with no other dependency inside it: text, lang, snbt,
  nbt_path, component, scoreboard, bossbar, gamerules, block_entity_nbt, fastutil_order; plus items moved out of
  lib modules that sit in the cycle, unchanged: ParticleType (particles.rs), the key display name (keybinds.rs),
  SOUND_SOURCES (settings.rs), uuid_text/uuid_from_text (save.rs), Registries/RegistryIds and parse_identifier
  (command/args.rs).

horizon-client re-exports every module and item under its old path (`horizon_client::text`,
`crate::command::Registries`, `particles::ParticleType`...), so no caller changes. Inside the moved files only
paths changed (`crate::snbt` -> `horizon_client_core::snbt` in command, `crate::command::args::parse_identifier`
-> `crate::identifier::parse_identifier`...), `sign_side` became pub, lang.rs includes the lang files from
horizon-client's assets, and gamerules' test that saves a Level moved to save.rs (it needs the client's save).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:36:05 +01:00
Ludovic MarieandClaude Opus 5.5 ebf3c1567c parity: follow code into new crates when choosing harnesses
Two changes so `parity.py --changed` keeps picking the right harnesses once code moves into a crate of its own:

- A harness recorded before the move names the old paths. A changed Rust file of a crate the recorded crates
  link (their path dependencies, transitively) but don't name now counts, and the reach search decides.
  Before, such a file matched no record and selected nothing.
- A library's root opens once reached code names the crate (`horizon_world::`, `pub use horizon_command as
  command`), instead of always. Every crate root was open, so any item of a new crate whose name matched a
  name used anywhere counted. On main the selection is the same for 15 of 16 sampled files; for
  horizon-world/src/nbt.rs one harness that reached it only through a name match drops out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:34:31 +01:00
Ludovic Marie 94d8130193 Merge branch 'feat/server-mods-game' into feat/server-mods-world
# Conflicts:
#	Cargo.toml
#	crates/cargo-horizon-mod/src/check.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-mods/src/manifest.rs
#	crates/horizon-mods/src/runtime.rs
2026-10-26 10:04:52 +01:00
Ludovic MarieandClaude Opus 5.5 35a89eaebe Mod API 0.3: capability world for server mods (blocks, biomes)
Server mods get interface world: get-block(dimension, x, y, z) reads a
loaded chunk's block state as "minecraft:oak_log[axis=y]", get-biome its
biome ("minecraft:plains"), and
set-blocks(dimension, changes) sets up to 4096 blocks per call, checked at
once, made as /setblock makes them (place_state: neighbours, light, block
entities, every client told through the block log) after the call, before
that dimension's next tick.

Reads cost no copy: for a mod call the game lends Mods the levels' chunk
maps (moved out with mem::take and put back after), only while a running
mod has world. Writes for a level that isn't ticking (another dimension,
in-frame) wait for it, up to 65,536.

Test scripts get blocks (setup) and expect.blocks; cargo horizon-mod check
reports a trial run's block writes. Example beacons: /beacon builds a
glowstone pillar beside the player, /ground reads the block underfoot and the biome.
parse_state is now a free function so the mod path shares it.

Docs: docs/mods.md (the capability, script keys), docs/api/mods.md and the
regenerated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:55:29 +01:00
251 changed files with 11699 additions and 1476 deletions

No files matched your search

+1
View File
@@ -144,6 +144,7 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
# Horizon never downloads the game. These jobs need a self-hosted Linux runner
# (label horizon-game) holding the maintainer's own copy: the official launcher's
# game directory in HORIZON_MINECRAFT_DIR and the pinned server jar in
+2 -2
View File
@@ -25,8 +25,8 @@ jobs:
parity-main:
# A Mac mini runner of its own (ludovics-mac-mini-2 holds only this label since 2026-10-26): behind PR parity jobs
# this run waited 2 h while every merge replaced the pending one, and on the release runner a release then waited
# behind the next run. PR parity jobs have ludovics-mac-mini and the desktop. Not the desktop: a job slowed down
# because the maintainer came back could time out, and a failure here bisects and comments on merges.
# behind the next run. PR parity jobs have ludovics-mac-mini and playhorizon. Not playhorizon: its 4 CPUs
# make a core-crate run several times longer, and a failure here bisects and comments on merges.
runs-on: [self-hosted, macOS, horizon-parity-main]
timeout-minutes: 240 # a core-crate batch reruns ~300 harnesses (~60-90 min), a bisect a few rounds of the failures
steps:
+43 -62
View File
@@ -1,6 +1,6 @@
name: parity
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the maintainer's
# own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# The vanilla harnesses a pull request's changes can affect, on the Mac mini's runner or playhorizon's, which hold the
# maintainer's own Minecraft files (Horizon never downloads the game). Called by ci.yml for pull requests from this repository
# and by run-parity.yml when a maintainer labels a fork's pull request run-parity. See docs/release-flow.md.
on:
workflow_call:
@@ -13,6 +13,13 @@ on:
description: The commit to check out and test
type: string
required: true
full:
description: >-
The pull request carries the full-parity label (a change every harness reaches, such as a crate split): it
runs on playhorizon's runner only, so the Mac mini's runners stay free for the others. While that runner is
busy the job waits for it.
type: boolean
default: false
permissions:
contents: read
env:
@@ -21,40 +28,53 @@ env:
jobs:
parity:
runs-on: [self-hosted, horizon-game]
timeout-minutes: 120 # a change to core crates reruns ~300 harnesses: ~60-90 min on the Mac mini
runs-on: ${{ inputs.full && fromJSON('["self-hosted", "horizon-game", "playhorizon"]') || fromJSON('["self-hosted", "horizon-game"]') }}
# A change to core crates reruns ~350 harnesses: ~60-90 min on the Mac mini; on playhorizon (4 of its 8 vCPUs, 4
# harnesses at a time) up to a few hours, and any pull request's job may land there.
timeout-minutes: ${{ inputs.full && 360 || 180 }}
steps:
- uses: actions/checkout@v4
with:
# The commit the event names: a fork's later pushes need another run-parity label.
# The commit the event names: a fork's later pushes need another run-parity label. The next step merges
# main into it.
ref: ${{ inputs.head }}
fetch-depth: 0
persist-credentials: false
# Keeps .local/ (parity state, probe builds, oracle results) and target/ between runs.
clean: false
- name: The head merged into main (what merging it would land)
id: merge
env:
BASE_SHA: ${{ inputs.base }}
HEAD_SHA: ${{ inputs.head }}
run: |
# Harness fixes and game fixes that reached main since the branch was made then apply here too: a head
# tested alone failed harnesses already fixed on main (server_knockback before #2114, mob_ai's heap before
# #2270, save_roundtrip before #2212). Selection then compares with this main, so it sees only the pull
# request's own changes. A head that conflicts with main is tested alone.
set -euo pipefail
git merge --abort 2>/dev/null || true
git fetch -q --no-tags origin +refs/heads/main:refs/remotes/origin/main
main=$(git rev-parse origin/main)
if git -c user.name=parity -c user.email=parity@localhost merge -q --no-edit --no-ff "$main" >/dev/null 2>&1; then
echo "base=$main" >> "$GITHUB_OUTPUT"
echo "Tested: head ${HEAD_SHA:0:9} merged into main ${main:0:9}" | tee -a "$GITHUB_STEP_SUMMARY"
else
git merge --abort
echo "base=$BASE_SHA" >> "$GITHUB_OUTPUT"
echo "::warning::head ${HEAD_SHA:0:9} conflicts with main ${main:0:9}: tested alone, without main's fixes"
echo "Tested: head ${HEAD_SHA:0:9} alone (it conflicts with main ${main:0:9})" | tee -a "$GITHUB_STEP_SUMMARY"
fi
- uses: dtolnay/rust-toolchain@stable
- name: Tools from the runner
run: bash tools/ci/runner-env.sh
- name: Vanilla data, and resources prepared by this preparer
env:
BASE_SHA: ${{ inputs.base }}
run: |
# main's script: one written before a fix to it left a real .local/horizon-client without sounds.json on
# the runner, and every later job there failed `subtitle`. A branch that changes the script runs its own.
set -euo pipefail
if git rev-parse --verify -q origin/main >/dev/null \
&& git diff --quiet "$(git merge-base "$BASE_SHA" HEAD)" HEAD -- tools/ci/vanilla-data.sh; then
git show origin/main:tools/ci/vanilla-data.sh > "$RUNNER_TEMP/vanilla-data.sh"
bash "$RUNNER_TEMP/vanilla-data.sh"
else
bash tools/ci/vanilla-data.sh
fi
run: bash tools/ci/vanilla-data.sh
- name: The prepared output is the one the preparer's OUTPUT_VERSION says
env:
BASE_SHA: ${{ inputs.base }}
BASE_SHA: ${{ steps.merge.outputs.base }}
run: |
# The job runs main's workflow on the head's files: a head made before OUTPUT_VERSION (#1947) has neither
# the tool nor the number, so there is nothing to check.
# A head made before OUTPUT_VERSION (#1947) that conflicts with main has neither the tool nor the number.
if [ ! -f tools/prepare_output.py ] || ! grep -q OUTPUT_VERSION crates/horizon-prepare/src/lib.rs; then
echo "This head's preparer has no OUTPUT_VERSION: nothing to check"
exit 0
@@ -65,47 +85,8 @@ jobs:
- name: The harnesses this change can affect
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ inputs.base }}
run: |
# main's selection (tools/parity.py --changed --list with main's parity.py and parity_reach.py), so a
# branch made before a better selection landed gets it too; the head's own runner then runs those
# harnesses. A branch that changes the selection itself selects with its own code.
set -euo pipefail
own() { python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400; } # world_spawn takes ~1480 s on a busy runner
fork_point=$(git merge-base "$BASE_SHA" HEAD)
if ! git rev-parse --verify -q origin/main >/dev/null || ! git diff --quiet "$fork_point" HEAD -- tools/parity.py tools/parity_reach.py; then
own
exit
fi
had_reach=$(git ls-files tools/parity_reach.py)
git show origin/main:tools/parity.py > tools/parity.py
git show origin/main:tools/parity_reach.py > tools/parity_reach.py
status=0
python3 tools/parity.py --changed "$BASE_SHA" --list > .local/parity-selection.txt || status=$?
git checkout -- tools/parity.py
if [ -n "$had_reach" ]; then git checkout -- tools/parity_reach.py; else rm -f tools/parity_reach.py; fi
cat .local/parity-selection.txt
if [ "$status" != 0 ]; then
echo "main's selection failed: the head's own selection runs"
own
exit
fi
names=$(sed '$d' .local/parity-selection.txt | grep -E '^[A-Za-z0-9_#]+$' || true)
# The summary line says how many would run: a count that doesn't match the names read (a format change)
# must not let the job pass untested.
expected=$(tail -1 .local/parity-selection.txt | sed -nE 's/^([0-9]+) of [0-9]+ harnesses would run$/\1/p')
count=$(printf '%s' "$names" | grep -c . || true)
if [ -z "$expected" ] || [ "$expected" != "$count" ]; then
echo "main's selection listed $count names, its summary says '${expected:-?}': the head's own selection runs"
own
exit
fi
if [ "$count" = 0 ]; then
echo "Nothing to run."
exit 0
fi
# shellcheck disable=SC2086
python3 tools/parity.py $names --timeout 2400
BASE_SHA: ${{ steps.merge.outputs.base }}
run: python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400 # world_spawn takes ~1480 s on a busy runner
- name: Logs of the failed harnesses
if: failure()
uses: actions/upload-artifact@v4
+1
View File
@@ -22,3 +22,4 @@ jobs:
with:
base: ${{ github.event.pull_request.base.sha }}
head: ${{ github.event.pull_request.head.sha }}
full: ${{ contains(github.event.pull_request.labels.*.name, 'full-parity') }}
+6 -4
View File
@@ -34,7 +34,9 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-client` | The game: a lib (`src/lib.rs`, ~180 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
| `horizon-client-core` | The client lib's bottom layer: `text.rs`, `lang.rs`, `snbt.rs`, `nbt_path.rs`, `component.rs`, `scoreboard.rs`, `bossbar.rs`, `gamerules.rs`, `block_entity_nbt.rs`, registry/particle/sound tables. Re-exported under the old paths (`horizon_client::text`...) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
@@ -118,16 +120,16 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| Commands | parse: crate `horizon-command` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`; `horizon-client-core`: `nbt_path.rs`, `scoreboard.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
Generated
+33
View File
@@ -216,6 +216,13 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "beacons"
version = "0.1.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -1365,6 +1372,8 @@ dependencies = [
"glam",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
"horizon-command",
"horizon-core",
"horizon-dualsense",
"horizon-friends",
@@ -1396,6 +1405,23 @@ dependencies = [
"zip",
]
[[package]]
name = "horizon-client-core"
version = "0.1.0"
dependencies = [
"horizon-world",
"serde_json",
]
[[package]]
name = "horizon-command"
version = "0.1.0"
dependencies = [
"horizon-client-core",
"horizon-protocol",
"horizon-world",
]
[[package]]
name = "horizon-core"
version = "0.1.0"
@@ -2971,6 +2997,13 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "recipes"
version = "1.0.0"
dependencies = [
"horizon-mod-sdk",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
+40 -1
View File
@@ -1,6 +1,45 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-hdr", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/horizon-mods/tests/ui_guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "crates/horizon-mod-sdk/examples/predator-missile", "crates/horizon-mod-sdk/examples/welcome", "mods/official/fps-overlay", "mods/official/keystrokes", "mods/official/minimap", "mods/official/zoom", "xtask"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
"crates/horizon-threads",
"crates/horizon-hdr",
"crates/horizon-world",
"crates/horizon-client",
"crates/horizon-client-core",
"crates/horizon-command",
"crates/horizon-server-files",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
"crates/horizon-protocol",
"crates/horizon-theme",
"crates/horizon-prepare",
"crates/horizon-telemetry",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
"crates/horizon-mod-sdk",
"crates/horizon-mods",
"crates/horizon-mods/tests/guest",
"crates/horizon-mods/tests/ui_guest",
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"crates/horizon-mod-sdk/examples/coords-hud",
"crates/horizon-mod-sdk/examples/predator-missile",
"crates/horizon-mod-sdk/examples/welcome",
"crates/horizon-mod-sdk/examples/beacons",
"mods/official/fps-overlay",
"mods/official/keystrokes",
"mods/official/minimap",
"mods/official/zoom",
"xtask",
"mods/official/recipes",
]
resolver = "2"
[workspace.package]
+9
View File
@@ -266,8 +266,17 @@ fn play(path: &Path, report: &mut Report) -> Result<(), String> {
format!("{} quiet command(s)", lines.len())
}
Output::Stopped { reason, .. } => return Err(format!("it stopped: {reason}")),
Output::SetBlocks {
dimension, blocks, ..
} => {
format!("set {} block(s) in {dimension}", blocks.len())
}
Output::OpenScreen { .. } => "opened a screen".to_string(),
Output::CloseScreen { .. } => "closed its screen".to_string(),
Output::PlaceRecipe { book_id, all } => {
format!("placed recipe {book_id}{}", if all { " (all)" } else { "" })
}
Output::Give { item, count, .. } => format!("gave {count} {item}"),
Output::Shake { strength, seconds } => {
format!("shook the camera: {strength:.2} for {seconds:.1} s")
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-client-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.90"
description = "The client's lowest layer: chat text and translations, SNBT and NBT paths, the scoreboard, boss bars, game rules and the registry and particle tables commands name"
[dependencies]
horizon-world = { path = "../horizon-world" }
serde_json = { version = "1.0.151", features = ["raw_value"] }
[lints]
workspace = true
@@ -259,7 +259,7 @@ fn is_pos(tag: &Tag) -> bool {
matches!(tag, Tag::IntArray(v) if v.len() == 3)
}
/// The loaded `SignText` of one side, or the empty text.
pub(crate) fn sign_side(tag: Option<&Tag>) -> Tag {
pub fn sign_side(tag: Option<&Tag>) -> Tag {
let empty = || string("");
let mut messages: Vec<Tag> = match tag.and_then(|t| t.get("messages")) {
Some(Tag::List(list)) => list.iter().filter_map(component).collect(),
@@ -268,7 +268,7 @@ pub fn to_text_with(
} else if let Some(sel) = o.get("selector").and_then(Value::as_str) {
resolve(sel)
} else if let Some(key) = o.get("keybind").and_then(Value::as_str) {
crate::keybinds::display_name(key)
crate::key_display::display_name(key)
} else {
Text::empty()
};
@@ -292,27 +292,4 @@ mod tests {
assert_eq!(GameRules::load(&dir), rules);
let _ = fs::remove_dir_all(dir);
}
/// The world's save (Level.write) writes the rules as they are now, and a
/// world opened again reads them back (#74).
#[test]
fn level_save_keeps_changed_rules() {
let dir =
std::env::temp_dir().join(format!("horizon-gamerules-level-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut level = crate::save::Level::default();
level.write(&dir).unwrap();
// Rules changed while playing, then saved and loaded again.
let mut rules = GameRules::load(&dir);
assert_eq!(rules, GameRules::default());
assert!(rules.set("advance_time", Value::Bool(false)));
assert!(rules.set("advance_weather", Value::Bool(false)));
assert!(rules.set("random_tick_speed", Value::Int(0)));
level.game_rules = rules.by_name();
level.write(&dir).unwrap();
assert_eq!(GameRules::load(&dir), rules);
let read = crate::save::Level::read(&dir).unwrap();
assert_eq!(read.game_rules, rules.by_name());
assert_eq!(read.game_rule("advance_time"), 0);
let _ = fs::remove_dir_all(dir);
}
}
@@ -0,0 +1,18 @@
//! Resource identifiers (`Identifier`).
/// Identifier.parse: `ns:path` or `path`, validated.
pub fn parse_identifier(raw: &str) -> Option<String> {
let (ns, path) = match raw.split_once(':') {
Some(("", path)) => ("minecraft", path),
Some((ns, path)) => (ns, path),
None => ("minecraft", raw),
};
let ns_ok = ns != ".."
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
@@ -0,0 +1,38 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
@@ -42,8 +42,12 @@ struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let body = match path {
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
+23
View File
@@ -0,0 +1,23 @@
//! The client's lowest layer, below commands and gameplay: chat text and
//! translations, SNBT and NBT paths, text components, the scoreboard, boss
//! bars, game rules, block entity NBT tables, and the registry, particle and
//! sound source tables commands name. horizon-client re-exports each module
//! under its old path (`horizon_client::text`, ...).
#![forbid(unsafe_code)]
pub mod block_entity_nbt;
pub mod bossbar;
pub mod component;
pub mod fastutil_order;
pub mod gamerules;
pub mod identifier;
pub mod key_display;
pub mod lang;
pub mod nbt_path;
pub mod particle_type;
pub mod registries;
pub mod scoreboard;
pub mod snbt;
pub mod sound_source;
pub mod text;
pub mod uuid;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
@@ -0,0 +1,209 @@
//! Particle types (`ParticleTypes`): the vanilla particle types with a client
//! implementation, by registry name, and their per-type flags.
macro_rules! particle_types {
($($variant:ident = $name:literal),* $(,)?) => {
/// Vanilla particle types with a client implementation here.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ParticleType { $($variant),* }
impl ParticleType {
pub const ALL: &'static [ParticleType] = &[$(ParticleType::$variant),*];
/// Registry path (`minecraft:<name>`), also the sprite-set file name.
pub fn name(self) -> &'static str {
match self { $(ParticleType::$variant => $name),* }
}
pub fn from_name(name: &str) -> Option<Self> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
match name { $($name => Some(ParticleType::$variant),)* _ => None }
}
}
};
}
particle_types! {
Block = "block",
BlockCrumble = "block_crumble",
Trail = "trail",
DustPillar = "dust_pillar",
FallingDust = "falling_dust",
Smoke = "smoke",
LargeSmoke = "large_smoke",
WhiteSmoke = "white_smoke",
Flame = "flame",
SoulFireFlame = "soul_fire_flame",
CopperFireFlame = "copper_fire_flame",
SmallFlame = "small_flame",
Lava = "lava",
DrippingWater = "dripping_water",
FallingWater = "falling_water",
DrippingLava = "dripping_lava",
FallingLava = "falling_lava",
LandingLava = "landing_lava",
DrippingHoney = "dripping_honey",
FallingHoney = "falling_honey",
LandingHoney = "landing_honey",
FallingNectar = "falling_nectar",
FallingSporeBlossom = "falling_spore_blossom",
SporeBlossomAir = "spore_blossom_air",
DrippingObsidianTear = "dripping_obsidian_tear",
FallingObsidianTear = "falling_obsidian_tear",
LandingObsidianTear = "landing_obsidian_tear",
DrippingDripstoneWater = "dripping_dripstone_water",
FallingDripstoneWater = "falling_dripstone_water",
DrippingDripstoneLava = "dripping_dripstone_lava",
FallingDripstoneLava = "falling_dripstone_lava",
Splash = "splash",
Rain = "rain",
Bubble = "bubble",
SulfurBubbles = "sulfur_bubbles",
BubblePop = "bubble_pop",
BubbleColumnUp = "bubble_column_up",
CurrentDown = "current_down",
Underwater = "underwater",
Portal = "portal",
ReversePortal = "reverse_portal",
Enchant = "enchant",
Nautilus = "nautilus",
VaultConnection = "vault_connection",
Crit = "crit",
EnchantedHit = "enchanted_hit",
DamageIndicator = "damage_indicator",
Heart = "heart",
AngryVillager = "angry_villager",
HappyVillager = "happy_villager",
Composter = "composter",
Mycelium = "mycelium",
Dolphin = "dolphin",
EggCrack = "egg_crack",
Poof = "poof",
Explosion = "explosion",
ExplosionEmitter = "explosion_emitter",
CampfireCosySmoke = "campfire_cosy_smoke",
CampfireSignalSmoke = "campfire_signal_smoke",
Note = "note",
Dust = "dust",
TotemOfUndying = "totem_of_undying",
Firework = "firework",
EndRod = "end_rod",
Ash = "ash",
WhiteAsh = "white_ash",
CrimsonSpore = "crimson_spore",
WarpedSpore = "warped_spore",
CherryLeaves = "cherry_leaves",
PaleOakLeaves = "pale_oak_leaves",
TintedLeaves = "tinted_leaves",
Effect = "effect",
InstantEffect = "instant_effect",
EntityEffect = "entity_effect",
Witch = "witch",
Infested = "infested",
Cloud = "cloud",
Sneeze = "sneeze",
Snowflake = "snowflake",
Soul = "soul",
SculkSoul = "sculk_soul",
DragonBreath = "dragon_breath",
Gust = "gust",
SmallGust = "small_gust",
GustEmitterLarge = "gust_emitter_large",
GustEmitterSmall = "gust_emitter_small",
DustColorTransition = "dust_color_transition",
SculkCharge = "sculk_charge",
SculkChargePop = "sculk_charge_pop",
Shriek = "shriek",
SonicBoom = "sonic_boom",
Vibration = "vibration",
SweepAttack = "sweep_attack",
Glow = "glow",
WaxOn = "wax_on",
WaxOff = "wax_off",
ElectricSpark = "electric_spark",
Scrape = "scrape",
SquidInk = "squid_ink",
GlowSquidInk = "glow_squid_ink",
Item = "item",
ItemSlime = "item_slime",
ItemSnowball = "item_snowball",
ItemCobweb = "item_cobweb",
SulfurCubeGoo = "sulfur_cube_goo",
Flash = "flash",
Firefly = "firefly",
TrialSpawnerDetection = "trial_spawner_detection",
TrialSpawnerDetectionOminous = "trial_spawner_detection_ominous",
TrialOmen = "trial_omen",
RaidOmen = "raid_omen",
OminousSpawning = "ominous_spawning",
Spit = "spit",
Fishing = "fishing",
DustPlume = "dust_plume",
RedPoplarLeaves = "red_poplar_leaves",
OrangePoplarLeaves = "orange_poplar_leaves",
YellowPoplarLeaves = "yellow_poplar_leaves",
PauseMobGrowth = "pause_mob_growth",
ResetMobGrowth = "reset_mob_growth",
BlockMarker = "block_marker",
}
impl ParticleType {
/// `ParticleType.getOverrideLimiter` (`ParticleTypes.register(name, true)`):
/// spawned regardless of distance.
pub fn override_limiter(self) -> bool {
matches!(
self,
Self::BlockMarker
| Self::DamageIndicator
| Self::ExplosionEmitter
| Self::Explosion
| Self::Gust
| Self::GustEmitterLarge
| Self::GustEmitterSmall
| Self::SonicBoom
| Self::SculkCharge
| Self::SculkChargePop
| Self::Vibration
| Self::Poof
| Self::Spit
| Self::SquidInk
| Self::SweepAttack
| Self::CampfireCosySmoke
| Self::CampfireSignalSmoke
| Self::GlowSquidInk
| Self::Glow
| Self::WaxOn
| Self::WaxOff
| Self::ElectricSpark
| Self::Scrape
| Self::TrialSpawnerDetection
| Self::TrialSpawnerDetectionOminous
| Self::VaultConnection
| Self::OminousSpawning
)
}
/// `SingleQuadParticle.Layer.translucent`: types whose vanilla particle class
/// hardcodes the translucent layer, drawn after translucent terrain.
/// `TerrainParticle`, `BreakingItemParticle` and `BlockMarker` resolve their
/// layer from the sprite instead (`Layer.bySprite`); see the `translucent`
/// sprite set on the engine.
pub fn translucent_layer(self) -> bool {
match self {
// CampfireSmokeParticle.
Self::CampfireCosySmoke | Self::CampfireSignalSmoke
// PlayerCloudParticle.
| Self::Cloud | Self::Sneeze
// SpellParticle.
| Self::Effect | Self::InstantEffect | Self::EntityEffect
| Self::Witch | Self::Infested | Self::TrialOmen | Self::RaidOmen
// EmissiveRisingParticle.
| Self::Soul | Self::SculkSoul
// SculkChargeParticle, SculkChargePopParticle, ShriekParticle,
// VibrationSignalParticle, FireflyParticle, OverlayParticle.
| Self::SculkCharge | Self::SculkChargePop | Self::Shriek
| Self::Vibration | Self::Firefly | Self::Flash
// SimpleAnimatedParticle and its subclasses: EndRodParticle,
// SparkParticle (firework), SquidInkParticle, TotemParticle.
| Self::EndRod | Self::Firework | Self::SquidInk | Self::GlowSquidInk
| Self::TotemOfUndying
// FlyTowardsPositionParticle: its LifetimeAlpha(0, 0.6, 0.25, 1) is not opaque.
| Self::VaultConnection => true,
_ => false,
}
}
}
@@ -0,0 +1,188 @@
//! Registry contents and source state that commands validate and complete
//! arguments against (what the server's registry access holds).
use std::collections::BTreeMap;
/// Registry contents and source state used to validate and complete arguments.
#[derive(Clone, Debug, Default)]
pub struct Registries {
/// Permission level of the command source.
pub permission: u8,
pub items: Vec<String>,
pub item_tags: Vec<String>,
/// Block id -> (property, allowed values).
pub blocks: BTreeMap<String, Vec<(String, Vec<String>)>>,
pub block_tags: Vec<String>,
/// Entity types that `summon` can create.
pub summonable: Vec<String>,
/// Every entity type (selector `type=`).
pub entity_types: Vec<String>,
pub effects: Vec<String>,
pub enchantments: Vec<String>,
pub structures: Vec<String>,
pub biomes: Vec<String>,
pub dimensions: Vec<String>,
pub clocks: Vec<String>,
pub timelines: Vec<String>,
pub time_markers: Vec<String>,
pub advancements: Vec<String>,
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
pub tracked: Vec<String>,
/// Loaded datapack functions (`ns:path`) and function tags (`ns:path`).
pub functions: Vec<String>,
/// `/data ... storage` ids.
pub storages: Vec<String>,
/// Boss bar ids and datapack predicate ids.
pub bossbars: Vec<String>,
pub predicates: Vec<String>,
pub item_modifiers: Vec<String>,
pub schedules: Vec<String>,
pub stopwatches: Vec<String>,
pub recipes: Vec<String>,
pub sounds: Vec<String>,
pub damage_types: Vec<String>,
pub attributes: Vec<String>,
pub loot_tables: Vec<String>,
pub templates: Vec<String>,
pub random_sequences: Vec<String>,
pub datapacks_enabled: Vec<String>,
pub datapacks_disabled: Vec<String>,
pub function_tags: Vec<String>,
/// The block the source is looking at (relevant coordinates).
pub target_block: Option<[i32; 3]>,
/// Every registry's element and tag ids (`minecraft:item` -> ...), as
/// the server's registry access and reloadable registries hold them.
pub by_registry: BTreeMap<String, RegistryIds>,
/// Blocks whose default state has a block entity.
pub block_entities: std::collections::BTreeSet<String>,
/// Block tag -> member blocks.
pub block_tag_members: BTreeMap<String, Vec<String>>,
/// Data component types with a codec (not transient).
pub persistent_components: Vec<String>,
/// Recipes `/recipe` can give (not special).
pub learnable_recipes: Vec<String>,
/// Advancement id -> criterion names.
pub advancement_criteria: BTreeMap<String, Vec<String>>,
/// World clock -> time markers shown in commands.
pub clock_time_markers: BTreeMap<String, Vec<String>>,
/// Trigger objectives the source may use (`/trigger` suggestions).
pub trigger_objectives: Vec<String>,
/// Objectives with the `trigger` criterion -> holder -> whether its score is locked.
pub trigger_scores: BTreeMap<String, BTreeMap<String, bool>>,
}
/// The ids and tag ids of one registry.
#[derive(Clone, Debug, Default)]
pub struct RegistryIds {
pub ids: Vec<String>,
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
impl Registries {
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
/// mob effects, data component types and dialogs, block entities and tag
/// members. Registries that data packs reload stay with the game.
pub fn fill_from_export(&mut self, r: &serde_json::Value) {
fn strings(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_owned))
.collect()
})
.unwrap_or_default()
}
let fill = |field: &mut Vec<String>, key: &str| {
if field.is_empty() {
*field = strings(&r[key]);
}
};
fill(&mut self.effects, "effects");
fill(&mut self.enchantments, "enchantments");
fill(&mut self.attributes, "attributes");
fill(&mut self.damage_types, "damage_types");
fill(&mut self.templates, "templates");
fill(&mut self.persistent_components, "persistent_components");
fill(&mut self.learnable_recipes, "learnable_recipes");
const RELOADED: [&str; 5] = [
"minecraft:advancement",
"minecraft:recipe",
"minecraft:loot_table",
"minecraft:predicate",
"minecraft:item_modifier",
];
for (name, entry) in r["by_registry"].as_object().into_iter().flatten() {
if RELOADED.contains(&name.as_str()) || self.by_registry.contains_key(name) {
continue;
}
self.by_registry.insert(
name.clone(),
RegistryIds {
ids: strings(&entry["ids"]),
tags: strings(&entry["tags"]),
},
);
}
if self.block_entities.is_empty() {
self.block_entities = strings(&r["block_entities"]).into_iter().collect();
}
if self.block_tag_members.is_empty() {
for (tag, members) in r["block_tag_members"].as_object().into_iter().flatten() {
self.block_tag_members.insert(tag.clone(), strings(members));
}
}
if self.clock_time_markers.is_empty() {
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
self.clock_time_markers
.insert(clock.clone(), strings(markers));
}
}
if self.advancement_criteria.is_empty() {
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
self.advancement_criteria
.insert(id.clone(), strings(criteria));
}
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.ids;
}
match registry {
"minecraft:item" => &self.items,
"minecraft:entity_type" => &self.entity_types,
"minecraft:mob_effect" => &self.effects,
"minecraft:enchantment" => &self.enchantments,
"minecraft:worldgen/structure" => &self.structures,
"minecraft:worldgen/biome" => &self.biomes,
"minecraft:world_clock" => &self.clocks,
"minecraft:timeline" => &self.timelines,
"minecraft:attribute" => &self.attributes,
"minecraft:damage_type" => &self.damage_types,
"minecraft:sound_event" => &self.sounds,
"minecraft:predicate" => &self.predicates,
"minecraft:item_modifier" => &self.item_modifiers,
"minecraft:loot_table" => &self.loot_tables,
"minecraft:recipe" => &self.recipes,
"minecraft:advancement" => &self.advancements,
_ => EMPTY,
}
}
/// The tag ids of a registry (sorted).
pub fn tags(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
return &r.tags;
}
match registry {
"minecraft:item" => &self.item_tags,
"minecraft:block" => &self.block_tags,
_ => EMPTY,
}
}
}
@@ -2,9 +2,9 @@
//! `ObjectiveCriteria`, `NumberFormat`) and its saved data
//! (`data/minecraft/scoreboard.dat`, `ScoreboardSaveData.Packed`).
use crate::{
command::Registries,
component,
fastutil_order::{HashSetOrder, OpenOrder},
registries::Registries,
text::{self, Style, Text},
};
use horizon_world::nbt::Tag;
@@ -1266,7 +1266,7 @@ fn entity_uuids(tag: &mut Tag) {
})
.collect::<Option<Vec<i32>>>(),
Tag::String(s) => {
crate::save::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
crate::uuid::uuid_from_text(&s.to_string_lossy()).map(Vec::from)
}
_ => None,
};
File renamed without changes.
@@ -0,0 +1,7 @@
//! Sound sources (`SoundSource`), by name.
/// SoundSource names in vanilla order.
pub const SOUND_SOURCES: [&str; 11] = [
"master", "music", "record", "weather", "block", "hostile", "neutral", "player", "ambient",
"voice", "ui",
];
@@ -71,7 +71,7 @@ pub struct Style {
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
crate::command::args::parse_identifier(v.as_str()?)
crate::identifier::parse_identifier(v.as_str()?)
}
impl Style {
pub fn color(c: u32) -> Self {
+24
View File
@@ -0,0 +1,24 @@
//! UUIDs as vanilla's four-int form, to and from their text form.
pub fn uuid_text(u: [i32; 4]) -> String {
let hex: String = u.iter().map(|v| format!("{:08x}", *v as u32)).collect();
format!(
"{}-{}-{}-{}-{}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
)
}
pub fn uuid_from_text(s: &str) -> Option<[i32; 4]> {
let hex: String = s.chars().filter(|c| *c != '-').collect();
if hex.len() != 32 {
return None;
}
let mut out = [0i32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = u32::from_str_radix(&hex[i * 8..i * 8 + 8], 16).ok()? as i32;
}
Some(out)
}
+2
View File
@@ -9,6 +9,8 @@ description = "Horizon native development client"
[dependencies]
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
horizon-command = { path = "../horizon-command" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
@@ -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()
@@ -592,6 +592,7 @@ pub fn main() -> horizon_client::Result<()> {
pose,
num("maxCrossbowChageDuration"),
num("ticksUsingItem"),
false,
);
}
Species::Evoker | Species::Illusioner | Species::Pillager | Species::Vindicator => {
+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.
+24 -1
View File
@@ -1,7 +1,7 @@
//! Biome colours from the pinned biome definitions and colormaps
//! (`Biome.getGrassColor`/`getFoliageColor`/..., `BlockTintSources`).
use crate::Result;
use horizon_core::{noise::SimplexNoise, random::LegacyRandom};
use horizon_core::{noise::SimplexNoise, position::BlockPos, random::LegacyRandom};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -33,6 +33,8 @@ pub struct BiomeInfo {
pub increased_fire_burnout: bool,
pub name: String,
pub temperature: f32,
/// `temperature_modifier: frozen` (the frozen oceans).
pub frozen: bool,
pub downfall: f32,
pub precipitation: bool,
pub grass: u32,
@@ -45,6 +47,26 @@ pub struct BiomeInfo {
pub fog: Option<u32>,
pub water_fog: u32,
}
impl BiomeInfo {
/// `Biome.getHeightAdjustedTemperature`: the frozen modifier, then cooler
/// with height above `sea_level + 17`.
pub fn temperature_at(&self, pos: BlockPos, sea_level: i32) -> f32 {
use horizon_world::worldgen::biome_temperature::{
BiomeTemperatureSettings, TemperatureModifier,
};
BiomeTemperatureSettings {
base_temperature: self.temperature,
modifier: if self.frozen {
TemperatureModifier::Frozen
} else {
TemperatureModifier::None
},
has_precipitation: self.precipitation,
downfall: self.downfall,
}
.height_adjusted(pos, sea_level)
}
}
pub struct Biomes {
pub list: Vec<BiomeInfo>,
pub index: HashMap<String, u16>,
@@ -135,6 +157,7 @@ impl Biomes {
.as_bool()
.unwrap_or(false),
temperature,
frozen: v["temperature_modifier"].as_str() == Some("frozen"),
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
grass: color(&effects["grass_color"])
+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| {
+166 -6
View File
@@ -180,7 +180,6 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("Silent", m.silent),
("NoGravity", m.no_gravity),
("Glowing", m.glowing),
("Invulnerable", m.invulnerable_flag),
] {
if on {
map.insert(key.into(), Tag::Byte(1));
@@ -188,6 +187,26 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
}
map.insert(
"Invulnerable".into(),
Tag::Byte(i8::from(m.invulnerable_flag)),
);
map.insert(
"LeftHanded".into(),
Tag::Byte(i8::from(m.extra.left_handed)),
);
// Keys of older formats that structure templates still carry.
for key in [
"HandItems",
"ArmorItems",
"HandDropChances",
"ArmorDropChances",
"Dimension",
"HurtByTimestamp",
"Leashed",
] {
map.remove(&horizon_world::nbt::JavaString::from(key));
}
if m.ticks_frozen > 0 {
map.insert("TicksFrozen".into(), Tag::Int(m.ticks_frozen));
} else {
@@ -197,7 +216,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("LeftHanded", Tag::Byte(0)),
("current_impulse_context_reset_grace_time", Tag::Int(0)),
(
"Brain",
Tag::compound([("memories", Tag::Compound(Compound::new()))]),
@@ -209,7 +228,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
// Only breedable animals have an age; a loaded creature that was saved without
// one (a frog) stays without.
let aged = raw.is_none_or(|r| r.get("Age").is_some());
if m.kind.category == "creature" && aged {
if (m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin) && aged {
map.insert("Age".into(), Tag::Int(m.extra.age));
map.insert("AgeLocked".into(), Tag::Byte(i8::from(m.extra.age_locked)));
map.insert("ForcedAge".into(), Tag::Int(m.extra.forced_age));
@@ -260,6 +279,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.or_insert_with(|| string("minecraft:classic"));
}
"minecraft:piglin" | "minecraft:piglin_brute" | "minecraft:hoglin" => {
// Only Piglin.addAdditionalSaveData writes CannotHunt.
if id == "minecraft:piglin_brute" {
map.remove(&"CannotHunt".into());
}
map.insert(
"IsImmuneToZombification".into(),
Tag::Byte(i8::from(m.extra.flag2)),
@@ -370,6 +393,12 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:bat" => {
map.insert("BatFlags".into(), Tag::Byte(i8::from(m.extra.flag)));
}
"minecraft:turtle" => {
map.insert("has_egg".into(), Tag::Byte(i8::from(m.extra.flag)));
if let Some(home) = m.extra.home {
map.insert("home_pos".into(), Tag::IntArray(home.to_vec()));
}
}
"minecraft:creaking" => {
// home_pos: the heart it is bound to.
if let Some(home) = m.extra.home {
@@ -492,8 +521,7 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
)])
});
}
"minecraft:zombie" => {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
"minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
for (key, value) in [
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -526,6 +554,21 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
Tag::Byte(i8::from(m.extra.can_break_doors)),
);
}
if matches!(
id,
"minecraft:zombie"
| "minecraft:husk"
| "minecraft:drowned"
| "minecraft:zombie_villager"
| "minecraft:zombified_piglin"
| "minecraft:piglin"
| "minecraft:zoglin"
) {
map.insert("IsBaby".into(), Tag::Byte(i8::from(m.is_baby())));
}
if let Some(a) = m.extra.attributes.as_deref() {
put_attributes(map, m, a);
}
crate::mobs::raider::write_nbt(m, map);
crate::mobs::riders::write_nbt(m, map);
// LivingEntity equipment: what the mob holds now.
@@ -608,6 +651,89 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
tag
}
/// `AttributeMap.pack` for the attributes with a base or permanent modifier
/// finalizeSpawn set, over the saved list.
fn put_attributes(map: &mut Compound, m: &Mob, a: &crate::mobs::finalize::Attributes) {
use crate::mobs::finalize::{self, Attr, Op};
let mut list = match map.get(&"attributes".into()) {
Some(Tag::List(l)) => l.clone(),
_ => Vec::new(),
};
for attr in Attr::ALL {
let modifiers: Vec<Tag> = a
.modifiers
.iter()
.filter(|x| x.attr == attr)
.map(|x| {
let op = match x.op {
Op::AddValue => "add_value",
Op::AddMultipliedBase => "add_multiplied_base",
Op::AddMultipliedTotal => "add_multiplied_total",
};
Tag::compound([
("id", string(x.id)),
("amount", Tag::Double(x.amount.to_bits())),
("operation", string(op)),
])
})
.collect();
if modifiers.is_empty() && !a.bases.iter().any(|(b, _)| *b == attr) {
continue;
}
let id = format!("minecraft:{}", attr.name());
let mut entry = vec![
("id", string(&id)),
("base", Tag::Double(finalize::base_value(m, attr).to_bits())),
];
if !modifiers.is_empty() {
entry.push(("modifiers", Tag::List(modifiers)));
}
let entry = Tag::compound(entry);
match list
.iter_mut()
.find(|t| text(t.get("id")).as_deref() == Some(id.as_str()))
{
Some(t) => *t = entry,
None => list.push(entry),
}
}
map.insert("attributes".into(), Tag::List(list));
}
/// `Brain.Packed`: the memories finalizeSpawn set, over the saved ones.
pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
if m.extra.memories.is_empty() {
return;
}
let mut memories = match map.get(&"Brain".into()).and_then(|b| b.get("memories")) {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
("pos", Tag::IntArray(p.to_vec())),
]),
None => continue,
},
_ => Tag::Int(value as i32),
};
let mut memory = vec![("value", value)];
if ttl != i64::MAX {
memory.push(("ttl", Tag::Long(ttl)));
}
memories.insert(
format!("minecraft:{name}").as_str().into(),
Tag::compound(memory),
);
}
map.insert(
"Brain".into(),
Tag::compound([("memories", Tag::Compound(memories))]),
);
}
/// `CustomName`: a text component saved as a string or a `{text: ..}` compound.
fn custom_name(tag: &Tag) -> Option<String> {
match tag.get("CustomName")? {
@@ -627,6 +753,22 @@ pub fn name_text(tag: &Tag) -> Text {
t => crate::scoreboard::comp_text(&crate::scoreboard::tag_to_json(t)),
}
}
/// Mob.finalizeSpawn's follow range bonus in the saved attributes.
pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
let Some(Tag::List(attrs)) = tag.get("attributes") else {
return None;
};
let range = attrs
.iter()
.find(|a| text(a.get("id")).as_deref() == Some("minecraft:follow_range"))?;
let Some(Tag::List(modifiers)) = range.get("modifiers") else {
return None;
};
let bonus = modifiers
.iter()
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
@@ -652,6 +794,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
}
if let Some(bonus) = spawn_bonus(tag) {
use crate::mobs::finalize::{Attr, Op, add_modifier};
let id = "minecraft:random_spawn_bonus";
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.hurt_time = number(tag.get("HurtTime")).unwrap_or(0) as i32;
// What it holds (equipment.mainhand / offhand), when saved.
@@ -693,7 +840,8 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.pick_up_loot = flag(tag, "CanPickUpLoot");
m.custom_name = custom_name(tag);
m.custom_name_text = tag.get("CustomName").map(name_text);
if m.kind.category == "creature" {
m.extra.left_handed = flag(tag, "LeftHanded");
if m.kind.category == "creature" || m.species() == crate::mobs::Species::Hoglin {
m.extra.age = number(tag.get("Age")).unwrap_or(0) as i32;
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
@@ -876,6 +1024,18 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
{
crate::mobs::creaking::set_transient(&mut m, [home[0], home[1], home[2]]);
}
if m.species() == crate::mobs::Species::Turtle {
// Turtle.readAdditionalSaveData: `home_pos` (else where it stands) and `has_egg`.
m.extra.flag = flag(tag, "has_egg");
m.extra.home = Some(match tag.get("home_pos") {
Some(Tag::IntArray(home)) if home.len() == 3 => [home[0], home[1], home[2]],
_ => [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
});
}
if m.species() == crate::mobs::Species::Wither {
m.extra.counter = number(tag.get("Invul")).unwrap_or(0) as i32;
}
@@ -35,6 +35,18 @@ impl Damage {
..Self::default()
}
}
/// A block that starts to fall: anvils hurt, and a brushable block
/// breaks where it lands (BrushableBlock.tick: disableDrop).
pub fn falling(block: &str) -> Self {
if block.ends_with("anvil") {
Self::anvil()
} else {
Self {
cancel_drop: block.starts_with("minecraft:suspicious_"),
..Self::default()
}
}
}
pub fn from_nbt(tag: &Tag, anvil: bool) -> Self {
Self {
hurt_entities: number(tag.get("HurtEntities")).map_or(anvil, |v| v != 0),
@@ -181,6 +193,14 @@ pub fn hurt_mobs(mobs: &mut [Mob], position: Vec3, amount: f32) {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_brushable_blocks_break_where_they_land() {
assert!(Damage::falling("minecraft:suspicious_gravel").cancel_drop);
assert!(Damage::falling("minecraft:suspicious_sand").cancel_drop);
assert!(!Damage::falling("minecraft:gravel").cancel_drop);
assert!(Damage::falling("minecraft:chipped_anvil").hurt_entities);
}
#[test]
fn impact_uses_ceil_distance_capped_damage_and_positive_damage_wear() {
let damage = Damage::anvil();
+302 -39
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod bundles;
mod campfire;
mod campfire_douse;
mod cartography;
@@ -102,7 +103,12 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mod_browser;
#[cfg(feature = "mods")]
mod mod_browser_recipes;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_recipes;
mod mod_render;
mod mods;
mod mount_menu;
@@ -370,7 +376,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -425,6 +431,16 @@ impl Options {
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
// world that exists opens; else the title screen. --world also creates one.
"--quickPlaySingleplayer" => {
let folder = std::env::current_dir()?.join("saves").join(&value);
if folder.join("level.dat").is_file() {
o.world = Some(value);
} else {
eprintln!("Quick play: there is no world {value} in saves/");
}
}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
@@ -435,7 +451,7 @@ impl Options {
}
o.benchmark = Some(value);
}
"--server" => o.server = Some(value),
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
"--name" => o.player_name = Some(value),
"--session-file" => session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
@@ -1262,6 +1278,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
@@ -1429,6 +1447,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>,
@@ -1660,7 +1682,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)
@@ -1808,9 +1830,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
@@ -2105,6 +2164,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;
@@ -2322,11 +2408,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.
@@ -2931,6 +3024,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,
@@ -2966,6 +3070,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 {
@@ -2975,7 +3081,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 => {
@@ -2985,7 +3091,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);
@@ -3034,11 +3140,9 @@ impl Game<'_> {
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage: if self.res.facts().get(state).name.ends_with("anvil") {
horizon_client::falling_block::Damage::anvil()
} else {
Default::default()
},
damage: horizon_client::falling_block::Damage::falling(
&self.res.facts().get(state).name,
),
saved: None,
});
}
@@ -3115,6 +3219,11 @@ impl Game<'_> {
kind: "hatch_sniffer",
..
} => self.hatch_sniffer([pos.x, pos.y, pos.z]),
LevelEvent::BlockEvent {
pos,
state,
kind: "hatch_turtles",
} => self.hatch_turtles([pos.x, pos.y, pos.z], state),
LevelEvent::BlockEvent {
pos,
kind: "portal_spawn",
@@ -3136,6 +3245,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 { .. } => {}
@@ -3511,7 +3645,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,
@@ -4303,6 +4437,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
@@ -4406,7 +4546,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 {
@@ -4512,6 +4656,22 @@ impl Game<'_> {
};
self.eye += (camera_eye.unwrap_or(eye_height) - self.eye) * 0.5;
let crouching = player.pose == horizon_client::pose::Pose::Crouching;
// `LivingEntity.baseTick` (Soul Speed) comes before `aiStep` (the
// glide's wear): both draw from the level random.
if let Some((at, velocity)) = self.tick_soul_speed() {
self.particles.send_particles(
&horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Soul,
),
at,
velocity,
1.0,
0,
);
if let Some(host) = self.lan.as_ref() {
lan_particles::send_soul_particle(host, at, velocity);
}
}
// `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear.
if glider_wear > 0
&& !self.creative
@@ -4601,9 +4761,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(),
@@ -4706,6 +4889,7 @@ impl Game<'_> {
/// What the tick runs after the local player's own update: effects, the
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.share_spawn_conditions();
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
@@ -4713,6 +4897,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
@@ -5402,6 +5587,7 @@ impl Game<'_> {
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
self.draw_mod_browser(&mut batch);
}
Screen::Creative => {
self.draw_creative(&mut batch);
@@ -5410,6 +5596,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);
@@ -5794,6 +5981,7 @@ impl Game<'_> {
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
self.mod_browser_closed();
self.reset_inventory_clicks();
self.close_station();
self.mount_closed();
@@ -5909,6 +6097,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);
@@ -6521,6 +6715,9 @@ impl Game<'_> {
fn inventory_press(&mut self, right: bool) {
use horizon_client::container_screen::Target;
use horizon_client::menu::Input;
if self.mod_browser_press(right) {
return;
}
if !right && self.sort_press() {
return;
}
@@ -6704,24 +6901,8 @@ impl Game<'_> {
}
/// `hasClickedOutside` of the open screen at the cursor.
fn clicked_outside(&self, batch: &HudBatch) -> bool {
use horizon_client::container_screen::{Book, Image, clicked_outside};
let (ox, oy) = self.inventory_origin(batch);
let (shift, width, height) = if self.screen == Screen::Creative {
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
} else {
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
Some(stations::Kind::Merchant) => (-50.0, 276),
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
_ => (0.0, 176),
};
(shift, width, self.screen_height() as i32)
};
let image = Image {
left: (ox + shift) as i32,
top: oy as i32,
width,
height,
};
use horizon_client::container_screen::{Book, clicked_outside};
let image = self.container_image(batch);
let book = self
.recipe_book_area(batch)
.map(|(visible, tab_hovered)| Book {
@@ -6735,6 +6916,26 @@ impl Game<'_> {
book,
)
}
/// The open container screen's window: its place and size.
fn container_image(&self, batch: &HudBatch) -> horizon_client::container_screen::Image {
let (ox, oy) = self.inventory_origin(batch);
let (shift, width, height) = if self.screen == Screen::Creative {
(0.0, Creative::WIDTH as i32, Creative::HEIGHT as i32)
} else {
let (shift, width) = match self.station.as_ref().map(|s| s.kind) {
Some(stations::Kind::Merchant) => (-50.0, 276),
Some(stations::Kind::Beacon) => (beacon::SHIFT, 230),
_ => (0.0, 176),
};
(shift, width, self.screen_height() as i32)
};
horizon_client::container_screen::Image {
left: (ox + shift) as i32,
top: oy as i32,
width,
height,
}
}
/// A click outside the window (slot id -999): `THROW` does nothing; a
/// `PICKUP` drops the carried stack (left) or one of it (right), thrown
/// from the hand (`AbstractContainerMenu.doClick`, `LivingEntity.drop`).
@@ -6895,7 +7096,7 @@ impl Game<'_> {
}
}
fn click_inventory(&mut self, right: bool) {
if self.mount_click(right) {
if self.mod_browser_press(right) || self.mount_click(right) {
return;
}
if self.recipe_book_click(right) {
@@ -7227,7 +7428,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;
@@ -7626,7 +7832,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);
@@ -7654,8 +7860,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 {
@@ -7842,7 +8049,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;
@@ -7905,6 +8112,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 {
@@ -8067,6 +8286,7 @@ impl Game<'_> {
creative: bool,
create: Option<&horizon_client::title::WorldOptions>,
) -> Result<()> {
world_loading::world_data_used();
if self.defer_world_entry(PendingWorldEntry::Local {
folder: folder.to_owned(),
name: name.map(str::to_owned),
@@ -8132,6 +8352,7 @@ impl Game<'_> {
);
self.session_lock = Some(session_lock);
self.end.set_locator(locator);
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.bosses = Default::default();
@@ -8270,6 +8491,7 @@ impl Game<'_> {
seed,
)?;
self.end.set_locator(locator);
self.share_spawn_conditions();
Ok((streaming, save))
}
/// Change dimension through a portal (Entity.changeDimension with
@@ -10754,6 +10976,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]))
@@ -11814,6 +12047,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);
@@ -12127,6 +12361,14 @@ impl ApplicationHandler for Game<'_> {
// The recipe book's search box takes every key but Escape.
self.recipe_key(&event);
}
WindowEvent::KeyboardInput { event, .. }
if self.mod_browser_typing()
&& event.state == ElementState::Pressed
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
{
// A mod's item panel's search box, as the recipe book's.
self.mod_browser_key(&event);
}
WindowEvent::KeyboardInput { event, .. }
if self.station_typing()
&& event.state == ElementState::Pressed
@@ -12608,6 +12850,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,
@@ -12669,7 +12912,14 @@ impl<'a> Game<'a> {
level,
session_lock: None,
audio,
music: Default::default(),
music: {
// The manager starts with the saved option (MusicManager's constructor).
let mut music = horizon_client::music::MusicManager::default();
music.frequency = horizon_client::music::MusicFrequency::parse(
&settings.file.name("musicFrequency"),
);
music
},
ambience: Default::default(),
sound_paused: false,
cart_sounds: HashSet::new(),
@@ -12713,6 +12963,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(),
@@ -13036,6 +13288,17 @@ impl Game<'_> {
if self.rest_loader.is_some() {
return Ok(());
}
let on_world_screens = self.title.as_ref().is_some_and(|title| {
!matches!(
title.mode,
horizon_client::title::Mode::Main | horizon_client::title::Mode::Multiplayer(_)
)
});
world_loading::preload_world_data(
on_world_screens,
&self.options.resources,
self.pack.assets.registry,
);
if std::mem::take(&mut self.pending_pack_reload) {
self.reload_packs();
}
+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,
@@ -145,9 +145,42 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map_or(0.0, |m| m.eye_y() - m.position.y),
_ => 0.0,
// ArmorStand.getDefaultDimensions: a marker's are empty, a small
// stand's are BABY_DIMENSIONS, whose eye height is 0.9875F.
Target::Stand(id) => match self.armor_stands.iter().find(|a| a.id == id) {
Some(a) if a.marker => 0.0,
Some(a) if a.small => f64::from(0.9875f32),
_ => self.type_eye_height(&self.target_type(t)),
},
// Entity.getEyeHeight: the type's dimensions.
_ => self.type_eye_height(&self.target_type(t)),
}
}
/// `EntityType.getDimensions().eyeHeight()` from `entity-types.json`,
/// read once: a float, widened as `Entity.getEyeY` does.
fn type_eye_height(&self, ty: &str) -> f64 {
static EYE_HEIGHTS: std::sync::OnceLock<std::collections::HashMap<String, f64>> =
std::sync::OnceLock::new();
EYE_HEIGHTS
.get_or_init(|| {
std::fs::read(self.options.resources.join("entity-types.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| {
v.as_object().map(|o| {
o.iter()
.filter_map(|(k, t)| {
Some((k.clone(), f64::from(t["eye_height"].as_f64()? as f32)))
})
.collect()
})
})
.unwrap_or_default()
})
.get(ty)
.copied()
.unwrap_or(0.0)
}
/// EntityAnchorArgument.Anchor.apply.
pub(super) fn anchor_position(&self, s: &Source) -> [f64; 3] {
match s.entity {
@@ -467,9 +500,10 @@ impl Game<'_> {
"positioned" if c.has("as") => targets(self)?
.into_iter()
.filter_map(|t| {
// withPosition keeps the anchor (only `positioned <pos>`
// resets it to the feet).
Some(Source {
pos: self.target_position(t)?,
eyes: false,
..s
})
})
@@ -606,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()) => {
@@ -244,7 +244,11 @@ impl Game<'_> {
/// hand them the next jobs as soon as each one is done, rather than once
/// a tick.
pub(in crate::game) fn pump_pregen(&mut self, until: Instant) {
while self.pregen_runs_here() {
// Force-loaded chunks too: decorating in vanilla's order, a chunk
// waits for its neighbours far more often than once a tick allows.
while self.pregen_runs_here()
|| (!self.streaming.forced.is_empty() && self.streaming.busy())
{
let now = Instant::now();
if now >= until {
break;
@@ -120,6 +120,9 @@ impl Game<'_> {
tracker.track(&holder(chunk_text(x, z)), i32::from(changed));
}
}
if add {
self.streaming.rank_forced(cx0, cz0, cx1, cz1);
}
let (lo, hi) = (chunk_text(cx0, cz0), chunk_text(cx1, cz1));
let verb = if add { "added" } else { "removed" };
let (message, total) = tracker.message(
@@ -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);
}
}
_ => {}
}
}
}
}
@@ -115,6 +115,7 @@ impl Game<'_> {
"in_fire" | "campfire" | "hot_floor" => DamageKind::InFire,
"lava" => DamageKind::Lava,
"drown" => DamageKind::Drown,
"dry_out" => DamageKind::DryOut,
"magic" | "indirect_magic" | "dragon_breath" => DamageKind::Magic,
"sting" => DamageKind::Sting,
"cactus" | "sweet_berry_bush" => DamageKind::Cactus,
@@ -140,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,
@@ -301,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 {
@@ -327,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(
@@ -605,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();
@@ -628,6 +628,9 @@ impl Game<'_> {
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
+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(
[
+274 -34
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},
@@ -430,6 +431,12 @@ impl Game<'_> {
),
}
}
/// What the generator finalizes this level's structure mobs with.
pub(super) fn share_spawn_conditions(&self) {
if let Some(terrain) = self.end.locator() {
terrain.set_spawn_conditions(self.difficulty_id() as u8, self.world.day_time);
}
}
/// `ServerLevel.getCurrentDifficultyAt`.
pub(super) fn local_difficulty(&self, at: [f64; 3]) -> spawning::LocalDifficulty {
use horizon_world::food::Difficulty;
@@ -526,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
@@ -598,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
@@ -656,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();
@@ -682,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);
@@ -1078,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);
}
@@ -1289,6 +1330,10 @@ impl Game<'_> {
self.mob_vibration_particle(from, to, ticks)
}
MobEvent::DigParticles { at } => self.warden_dig_particles(at),
MobEvent::BlockBreakEffect { pos, block } => {
let state = self.world.block(block[0], block[1], block[2]);
self.destroy_particles(pos, state);
}
MobEvent::Darkness { at, radius } => self.apply_darkness(at, radius),
MobEvent::CreakingHurt {
home,
@@ -1327,11 +1372,51 @@ impl Game<'_> {
m.ai.aggressive |= aggressive;
}
}
MobEvent::GiftLoot { mob, table, at } => self.drop_gift_loot(mob, table, at),
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached: only with mob loot on.
if table == "minecraft:gameplay/turtle_grow" && !self.ui.rules.bool("mob_drops")
{
continue;
}
self.drop_gift_loot(mob, table, at);
}
}
}
Ok(())
}
/// `TurtleEggBlock.randomTick` at the last hatch stage: one baby turtle
/// per egg, a day from grown, with the egg as its home (the sound, the
/// crumbs and the block's removal came from the level).
pub(super) fn hatch_turtles(
&mut self,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) {
let eggs = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("eggs"))
.and_then(|v| v.parse::<i32>().ok())
.unwrap_or(1);
for i in 0..eggs {
let at = [
f64::from(pos[0]) + 0.3 + f64::from(i) * 0.2,
f64::from(pos[1]),
f64::from(pos[2]) + 0.3,
];
if let Some(id) = self.spawn_mob("minecraft:turtle", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
m.extra.home = Some(pos);
m.yaw = 0.0;
m.body_yaw = 0.0;
m.head_yaw = 0.0;
}
}
}
fn apply_setup(&mut self, id: u32, setup: &SpawnSetup) {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
@@ -1394,6 +1479,8 @@ impl Game<'_> {
.unwrap_or_else(|| parent.clone());
let mut child = parent.clone();
child.baby = true;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = p.species() == Species::Turtle;
if matches!(
(
p.species(),
@@ -1406,7 +1493,7 @@ impl Game<'_> {
) {
child.kind = "minecraft:mule".into();
}
self.trigger_bred(&parent, &partner_view, Some(&child));
self.trigger_bred(&parent, &partner_view, (!turtle).then_some(&child));
}
if let Some(q) = self.mobs.iter_mut().find(|m| m.id == partner) {
q.extra.age = 6000;
@@ -1418,6 +1505,19 @@ impl Game<'_> {
self.lay_sniffer_egg(&p);
return;
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
if p.species() == Species::Turtle {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
if self.ui.rules.bool("mob_drops")
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
{
let xp = 1 + m.random.next_int_bound(7).unwrap_or(0) as u32;
self.add_orb([p.position.x, p.position.y, p.position.z], xp);
}
return;
}
let coin = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
let coin2 = self.world.random.next_int_bound(2).unwrap_or(0) == 0;
// A horse and a donkey have a mule.
@@ -1581,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 {
@@ -1598,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) {
@@ -1642,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()
@@ -1980,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};
@@ -2008,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);
@@ -2157,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,
@@ -2182,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);
@@ -2203,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
}
@@ -2215,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;
@@ -2436,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),
_ => {}
}
@@ -2695,6 +2901,7 @@ impl Game<'_> {
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -2984,6 +3191,14 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
// Turtle.ageBoundaryReached: food that finishes a baby's growth.
if baby_turtle && !self.mobs[index].is_baby() {
self.handle_mob_events(vec![MobEvent::GiftLoot {
mob: self.mobs[index].id,
table: "minecraft:gameplay/turtle_grow",
at: feet,
}])?;
}
self.swing_arm();
if let Some(s) = sound {
let pitch =
@@ -3343,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,
@@ -3456,6 +3695,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool)
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::DryOut => "minecraft:dry_out",
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
@@ -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);
+7 -2
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);
}
@@ -2598,7 +2601,9 @@ impl Game<'_> {
}
pub(super) fn fight_ready(&mut self) -> bool {
self.load_fight();
self.fight.loaded
// spawnExitPortal runs from scanState and the dragon's death, both
// only with players in the fight.
self.fight.loaded && self.fight.players
}
pub(super) fn exit_portal(&self) -> Option<[i32; 3]> {
self.fight.exit_portal
@@ -3,10 +3,14 @@
//! unbreaking (`minecraft:item_damage`), sharpness/smite/bane and power
//! (`minecraft:damage`), knockback, fire aspect, infinity (`minecraft:ammo_use`),
//! thorns (`minecraft:post_attack`), mending (`minecraft:repair_with_xp`) and
//! the armor attribute effects (respiration, depth strider, ...).
//! the armor attribute effects (respiration, depth strider, ...) and Soul
//! Speed's `location_changed` and `tick` effects.
use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
use horizon_client::{
enchant::{EffectContext, Enchantments, level_value},
items::ItemStack,
};
use horizon_core::{position::BlockPos, random::RandomSource};
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
@@ -458,6 +462,134 @@ impl Game<'_> {
}
ignite
}
/// Soul Speed (`data/minecraft/enchantment/soul_speed.json`), the parts of
/// `Enchantment.runLocationChangedEffects` and `tick` that file uses. The
/// `location_changed` effects run when the player enters a new block, lands,
/// or its boots change:
/// - the speed and movement efficiency modifiers turn on over a soul speed
/// block (not flying, not riding) and stay on while airborne, then off
/// (`enchantment_active_check`);
/// - the boots lose 1 durability with a 4% chance when on such a block.
///
/// Every fifth tick the player walking on one also gets a soul particle
/// (returned as its position and velocity, for the caller to show or send
/// to the players around) and sometimes a sound. Runs for the window's own
/// player and for each guest on the server (`guest_server_tick`); a remote
/// client leaves it to its server.
pub(super) fn tick_soul_speed(&mut self) -> Option<([f64; 3], [f64; 3])> {
const ID: &str = "minecraft:soul_speed";
let player = self.player.as_mut()?;
if player.client_side {
return None;
}
let level = self.armor[3]
.as_ref()
.map_or(0, |s| s.enchantment_level(ID));
let position = player.position;
let (on_ground, flying) = (player.ground.on_ground, player.flying);
let moved = position.subtract(player.old_position);
let (tick, width) = (player.health.tick_count, player.dimensions.width);
let fx = &mut player.location_effects;
let on_soul = fx.on_soul_speed_block;
let block = BlockPos::containing(position);
let changed_block = fx.block.replace(block) != Some(block);
let landed = std::mem::take(&mut fx.landed);
// Changed boots: `stopLocationBasedEffects`, then the effects run again.
let equipped = std::mem::replace(&mut fx.boots_level, level) != level;
let was_active = fx.applied_level;
let mut active = was_active > 0 && !equipped;
let mut wear = false;
if level > 0 && (changed_block || landed || equipped) {
let riding = self.riding.is_some();
let wanted = !riding && !flying && (on_soul || (active && !on_ground));
active = wanted;
// `random_chance` is the first term of the wear's `all_of`: it
// draws from the level random before the block is looked at.
let roll = self.world.random.next_float() < 0.04;
wear = roll && on_ground && on_soul;
}
fx.applied_level = if active { level } else { 0 };
// `Entity.getKnownMovement` horizontal speed of at least 1.0E-5.
let walking = moved.x * moved.x + moved.z * moved.z >= 1.0e-10;
let tick_effects = level > 0 && !flying && on_ground && walking && on_soul && tick % 5 == 0;
// The attribute effects were applied to the player before: swap them.
if was_active > 0 && (!active || equipped) {
for (attribute, id, _, op) in
soul_speed_modifiers(&self.res.stations().enchantments, was_active)
{
let _ = player.set_modifier(&attribute, &id, 0.0, &op, false);
}
}
if active && (was_active == 0 || equipped) {
for (attribute, id, amount, op) in
soul_speed_modifiers(&self.res.stations().enchantments, level)
{
let _ = player.set_modifier(&attribute, &id, amount, &op, true);
}
}
if wear && !self.creative {
self.wear_boots(1);
}
if level == 0 {
return None;
}
let r = &mut self.world.random;
// `spawn_particles`: in the bounding box, 0.1 above the feet, thrown
// back against the movement (-0.2) and up (0.1).
let particle = tick_effects.then(|| {
let x = position.x + (r.next_double() - 0.5) * f64::from(width);
let z = position.z + (r.next_double() - 0.5) * f64::from(width);
(
[x, position.y + 0.1, z],
[moved.x * -0.2, 0.1, moved.z * -0.2],
)
});
// `play_sound`: its `random_chance` (35%) comes first in its `all_of`,
// so the level random is drawn every tick the boots are worn; volume
// 0.6, pitch 0.6 to 1.0.
if r.next_float() < 0.35 && tick_effects {
let pitch = r.next_float() * 0.4 + 0.6;
self.audio.play(
"minecraft:particle.soul_escape",
Some([position.x, position.y, position.z]),
0.6,
pitch,
);
}
particle
}
/// `ItemStack.hurtAndBreak` on the worn boots (`change_item_damage`):
/// Unbreaking applies, and they break when worn out.
fn wear_boots(&mut self, amount: u32) {
let Some(stack) = self.armor[3].clone() else {
return;
};
let loss = self.durability_loss(&stack, amount);
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |info| info.max_damage);
if max == 0 {
return;
}
let Some(boots) = self.armor[3].as_mut() else {
return;
};
boots.damage += loss;
if boots.damage >= max {
self.armor[3] = None;
if let Some(eye) = self.eye_position() {
self.audio.play(
"minecraft:entity.item.break",
Some(eye),
0.8,
0.8 + self.world.random.next_float() * 0.4,
);
}
self.apply_equipment();
}
}
/// Enchantment attribute modifiers of worn armor (respiration, depth strider...).
/// Each piece's modifier id ends with its slot (`EnchantmentAttributeEffect.idForSlot`),
/// so the same enchantment on two pieces stacks instead of replacing itself.
@@ -481,3 +613,28 @@ impl Game<'_> {
self.potions.enchant_modifiers = wanted;
}
}
/// Soul Speed's `attribute` effects at `level` (attribute, id, amount, operation),
/// the id taking the boots slot like `EnchantmentAttributeEffect.idForSlot`.
fn soul_speed_modifiers(
enchantments: &Enchantments,
level: u32,
) -> Vec<(String, String, f64, String)> {
let Some(e) = enchantments.get("minecraft:soul_speed") else {
return Vec::new();
};
e.effects["minecraft:location_changed"][0]["effect"]["effects"]
.as_array()
.into_iter()
.flatten()
.filter(|a| a["type"] == "minecraft:attribute")
.map(|a| {
(
a["attribute"].as_str().unwrap_or("").to_owned(),
format!("{}/feet", a["id"].as_str().unwrap_or("")),
f64::from(level_value(&a["amount"], level)),
a["operation"].as_str().unwrap_or("add_value").to_owned(),
)
})
.collect()
}
+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]);
@@ -64,6 +64,25 @@ impl Game<'_> {
pos: BlockPos::new(pos[0], pos[1], pos[2]),
data: 0,
});
} else if self
.res
.facts()
.get(block.state)
.name
.starts_with("minecraft:suspicious_")
{
// BrushableBlock.onBrokenAfterFall.
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.level_events.push(LevelEvent::LevelEvent {
id: 2001,
pos: at,
data: block.state.0 as i32,
});
self.level_events.push(LevelEvent::GameEvent {
pos: at,
event: "minecraft:block_destroy",
state: None,
});
}
return;
}
+12 -1
View File
@@ -71,12 +71,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);
+145 -23
View File
@@ -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,33 +122,102 @@ 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>> {
let mut finder = match self.player_spawn {
Some(finder) => finder,
None if self.level.game_mode == 2 => {
// Adventure mode: the suggestion itself.
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
finder.next_candidate();
finder
}
None => {
let mut radius = self.ui.rules.int("respawn_radius").max(0);
let to_border = self
.border
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
.floor() as i32;
if to_border < radius {
radius = to_border;
}
if to_border <= 1 {
radius = 1;
}
PlayerSpawnFinder::new(suggestion, radius, |n| {
entity_random().next_int_bound(n).unwrap_or(0)
})
}
None => self.spawn_finder(suggestion),
};
let found = loop {
let Some(candidate) = finder.next_candidate() else {
@@ -176,6 +246,58 @@ impl Game<'_> {
self.player_spawn = found.is_none().then_some(finder);
Ok(found)
}
/// A new PlayerSpawnFinder around `suggestion`: the respawn radius, cut
/// by the world border; the suggestion itself in adventure mode.
fn spawn_finder(&self, suggestion: [i32; 3]) -> PlayerSpawnFinder {
if self.level.game_mode == 2 {
let mut finder = PlayerSpawnFinder::new(suggestion, 0, |_| 0);
finder.next_candidate();
return finder;
}
let mut radius = self.ui.rules.int("respawn_radius").max(0);
let to_border = self
.border
.distance(f64::from(suggestion[0]), f64::from(suggestion[2]))
.floor() as i32;
if to_border < radius {
radius = to_border;
}
if to_border <= 1 {
radius = 1;
}
PlayerSpawnFinder::new(suggestion, radius, |n| {
entity_random().next_int_bound(n).unwrap_or(0)
})
}
/// ServerPlayer.adjustSpawnLocation for a server's new player: where
/// PlayerSpawnFinder.findSpawn puts their feet around the world spawn
/// (`spawn`, the feet there), so that players new to a world don't all
/// stand in one spot. Vanilla waits for each candidate's chunk; a server
/// tick can't, so a candidate in a chunk not loaded yet is passed over,
/// and `spawn` stays when the search never reaches a loaded column.
pub(super) fn server_player_spawn(&self, spawn: [f64; 3]) -> [f64; 3] {
let suggestion = spawn.map(|v| v.floor() as i32);
let mut finder = self.spawn_finder(suggestion);
while let Some(candidate) = finder.next_candidate() {
let (x, z) = match candidate {
SpawnCandidate::Column(x, z) => (x, z),
SpawnCandidate::Suggestion => (suggestion[0], suggestion[2]),
};
if !self.streaming.is_lit(x >> 4, z >> 4) {
continue;
}
let feet = match candidate {
SpawnCandidate::Column(x, z) => level_respawn_pos(&self.spawn_columns(), x, z)
.filter(|&pos| self.player_fits(pos))
.map(bottom_center),
SpawnCandidate::Suggestion => Some(self.fixup_spawn_height(suggestion)),
};
if let Some(feet) = feet {
return [feet.x, feet.y, feet.z];
}
}
spawn
}
/// PlayerSpawnFinder.noCollisionNoLiquid: a player's box standing at the
/// block's bottom centre touches no block or liquid.
fn player_fits(&self, pos: [i32; 3]) -> bool {
@@ -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,
+109 -20
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},
@@ -24,11 +24,24 @@ use horizon_protocol::{
};
use std::{
collections::HashMap,
sync::{Arc, OnceLock},
sync::{Arc, Mutex},
};
/// The registries and tags are the same for every world: load them once.
static SYNC: OnceLock<std::result::Result<Arc<SyncData>, String>> = OnceLock::new();
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
/// The registries and tags a server sends, loaded from `root` the first time.
pub(super) fn sync_data(root: &std::path::Path) -> std::result::Result<Arc<SyncData>, String> {
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
sync.get_or_insert_with(|| SyncData::load(root).map(Arc::new))
.clone()
}
/// Let go of what `sync_data` loaded: for a preload no world used
/// (game/world_loading.rs).
pub(super) fn forget_sync_data() {
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// The settings of the open world (WorldOptionsScreen's multiplayer section).
#[derive(Clone, Copy, Debug)]
@@ -178,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).
@@ -223,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 };
@@ -337,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 {
@@ -562,10 +615,7 @@ impl Game<'_> {
config: HubConfig,
host: Option<u128>,
) -> Result<()> {
let root = self.options.resources.clone();
let sync = SYNC
.get_or_init(|| SyncData::load(&root).map(Arc::new))
.clone()?;
let sync = sync_data(&self.options.resources)?;
let biome_names: Vec<String> = self
.res
.biomes()
@@ -835,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(),
},
})
}
@@ -952,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();
@@ -1128,8 +1198,17 @@ impl Game<'_> {
g.game_mode = default_mode;
}
if !known {
g.position = spawn;
g.previous_position = spawn;
// ServerPlayer.adjustSpawnLocation: a player new to the world
// stands where PlayerSpawnFinder puts them, not all on one spot
// (it reads the loaded level, which holds the spawn when it is
// the overworld).
let feet = if self.dim == super::Dim::Overworld {
self.server_player_spawn(spawn)
} else {
spawn
};
g.position = feet;
g.previous_position = feet;
g.health = 20.0;
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
@@ -1224,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>,
@@ -1233,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.
@@ -1320,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);
}
}
@@ -1409,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]
@@ -2183,6 +2189,7 @@ impl Game<'_> {
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let mut soul = None;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -2252,6 +2259,7 @@ impl Game<'_> {
w.player.shift_key_down = sneaking;
w.player.freeze_immune = freeze_immune;
let _ = w.server_block_damage(start);
let _ = w.update_soul_speed_block();
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
@@ -2279,6 +2287,9 @@ impl Game<'_> {
// LivingEntity.baseTick: outside the world border, then tickEffects.
game.border_damage();
game.tick_mob_effects();
// Soul Speed's effects (LivingEntity.baseTick's location_changed
// and tick effects); its particle goes to the players around.
soul = game.tick_soul_speed();
let Some(player) = game.player.as_mut() else {
return (Vec::new(), None);
};
@@ -2307,11 +2318,42 @@ impl Game<'_> {
let Some((events, marked)) = events else {
return;
};
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
self.as_guest(host, conn, |game| game.tick_player_sculk_events());
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);
}
}
_ => {}
}
}
@@ -354,6 +354,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)));
+110 -8
View File
@@ -1,7 +1,8 @@
//! Particles a server sends its players and its clients draw:
//! `ClientboundLevelParticlesPacket` (`ServerLevel.sendParticles`, which
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
@@ -24,6 +25,35 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
[c[0], c[1], c[2]]
}
/// `ServerLevel.sendParticles` of one soul particle moving by `velocity`
/// (Soul Speed's `spawn_particles`): sent to the players within 32 blocks.
pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], velocity: [f64; 3]) {
let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:soul")
else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle: Particle { id, data: None },
override_limiter: false,
always_show: false,
pos: at,
dist: velocity.map(|v| v as f32),
max_speed: [1.0; 3],
count: 0,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
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);
}
}
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
@@ -52,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
@@ -60,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 {
@@ -225,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 {
+19 -2
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,
@@ -109,6 +109,19 @@ pub(super) struct Levels {
parked: HashMap<Dim, Parked>,
}
impl Levels {
/// The chunks of each level not in place (capability `world` lends
/// them to server mods for a call).
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) fn parked_chunks(
&mut self,
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
self.parked
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
}
impl Game<'_> {
/// A new level for `dim`, opened from the save (`MinecraftServer.createLevels`).
fn open_level(&self, dim: Dim) -> Result<Parked> {
@@ -179,6 +192,7 @@ impl Game<'_> {
let mut locator = self.end.locator();
swap(&mut locator, &mut p.locator);
self.end.set_locator(locator);
self.share_spawn_conditions();
swap(&mut self.save, &mut p.save);
swap(&mut self.block_ticks, &mut p.block_ticks);
swap(&mut self.tick_chunks, &mut p.tick_chunks);
@@ -314,6 +328,8 @@ impl Game<'_> {
};
self.update_dedicated_streaming();
self.update_world();
// What server mods set in this level since its last tick.
self.apply_mod_block_writes();
result = if paused {
self.tick_lan(true);
Ok(())
@@ -384,7 +400,8 @@ impl Game<'_> {
}
*next += self.clock.tick_duration();
if *next > now {
// A `/pregen` task keeps the workers fed until the next tick.
// A `/pregen` task or force-loaded chunks keep the workers fed
// until the next tick.
self.pump_pregen(*next);
let now = Instant::now();
if *next > now {
+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).
@@ -0,0 +1,485 @@
//! Capability `ui`'s panel beside container screens (WIT `interface
//! browser`, docs/mods.md): the items a recipe viewer mod lists, drawn as
//! the game's own item slots in pages right of the screen's window, with a
//! search box under them. The mod says what the panel holds; the game pages
//! it and tells the mod what the player clicked, typed, and which of the
//! mod's keys went down over an item. With no mod showing a panel, each
//! hook here returns after one check.
use super::Game;
#[cfg(feature = "mods")]
use {
super::Screen,
horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
ui::EditBox,
},
horizon_mods::{BrowserClick, BrowserEvent, BrowserStack},
};
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_browser(&mut self, _batch: &mut horizon_client::hud::HudBatch) {}
pub(super) fn mod_browser_press(&mut self, _right: bool) -> bool {
false
}
pub(super) fn mod_browser_typing(&self) -> bool {
false
}
pub(super) fn mod_browser_key(&mut self, _event: &winit::event::KeyEvent) {}
pub(super) fn mod_browser_text(&mut self, _text: Option<&str>, _backspace: bool) {}
pub(super) fn mod_browser_closed(&mut self) {}
}
/// One slot of the panel, in GUI pixels.
#[cfg(feature = "mods")]
const SLOT: f32 = 18.0;
/// The most columns the panel takes, and the fewest it shows with.
#[cfg(feature = "mods")]
const COLUMNS: (usize, usize) = (12, 2);
/// The gap between the window and the panel, and the screen's edges.
#[cfg(feature = "mods")]
const MARGIN: f32 = 8.0;
/// The panel's state in the game.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Browser {
/// The items' owner and version they were made from.
of: Option<(usize, u64)>,
/// The mod's items as stacks; `None` for an item the game doesn't have.
stacks: Vec<Option<ItemStack>>,
page: usize,
search: Option<EditBox>,
/// The search text the mod last heard.
sent: String,
/// `set-recipes`' recipes, shown instead of the items.
pub(super) recipes: Option<super::mod_browser_recipes::Pages>,
}
/// Where the panel's parts are this frame.
#[cfg(feature = "mods")]
pub(super) struct Layout {
/// The panel's whole area, for clicks that hit none of its parts.
pub(super) area: [f32; 4],
/// The grid's top left corner, its columns and rows.
pub(super) grid: (f32, f32),
pub(super) columns: usize,
pub(super) rows: usize,
/// The page arrows' row.
pub(super) arrows_y: f32,
pub(super) search: [f32; 4],
}
#[cfg(feature = "mods")]
impl Layout {
fn per_page(&self) -> usize {
self.columns * self.rows
}
pub(super) fn width(&self) -> f32 {
self.columns as f32 * SLOT
}
fn slot(&self, n: usize) -> (f32, f32) {
(
self.grid.0 + (n % self.columns) as f32 * SLOT,
self.grid.1 + (n / self.columns) as f32 * SLOT,
)
}
/// The page arrows: backward, then forward.
pub(super) fn arrows(&self) -> [(f32, f32); 2] {
[
(self.grid.0, self.arrows_y),
(self.grid.0 + self.width() - 12.0, self.arrows_y),
]
}
}
#[cfg(feature = "mods")]
pub(super) fn inside(p: (f32, f32), [x, y, w, h]: [f32; 4]) -> bool {
p.0 >= x && p.1 >= y && p.0 < x + w && p.1 < y + h
}
#[cfg(feature = "mods")]
impl Game<'_> {
fn browser_batch(&self) -> Option<HudBatch> {
let r = self.renderer.as_ref()?;
Some(HudBatch::new([r.config.width, r.config.height]))
}
/// The mod showing a panel, with its items made into stacks once per
/// `set-items`; none while no mod shows one or no container screen is up.
fn browser_sync(&mut self) -> Option<usize> {
if self.screen != Screen::Inventory || self.title.is_some() {
return None;
}
let Some((owner, version)) = self.mods.mods.browser_items().map(|(o, v, _)| (o, v)) else {
self.mods.browser.of = None;
self.mods.browser.stacks.clear();
return None;
};
if self.mods.browser.of != Some((owner, version)) {
let items = self.res.items();
let stacks = self
.mods
.mods
.browser_items()
.map_or_else(Vec::new, |(_, _, list)| {
list.iter().map(|s| browser_stack(items, s)).collect()
});
self.mods.browser.stacks = stacks;
// The same title again (the mod updating its buttons) keeps the page.
let old = self.mods.browser.recipes.take();
self.mods.browser.recipes = self
.mods
.mods
.browser_recipes(owner)
.map(|shown| super::mod_browser_recipes::Pages::new(items, shown, old));
self.mods.browser.of = Some((owner, version));
}
Some(owner)
}
/// The panel's parts: right of the window, from the top of the screen to
/// the search box at its foot. None when the screen has no room for it.
fn browser_layout(&self, batch: &HudBatch) -> Option<Layout> {
let image = self.container_image(batch);
let left = (image.left + image.width) as f32 + MARGIN;
let right = batch.width - MARGIN;
let columns = (((right - left) / SLOT).floor().max(0.0) as usize).min(COLUMNS.0);
if columns < COLUMNS.1 {
return None;
}
let x = (left + (right - left - columns as f32 * SLOT) / 2.0).floor();
let arrows_y = MARGIN;
let top = arrows_y + 17.0 + 4.0;
let search = [x, batch.height - MARGIN - 16.0, columns as f32 * SLOT, 16.0];
let rows = ((search[1] - 4.0 - top) / SLOT).floor().max(0.0) as usize;
(rows > 0).then_some(Layout {
area: [left, 0.0, right - left, batch.height],
grid: (x, top),
columns,
rows,
arrows_y,
search,
})
}
fn browser_pages(&self, layout: &Layout) -> usize {
self.mods
.browser
.stacks
.len()
.div_ceil(layout.per_page())
.max(1)
}
/// The stack under the pointer on the panel, an item's or a recipe's,
/// as the mod sent it and as a stack.
fn browser_pointed_stack(&self, layout: &Layout) -> Option<(BrowserStack, ItemStack)> {
if self.mods.browser.recipes.is_some() {
if let Some((_, sent, stack)) = self.recipe_tab_hovered(layout) {
return Some((sent, stack));
}
let (_, _, sent, stack) = self.recipe_slot_hovered(layout)?;
return Some((sent, stack));
}
let (index, stack) = self.browser_hovered(layout)?;
let (_, _, items) = self.mods.mods.browser_items()?;
Some((items.get(index)?.clone(), stack))
}
/// The panel's item under the pointer: its index and stack.
fn browser_hovered(&self, layout: &Layout) -> Option<(usize, ItemStack)> {
if self.mods.browser.recipes.is_some() {
return None;
}
let first = self.mods.browser.page * layout.per_page();
(0..layout.per_page()).find_map(|n| {
let (x, y) = layout.slot(n);
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
return None;
}
let stack = self.mods.browser.stacks.get(first + n)?.clone()?;
Some((first + n, stack))
})
}
fn browser_search(&mut self, layout: &Layout) -> &mut EditBox {
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit.focused = false;
edit
});
[search.x, search.y, search.width, search.height] = layout.search;
search
}
/// The items' page arrows and grid.
fn draw_browser_items(&mut self, batch: &mut HudBatch, layout: &Layout) {
let pages = self.browser_pages(layout);
self.mods.browser.page = self.mods.browser.page.min(pages - 1);
let page = self.mods.browser.page;
let cursor = self.cursor;
if pages > 1 {
let labels = [
self.translate("gui.recipebook.previous_page", "Previous Page"),
self.translate("gui.recipebook.next_page", "Next Page"),
];
let shown = [page > 0, page + 1 < pages];
let atlas = &self.gui.atlas;
for (((x, y), name), (label, shown)) in layout
.arrows()
.into_iter()
.zip(["page_backward", "page_forward"])
.zip(labels.iter().zip(shown))
{
if !shown {
continue;
}
let hover = inside(cursor, [x, y, 12.0, 17.0]);
let suffix = if hover { "_highlighted" } else { "" };
batch.sprite(
atlas,
&format!("gui/sprites/recipe_book/{name}{suffix}"),
x,
y,
);
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
}
let text = format!("{}/{pages}", page + 1);
let w = HudBatch::text_width(atlas, &text);
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
}
let first = page * layout.per_page();
for n in 0..layout.per_page() {
let Some(stack) = self.mods.browser.stacks.get(first + n) else {
break;
};
let Some(stack) = stack.clone() else {
continue;
};
let (x, y) = layout.slot(n);
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
let rect = [x + 1.0, y + 1.0, 16.0, 16.0];
if inside(cursor, [x, y, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&name,
rect,
true,
horizon_client::widgets::Kind::Button,
);
}
}
/// Draws the panel over the open container screen, then the carried
/// stack or the tooltip again so they stay on top of it.
pub(super) fn draw_mod_browser(&mut self, batch: &mut HudBatch) {
if self.browser_sync().is_none() {
return;
}
let Some(layout) = self.browser_layout(batch) else {
return;
};
let cursor = self.cursor;
if self.mods.browser.recipes.is_some() {
self.draw_browser_recipes(batch, &layout);
} else {
self.draw_browser_items(batch, &layout);
}
let hint = self.translate("gui.recipebook.search_hint", "Search...");
let search = self.browser_search(&layout);
// The hint shows in an empty box without focus, as the recipe book's.
search.suggestion = (search.value.is_empty() && !search.focused).then_some(hint);
let mut search = search.clone();
search.draw(batch, &self.gui.atlas, None);
self.mods.browser.search = Some(search);
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
let lines = self.stack_tooltip(&stack.1);
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
} else {
self.draw_slot_tooltip(batch);
}
}
/// A mouse press on a container screen: true when the panel took it
/// (its search box, page arrows, items or empty space; a click there
/// never throws the carried stack).
pub(super) fn mod_browser_press(&mut self, right: bool) -> bool {
let Some(owner) = self.browser_sync() else {
return false;
};
let Some(batch) = self.browser_batch() else {
return false;
};
let Some(layout) = self.browser_layout(&batch) else {
return false;
};
let cursor = self.cursor;
let shift = self.has_shift_down();
let atlas = &self.gui.atlas;
let search = self.mods.browser.search.get_or_insert_with(|| {
let mut edit = EditBox::new(0.0, 0.0, 0.0, 0.0);
edit.max_length = 50;
edit
});
[search.x, search.y, search.width, search.height] = layout.search;
if inside(cursor, layout.search) {
search.focused = true;
search.click(atlas, cursor.0, shift, false);
search.reset_blink();
return true;
}
search.focused = false;
if self.mods.browser.recipes.is_some() {
return self.browser_recipes_press(&layout, owner, right)
|| inside(cursor, layout.area);
}
let pages = self.browser_pages(&layout);
let page = self.mods.browser.page;
let [back, forward] = layout.arrows();
let step = if pages > 1 && page > 0 && inside(cursor, [back.0, back.1, 12.0, 17.0]) {
Some(page - 1)
} else if pages > 1
&& page + 1 < pages
&& inside(cursor, [forward.0, forward.1, 12.0, 17.0])
{
Some(page + 1)
} else {
None
};
if let Some(to) = step {
self.mods.browser.page = to;
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
if let Some((index, _)) = self.browser_hovered(&layout) {
let event = BrowserEvent::Clicked(BrowserClick {
index: index as u32,
right,
});
self.mods.mods.browser_event(owner, event);
return true;
}
inside(cursor, layout.area)
}
/// Whether keys go to the panel's search box.
pub(super) fn mod_browser_typing(&self) -> bool {
self.screen == Screen::Inventory
&& self.mods.browser.of.is_some()
&& self.mods.browser.search.as_ref().is_some_and(|s| s.focused)
}
/// A key press while the search box has focus.
pub(super) fn mod_browser_key(&mut self, event: &winit::event::KeyEvent) {
use winit::keyboard::PhysicalKey;
let shortcut = self.edit_shortcut(event);
let ctrl = self.has_control_down();
let shift = self.has_shift_down();
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
search.shortcut(shortcut, &mut self.clipboard);
} else {
let handled = match event.physical_key {
PhysicalKey::Code(code) => search.key(code, ctrl, shift),
_ => false,
};
if !handled
&& !ctrl
&& let Some(text) = &event.text
{
let text: String = text.chars().filter(|c| !c.is_control()).collect();
search.insert_text(&text);
}
}
search.reset_blink();
self.browser_searched();
}
/// Text typed (or one backspace) into the search box (`horizon-ctl type`).
pub(super) fn mod_browser_text(&mut self, text: Option<&str>, backspace: bool) {
let Some(search) = self.mods.browser.search.as_mut() else {
return;
};
if backspace {
search.delete_chars(-1);
} else if let Some(text) = text {
search.insert_text(text);
}
self.browser_searched();
}
/// The mod hears the search text when it changed.
fn browser_searched(&mut self) {
let (Some((owner, _)), Some(search)) = (self.mods.browser.of, &self.mods.browser.search)
else {
return;
};
if search.value != self.mods.browser.sent {
let value = search.value.clone();
self.mods.browser.sent.clone_from(&value);
self.mods.browser.page = 0;
self.mods
.mods
.browser_event(owner, BrowserEvent::Search(value));
}
}
/// The container screen closed: the search box lets go of the keys.
pub(super) fn mod_browser_closed(&mut self) {
if let Some(search) = self.mods.browser.search.as_mut() {
search.focused = false;
}
}
/// The item under the pointer on a container screen, for the mods' keys
/// (`browser`'s `key` event): the panel's, else a slot's.
pub(super) fn mod_browser_pointed(&mut self) -> Option<BrowserStack> {
let batch = self.browser_batch()?;
let stack = match self
.browser_sync()
.and_then(|_| self.browser_layout(&batch))
{
// The panel's stacks as the mod sent them.
Some(layout) if inside(self.cursor, layout.area) => {
return Some(self.browser_pointed_stack(&layout)?.0);
}
_ => {
let slot = self.slot_at(&batch, self.cursor.0, self.cursor.1)?;
self.slot_view(slot)?
}
};
Some(BrowserStack {
item: stack.item.to_string(),
count: stack.count,
components: super::mod_recipes::components(&stack),
})
}
}
/// A mod's item as a stack: an item the game has, its components read as
/// `/give` reads them (ones it can't read are left out).
#[cfg(feature = "mods")]
pub(super) fn browser_stack(
items: &horizon_client::items::Items,
s: &BrowserStack,
) -> Option<ItemStack> {
items.get(&s.item)?;
let (damage, extra) = if s.components.is_empty() {
(0, None)
} else {
horizon_client::item_data::command_components(&s.components).map_or((0, None), |tag| {
horizon_client::item_data::decode_components(Some(&tag))
})
};
Some(ItemStack {
item: s.item.as_str().into(),
count: s.count.clamp(1, 99),
damage,
extra,
})
}
@@ -0,0 +1,523 @@
//! `browser.set-recipes` (docs/mods.md): a mod's recipes shown in its item
//! panel's place, laid out by the game with its own slots, arrows and
//! buttons, paged to the panel's height, under a title and a back button.
//! The mod hears clicks on their slots, their action buttons, the back
//! button and page turns.
use super::{
Game,
mod_browser::{Layout, browser_stack, inside},
};
use horizon_client::{
hud::{HudBatch, rgba},
items::ItemStack,
};
use horizon_mods::{BrowserEvent, BrowserLayout, BrowserSlotClick, BrowserStack, RecipePage};
use std::time::Instant;
const SLOT: f32 = 18.0;
/// The title row's height, and the gap under each recipe.
const TITLE: f32 = 18.0;
/// A tab's step: an 18 px slot and a gap.
const TAB: f32 = 20.0;
const GAP: f32 = 6.0;
/// One recipe as the game draws it.
pub(super) struct Shown {
layout: BrowserLayout,
width: usize,
height: usize,
/// Each slot's stacks (the result last), as the mod sent them and as
/// stacks; a stack the game doesn't have is left out of the cycle.
slots: Vec<Vec<(BrowserStack, ItemStack)>>,
note: String,
action: Option<bool>,
}
/// The recipes a panel shows, and its page.
pub(super) struct Pages {
title: String,
/// The tabs' icons and the selected one.
tabs: Vec<(BrowserStack, Option<ItemStack>)>,
tab: usize,
recipes: Vec<Shown>,
page: usize,
since: Instant,
}
impl Pages {
pub(super) fn new(
items: &horizon_client::items::Items,
shown: &RecipePage,
old: Option<Pages>,
) -> Self {
let (title, list, tab) = (&shown.title, &shown.recipes, shown.tab as usize);
let kept = old.filter(|o| o.title == *title && o.tab == tab);
let cycle = |stacks: &[BrowserStack]| -> Vec<(BrowserStack, ItemStack)> {
stacks
.iter()
.filter_map(|s| Some((s.clone(), browser_stack(items, s)?)))
.collect()
};
let recipes = list
.iter()
.map(|r| {
let mut slots: Vec<_> = r.slots.iter().map(|s| cycle(s)).collect();
slots.push(cycle(&r.results));
Shown {
layout: r.layout,
width: r.width as usize,
height: r.height as usize,
slots,
note: r.note.clone(),
action: r.action,
}
})
.collect();
Self {
title: title.clone(),
tabs: shown
.tabs
.iter()
.map(|t| (t.clone(), browser_stack(items, t)))
.collect(),
tab,
recipes,
page: kept.as_ref().map_or(0, |o| o.page),
since: kept.map_or_else(Instant::now, |o| o.since),
}
}
}
impl Shown {
/// The inputs' block: its size and each input slot's place in it.
fn inputs(&self) -> ((f32, f32), Vec<(f32, f32)>) {
let row = |n: usize| (0..n).map(|i| (i as f32 * SLOT, 0.0)).collect();
match self.layout {
BrowserLayout::Grid => {
let (w, h) = (self.width.max(1), self.height.max(1));
let at = (0..w * h)
.map(|i| ((i % w) as f32 * SLOT, (i / w) as f32 * SLOT))
.collect();
((w as f32 * SLOT, h as f32 * SLOT), at)
}
// The input over the flame.
BrowserLayout::Cooking => ((SLOT, 2.0 * SLOT), vec![(0.0, 0.0)]),
BrowserLayout::Single => ((SLOT, SLOT), row(1)),
BrowserLayout::Smithing => ((3.0 * SLOT, SLOT), row(3)),
BrowserLayout::Brewing => ((2.0 * SLOT, SLOT), row(2)),
}
}
/// The height of its slots, arrow and button, `room` wide.
fn block(&self, room: f32) -> f32 {
let h = self.inputs().0.1;
let (_, _, action) = self.places(room);
match self.action {
Some(_) => h.max(action.1 + 12.0),
None => h,
}
}
fn height(&self, room: f32) -> f32 {
let note = if self.note.is_empty() { 0.0 } else { 11.0 };
self.block(room) + note + GAP
}
/// Every slot's place from the recipe's corner (the result last), the
/// arrow's and the action button's: beside the result, or under it
/// when the panel is `room` wide and that doesn't fit.
fn places(&self, room: f32) -> (Vec<(f32, f32)>, (f32, f32), (f32, f32)) {
let ((w, h), mut at) = self.inputs();
// The mod's slots beyond the layout's are not shown.
at.truncate(self.slots.len() - 1);
let arrow = (w + 4.0, ((h - 16.0) / 2.0).floor());
let result = (w + 32.0, ((h - SLOT) / 2.0).floor());
let action = if result.0 + SLOT + 16.0 <= room {
(result.0 + SLOT + 4.0, ((h - 12.0) / 2.0).floor())
} else {
(result.0 + 3.0, result.1 + SLOT + 2.0)
};
while at.len() < self.slots.len() - 1 {
at.push((f32::NAN, f32::NAN));
}
at.push(result);
(at, arrow, action)
}
}
/// The tabs' places, in rows under the title row.
fn tab_places(pages: &Pages, layout: &Layout) -> Vec<(f32, f32)> {
let per_row = ((layout.width() / TAB).floor() as usize).max(1);
(0..pages.tabs.len())
.map(|i| {
(
layout.grid.0 + (i % per_row) as f32 * TAB,
layout.grid.1 + TITLE + 3.0 + (i / per_row) as f32 * TAB,
)
})
.collect()
}
/// Where the recipes start: under the title row and the tabs.
fn recipes_top(pages: &Pages, layout: &Layout) -> f32 {
let tabs = tab_places(pages, layout);
tabs.last()
.map_or(layout.grid.1 + TITLE, |&(_, y)| y + TAB + 3.0)
}
impl Game<'_> {
/// The first recipe of each page, filling the panel from the tabs to
/// the search box.
fn recipe_pages(pages: &Pages, layout: &Layout) -> Vec<usize> {
let room = layout.search[1] - 4.0 - recipes_top(pages, layout);
let mut starts = vec![0];
let mut used = 0.0;
for (i, r) in pages.recipes.iter().enumerate() {
let h = r.height(layout.width());
if used > 0.0 && used + h > room {
starts.push(i);
used = 0.0;
}
used += h;
}
starts
}
/// The recipes on the open page, each with its corner.
fn recipes_shown(&self, layout: &Layout) -> Vec<(usize, (f32, f32))> {
let Some(pages) = &self.mods.browser.recipes else {
return Vec::new();
};
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let end = starts.get(page + 1).copied().unwrap_or(pages.recipes.len());
let mut y = recipes_top(pages, layout);
(starts[page]..end)
.map(|i| {
let at = (layout.grid.0, y);
y += pages.recipes[i].height(layout.width());
(i, at)
})
.collect()
}
fn back_button(&self, layout: &Layout) -> ([f32; 4], String) {
let label = self.translate("gui.back", "Back");
let w = HudBatch::text_width(&self.gui.atlas, &label) + 8.0;
([layout.grid.0, layout.grid.1, w, 14.0], label)
}
/// What a slot of a shown recipe holds now: its stacks cycle once a second.
fn recipe_slot_now(&self, recipe: usize, slot: usize) -> Option<&(BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let stacks = pages.recipes.get(recipe)?.slots.get(slot)?;
let n = pages.since.elapsed().as_secs() as usize;
stacks.get(n % stacks.len().max(1))
}
/// The tab under the pointer: its index and icon.
pub(super) fn recipe_tab_hovered(
&self,
layout: &Layout,
) -> Option<(usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
let places = tab_places(pages, layout);
places.iter().enumerate().find_map(|(i, &(x, y))| {
if !inside(self.cursor, [x, y, SLOT, SLOT]) {
return None;
}
let (sent, stack) = pages.tabs.get(i)?;
Some((i, sent.clone(), stack.clone()?))
})
}
/// The recipe slot under the pointer: recipe, slot, and its stack now.
pub(super) fn recipe_slot_hovered(
&self,
layout: &Layout,
) -> Option<(usize, usize, BrowserStack, ItemStack)> {
let pages = self.mods.browser.recipes.as_ref()?;
self.recipes_shown(layout)
.into_iter()
.find_map(|(i, (x, y))| {
let (at, _, _) = pages.recipes[i].places(layout.width());
at.iter().enumerate().find_map(|(k, &(sx, sy))| {
if !inside(self.cursor, [x + sx, y + sy, SLOT, SLOT]) {
return None;
}
let (mod_stack, stack) = self.recipe_slot_now(i, k)?.clone();
Some((i, k, mod_stack, stack))
})
})
}
/// Draws the title row, the page's recipes and the page arrows.
pub(super) fn draw_browser_recipes(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let cursor = self.cursor;
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let (title, pages_n) = (pages.title.clone(), starts.len());
let (back, label) = self.back_button(layout);
let atlas = &self.gui.atlas;
let hover = inside(cursor, back);
let sprite = if hover {
"gui/sprites/widget/button_highlighted"
} else {
"gui/sprites/widget/button"
};
horizon_client::ui::nine_slice(
batch,
atlas,
sprite,
back[0],
back[1],
back[2],
back[3],
u32::MAX,
);
batch.text(atlas, &label, back[0] + 4.0, back[1] + 3.0, u32::MAX, true);
horizon_client::widgets::record(&label, back, true);
let room = layout.width() - back[2] - 4.0;
// Cut to the panel with vanilla's ellipsis (CommonComponents.ELLIPSIS).
let shown = if atlas.font.fit(&title, room) >= title.chars().count() {
title
} else {
let fit = atlas.font.fit(&title, room - atlas.font.width("..."));
let kept: String = title.chars().take(fit).collect();
format!("{}...", kept.trim_end())
};
batch.text(
atlas,
&shown,
back[0] + back[2] + 4.0,
back[1] + 3.0,
u32::MAX,
true,
);
self.draw_recipe_tabs(batch, layout);
if pages_n > 1 {
let labels = [
self.translate("gui.recipebook.previous_page", "Previous Page"),
self.translate("gui.recipebook.next_page", "Next Page"),
];
let visible = [page > 0, page + 1 < pages_n];
let atlas = &self.gui.atlas;
for (((x, y), name), (label, visible)) in layout
.arrows()
.into_iter()
.zip(["page_backward", "page_forward"])
.zip(labels.iter().zip(visible))
{
if !visible {
continue;
}
let suffix = if inside(cursor, [x, y, 12.0, 17.0]) {
"_highlighted"
} else {
""
};
batch.sprite(
atlas,
&format!("gui/sprites/recipe_book/{name}{suffix}"),
x,
y,
);
horizon_client::widgets::record(label, [x, y, 12.0, 17.0], true);
}
let text = format!("{}/{pages_n}", page + 1);
let w = HudBatch::text_width(atlas, &text);
let x = (layout.grid.0 + (layout.width() - w) / 2.0).floor();
batch.text(atlas, &text, x, layout.arrows_y + 5.0, u32::MAX, true);
}
for (i, (x, y)) in self.recipes_shown(layout) {
let Some(r) = self.mods.browser.recipes.as_ref().map(|p| &p.recipes[i]) else {
break;
};
let (at, arrow, action) = r.places(layout.width());
let h = r.block(layout.width());
let (cooking, note, button) =
(r.layout == BrowserLayout::Cooking, r.note.clone(), r.action);
let atlas = &self.gui.atlas;
for &(sx, sy) in at.iter().filter(|p| p.0.is_finite()) {
batch.sprite(atlas, "gui/sprites/container/slot", x + sx, y + sy);
}
if cooking {
batch.sprite(
atlas,
"gui/sprites/container/furnace/lit_progress",
x + 2.0,
y + SLOT + 2.0,
);
}
batch.sprite(
atlas,
"gui/sprites/container/furnace/burn_progress",
x + arrow.0,
y + arrow.1,
);
if let Some(enabled) = button {
let rect = [x + action.0, y + action.1, 12.0, 12.0];
let sprite = match (enabled, inside(cursor, rect)) {
(false, _) => "gui/sprites/widget/button_disabled",
(true, true) => "gui/sprites/widget/button_highlighted",
(true, false) => "gui/sprites/widget/button",
};
horizon_client::ui::nine_slice(
batch,
atlas,
sprite,
rect[0],
rect[1],
12.0,
12.0,
u32::MAX,
);
let color = if enabled {
u32::MAX
} else {
rgba(160, 160, 160, 255)
};
batch.text(atlas, "+", rect[0] + 3.0, rect[1] + 2.0, color, true);
horizon_client::widgets::record(&format!("+ {i}"), rect, enabled);
}
if !note.is_empty() {
batch.text(atlas, &note, x, y + h + 2.0, rgba(224, 224, 224, 255), true);
}
for (k, &(sx, sy)) in at.iter().enumerate().filter(|(_, p)| p.0.is_finite()) {
let Some((_, stack)) = self.recipe_slot_now(i, k).cloned() else {
continue;
};
self.draw_item(batch, &stack, x + sx + 1.0, y + sy + 1.0);
if inside(cursor, [x + sx, y + sy, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + sx + 1.0,
y + sy + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&format!("{name} {i}:{k}"),
[x + sx + 1.0, y + sy + 1.0, 16.0, 16.0],
true,
horizon_client::widgets::Kind::Button,
);
}
}
}
/// The tabs, on slots; the selected one framed.
fn draw_recipe_tabs(&mut self, batch: &mut HudBatch, layout: &Layout) {
let Some(pages) = &self.mods.browser.recipes else {
return;
};
let places = tab_places(pages, layout);
let tabs: Vec<Option<ItemStack>> = pages.tabs.iter().map(|t| t.1.clone()).collect();
let selected = pages.tab;
for (i, (&(x, y), stack)) in places.iter().zip(tabs).enumerate() {
let atlas = &self.gui.atlas;
batch.sprite(atlas, "gui/sprites/container/slot", x, y);
let Some(stack) = stack else {
continue;
};
self.draw_item(batch, &stack, x + 1.0, y + 1.0);
// The selected tab: a white frame around its slot.
if i == selected {
let white = rgba(255, 255, 255, 255);
let atlas = &self.gui.atlas;
batch.fill(atlas, x - 1.0, y - 1.0, SLOT + 2.0, 1.0, white);
batch.fill(atlas, x - 1.0, y + SLOT, SLOT + 2.0, 1.0, white);
batch.fill(atlas, x - 1.0, y, 1.0, SLOT, white);
batch.fill(atlas, x + SLOT, y, 1.0, SLOT, white);
}
if inside(self.cursor, [x, y, SLOT, SLOT]) {
batch.fill(
&self.gui.atlas,
x + 1.0,
y + 1.0,
16.0,
16.0,
rgba(255, 255, 255, 128),
);
}
let name = self.pack.names.display_item(&stack.item);
horizon_client::widgets::record_as(
&format!("{name} tab"),
[x + 1.0, y + 1.0, 16.0, 16.0],
true,
horizon_client::widgets::Kind::Tab(i == selected),
);
}
}
/// A press on the recipes: true when it hit one of their parts.
pub(super) fn browser_recipes_press(
&mut self,
layout: &Layout,
owner: usize,
right: bool,
) -> bool {
let Some(pages) = &self.mods.browser.recipes else {
return false;
};
let cursor = self.cursor;
let starts = Self::recipe_pages(pages, layout);
let page = pages.page.min(starts.len() - 1);
let [back_arrow, forward] = layout.arrows();
let turn = if page > 0 && inside(cursor, [back_arrow.0, back_arrow.1, 12.0, 17.0]) {
Some(page - 1)
} else if page + 1 < starts.len() && inside(cursor, [forward.0, forward.1, 12.0, 17.0]) {
Some(page + 1)
} else {
None
};
if let Some(to) = turn {
if let Some(p) = self.mods.browser.recipes.as_mut() {
p.page = to;
}
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Page(starts[to] as u32));
return true;
}
if let Some((tab, _, _)) = self.recipe_tab_hovered(layout) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Tab(tab as u32));
return true;
}
if inside(cursor, self.back_button(layout).0) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods.mods.browser_event(owner, BrowserEvent::Back);
return true;
}
if let Some((recipe, slot, stack, _)) = self.recipe_slot_hovered(layout) {
let event = BrowserEvent::SlotClicked(BrowserSlotClick {
recipe: recipe as u32,
slot: slot as u32,
stack,
right,
});
self.mods.mods.browser_event(owner, event);
return true;
}
let action = self.recipes_shown(layout).into_iter().find(|&(i, (x, y))| {
let r = &pages.recipes[i];
let (_, _, (ax, ay)) = r.places(layout.width());
r.action.is_some() && inside(cursor, [x + ax, y + ay, 12.0, 12.0])
});
if let Some((i, _)) = action {
if pages.recipes[i].action == Some(true) {
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
self.mods
.mods
.browser_event(owner, BrowserEvent::Action(i as u32));
}
return true;
}
false
}
}
@@ -111,6 +111,18 @@ impl Game<'_> {
if repeat || !self.mods.mods.has_keys() {
return;
}
// Over an item on a container screen, the mods' keys are `browser`
// events (a recipe viewer's R and U).
if pressed
&& self.screen == Screen::Inventory
&& self.title.is_none()
&& !self.mod_browser_typing()
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
self.mods.mods.browser_key(name, &stack);
return;
}
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
return;
}
@@ -0,0 +1,274 @@
//! Capability `recipes` (docs/mods.md): every item and recipe the game
//! knows, made once for the mods that read them, and the recipe book's
//! known displays. The recipes are the window's own data: the world's in
//! single player, vanilla's on a server (a client is sent no others), while
//! the known displays follow the server's book as the recipe book does.
use super::Game;
use horizon_client::{
crafting::Ingredient,
items::ItemStack,
recipe_book::{Category, Display},
stations::Smithing,
};
use horizon_mods::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use std::{collections::HashMap, sync::Arc};
/// Item id -> its index in [`Recipes::items`].
struct Index(HashMap<Arc<str>, u32>);
impl Index {
fn stack(&self, item: &str, count: u32, components: String) -> Option<RecipeStack> {
Some(RecipeStack {
item: *self.0.get(item)?,
count,
components,
})
}
fn of(&self, s: &ItemStack) -> Option<RecipeStack> {
self.stack(&s.item, s.count, components(s))
}
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
i.1.iter()
.filter_map(|id| self.stack(id, 1, String::new()))
.collect()
}
}
/// A stack's components beyond its item's, as vanilla's item syntax writes
/// them (`[minecraft:potion_contents={potion:"minecraft:swiftness"}]`), or "".
pub(super) fn components(s: &ItemStack) -> String {
use horizon_world::nbt::Tag;
let Some(Tag::Compound(entries)) = horizon_client::item_data::encode_components(s) else {
return String::new();
};
let parts: Vec<String> = entries
.iter()
.map(|(k, v)| {
format!(
"{}={}",
k.to_string_lossy(),
horizon_client::snbt::to_snbt(v)
)
})
.collect();
format!("[{}]", parts.join(","))
}
fn recipe(id: String, kind: RecipeKind) -> Recipe {
Recipe {
id,
kind,
category: String::new(),
group: String::new(),
width: 0,
height: 0,
slots: Vec::new(),
results: Vec::new(),
cooking_time: 0,
experience: 0.0,
book_id: -1,
}
}
impl Game<'_> {
/// `recipes.give`: a creative player takes the stack as from the
/// creative inventory; a server hears it as `SetCreativeModeSlot`
/// through the inventory's sync, and refuses it unless creative.
pub(super) fn mod_give(&mut self, item: &str, count: u32, components: &str) {
let Some(max) = self.res.items().get(item).map(|i| i.max_stack) else {
return;
};
if !self.creative {
return;
}
let (damage, extra) = horizon_client::item_data::command_components(components)
.map_or((0, None), |tag| {
horizon_client::item_data::decode_components(Some(&tag))
});
let _ = self.give(ItemStack {
item: item.into(),
// One stack at most, as the creative inventory hands out.
count: count.min(max),
damage,
extra,
});
}
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
self.mods.recipes = Some(Arc::new(self.make_mod_recipes()));
}
let (version, known) = self.recipe_book_displays();
if self.mods.known_recipes.0 != Some(version) {
self.mods.known_recipes = (Some(version), known.into());
}
(self.mods.recipes.clone(), self.mods.known_recipes.1.clone())
}
fn make_mod_recipes(&mut self) -> Recipes {
let names = &self.pack.names;
let items = self.res.items();
let mut tags: HashMap<&str, Vec<String>> = HashMap::new();
for (tag, members) in &items.item_tags {
for m in members {
tags.entry(m).or_default().push(tag.clone());
}
}
let table: Vec<RecipeItem> = items
.registry_order
.iter()
.map(|id| RecipeItem {
id: id.to_string(),
name: names.display_item(id),
tags: tags.remove(&**id).map_or_else(Vec::new, |mut t| {
t.sort();
t
}),
})
.collect();
let index = Index(
items
.registry_order
.iter()
.enumerate()
.map(|(i, id)| (id.clone(), i as u32))
.collect(),
);
let mut out = Vec::new();
let cooking: HashMap<String, (u32, f32)> = self
.res
.recipes()
.cooking
.iter()
.map(|c| (format!("minecraft:{}", c.id), (c.time, c.experience)))
.collect();
for e in self.recipe_book_entries() {
let furnace = |c: Category| match c.name().split('_').next() {
Some("blast") => RecipeKind::Blasting,
Some("smoker") => RecipeKind::Smoking,
_ => RecipeKind::Smelting,
};
let kind = match &e.display {
Display::Shaped { .. } => RecipeKind::CraftingShaped,
Display::Shapeless(_) => RecipeKind::CraftingShapeless,
Display::Furnace(_) => furnace(e.category),
};
let mut r = recipe(e.id.clone(), kind);
r.category = e.category.name().to_owned();
r.group = e.group.clone().unwrap_or_default();
r.book_id = e.display_id;
r.slots = match &e.display {
Display::Shaped {
width,
height,
cells,
} => {
(r.width, r.height) = (*width as u32, *height as u32);
cells
.iter()
.map(|c| c.as_ref().map_or_else(Vec::new, |i| index.any(i)))
.collect()
}
Display::Shapeless(list) => list.iter().map(|i| index.any(i)).collect(),
Display::Furnace(i) => vec![index.any(i)],
};
// Requirements shown as other stacks (an arrow's potions): the
// requirement's index counts the filled slots only.
let filled: Vec<usize> = (0..r.slots.len())
.filter(
|&k| !matches!(&e.display, Display::Shaped { cells, .. } if cells[k].is_none()),
)
.collect();
for (req, stacks) in &e.stacks {
if let Some(&k) = filled.get(*req) {
r.slots[k] = stacks.iter().filter_map(|s| index.of(s)).collect();
}
}
r.results = e.results().iter().filter_map(|s| index.of(s)).collect();
if let Some(&(time, xp)) = cooking.get(&e.id) {
(r.cooking_time, r.experience) = (time, xp);
}
out.push(r);
}
for c in &self.res.recipes().cooking {
if c.kind == "minecraft:campfire_cooking" {
let mut r = recipe(format!("minecraft:{}", c.id), RecipeKind::Campfire);
r.group = c.group.clone();
r.slots = vec![index.any(&c.ingredient)];
r.results = index.of(&c.result).into_iter().collect();
(r.cooking_time, r.experience) = (c.time, c.experience);
out.push(r);
}
}
let stations = self.res.stations();
for s in &stations.recipes.stonecutting {
let mut r = recipe(s.id.clone(), RecipeKind::Stonecutting);
r.slots = vec![index.any(&s.input)];
r.results = index.of(&s.result).into_iter().collect();
out.push(r);
}
for s in &stations.recipes.smithing {
let (id, template, base, addition, results) = match s {
Smithing::Transform {
id,
template,
base,
addition,
result,
} => (
id,
template,
base,
addition,
index.stack(result, 1, String::new()).into_iter().collect(),
),
Smithing::Trim {
id,
template,
base,
addition,
..
} => (id, template, base, addition, index.any(base)),
};
let mut r = recipe(id.clone(), RecipeKind::Smithing);
r.slots = vec![index.any(template), index.any(base), index.any(addition)];
r.results = results;
out.push(r);
}
for b in &stations.brewing.recipes {
let potion = |p: &horizon_client::brewing::PotionIngredient| -> Vec<RecipeStack> {
let mut item_ids: Vec<&String> = p.items.iter().collect();
item_ids.sort();
let mut potions: Vec<&String> = p.potions.iter().flatten().collect();
potions.sort();
item_ids
.into_iter()
.flat_map(|item| {
let one = |c: String| index.stack(item, 1, c);
if potions.is_empty() {
one(String::new()).into_iter().collect::<Vec<_>>()
} else {
potions
.iter()
.filter_map(|p| {
one(format!("[minecraft:potion_contents={{potion:\"{p}\"}}]"))
})
.collect()
}
})
.collect()
};
let mut r = recipe(b.id.clone(), RecipeKind::Brewing);
r.slots = vec![potion(&b.input), potion(&b.reagent)];
r.results = index.of(&b.output).into_iter().collect();
out.push(r);
}
Recipes {
items: table,
recipes: out,
}
}
}
+41 -2
View File
@@ -63,6 +63,14 @@ pub(super) struct ModsState {
/// What players did this tick, for server mods at its end.
#[cfg(feature = "mods")]
pub(super) events: Vec<ModEvent>,
/// A running server mod has capability `world`: its calls get the
/// levels' chunks to read.
#[cfg(feature = "mods")]
pub(super) world: bool,
/// Blocks server mods set, by dimension, made before that level's next
/// tick.
#[cfg(feature = "mods")]
pub(super) block_writes: Vec<(String, horizon_mods::BlockChange)>,
/// `command.run` lines for this window's own world, run on the next tick:
/// run at once, their chat lines would reach the mods while their output
/// is still being shown, and a mod answering its own lines would recurse.
@@ -81,6 +89,15 @@ pub(super) struct ModsState {
/// The HUD font's advances for `render.text-width`, made once.
#[cfg(feature = "mods")]
font_widths: std::sync::Arc<[f32]>,
/// Capability `recipes`'s tables, made once a mod reads them.
#[cfg(feature = "mods")]
pub(super) recipes: Option<std::sync::Arc<horizon_mods::Recipes>>,
/// The recipe book's known displays, and the book's version they are of.
#[cfg(feature = "mods")]
pub(super) known_recipes: (Option<u64>, std::sync::Arc<[i32]>),
/// Capability `ui`'s item panel beside container screens.
#[cfg(feature = "mods")]
pub(super) browser: super::mod_browser::Browser,
/// Extras that are official mods now, being installed.
#[cfg(feature = "mods")]
pub(super) moved: super::moved_extras::MovedState,
@@ -293,6 +310,8 @@ impl Game<'_> {
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
// Entries were replaced wholesale: a screen's owner index is stale.
self.mods.screen = None;
// Content mods may have changed the items: remade on the next read.
self.mods.recipes = None;
self.list_mods();
self.sync_mod_commands();
self.show_mod_output();
@@ -309,6 +328,7 @@ impl Game<'_> {
/// The Mods page's rows, from what loaded.
fn list_mods(&mut self) {
self.mods.server = self.mods.mods.runs_server();
self.mods.world = self.mods.mods.wants_world();
let dev = &self.mods.dev;
self.settings.mods.installed = self
.mods
@@ -440,6 +460,7 @@ impl Game<'_> {
.map(|c| char::from_u32(c).map_or(6.0, |c| atlas.glyph_cell(c).1))
.collect();
}
let (recipes, known_recipes) = self.mod_recipes();
Snapshot {
player,
clock,
@@ -451,6 +472,10 @@ impl Game<'_> {
font_widths: self.mods.font_widths.clone(),
players,
view_hit: self.mod_view_hit(),
recipes,
known_recipes,
placing: self.mod_recipe_screen(),
creative: self.creative,
}
}
/// The first block along the player's view within
@@ -673,8 +698,10 @@ impl Game<'_> {
}
Text::literal(line).with_color(text::RED)
}
server
@ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => {
server @ (Output::Tell { .. }
| Output::Broadcast { .. }
| Output::Reply { .. }
| Output::SetBlocks { .. }) => {
self.route_server_output(host.as_deref_mut(), server);
continue;
}
@@ -686,6 +713,18 @@ impl Game<'_> {
self.mods.shake.add(strength, seconds);
continue;
}
Output::PlaceRecipe { book_id, all } => {
self.mod_place_recipe(book_id, all);
continue;
}
Output::Give {
item,
count,
components,
} => {
self.mod_give(&item, count, &components);
continue;
}
Output::CloseScreen { owner } => {
if self.mods.screen.as_ref().is_some_and(|s| s.owner == owner) {
self.close_mod_screen(false);
+6 -1
View File
@@ -3,7 +3,7 @@
use super::Game;
use horizon_client::{
blocks::Fluid,
music::{BackgroundMusic, Music, Situation},
music::{BackgroundMusic, Music, MusicFrequency, Situation},
sound_engine::TickContext,
};
@@ -11,6 +11,11 @@ impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
pub(super) fn tick_music(&mut self) {
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
if frequency != self.music.frequency {
self.music.set_frequency(frequency, music.as_ref());
}
// LevelLoadingScreen: no new track before the player is in the world.
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
+10 -2
View File
@@ -357,7 +357,8 @@ impl Game<'_> {
pub(super) fn load_mob(&mut self, tag: Tag, mount: Option<u32>) -> Option<u32> {
self.next_entity += 1;
let uuid = entity_nbt::uuid(&tag);
// Structure entities placed with finalizeEntities.
// Structure mobs that generation left to finish: saves from before
// they were finalized at placement, or a placement that could not.
let mut tag = tag;
let mut finalize = false;
if let Tag::Compound(c) = &mut tag {
@@ -449,7 +450,8 @@ impl Game<'_> {
}
}
/// An entity of a type Horizon does not simulate, kept as its compound.
fn load_other(&mut self, tag: Tag, kind: &str) -> Option<u32> {
fn load_other(&mut self, mut tag: Tag, kind: &str) -> Option<u32> {
strip_finalize(&mut tag);
let position = entity_nbt::position(&tag)?;
let rotation = match tag.get("Rotation") {
Some(Tag::List(l)) => {
@@ -1014,3 +1016,9 @@ impl Game<'_> {
self.save.save_entities(chunks)
}
}
/// Only a mob is finalized; another entity drops the request.
fn strip_finalize(tag: &mut Tag) {
if let Tag::Compound(c) = tag {
c.remove(&"horizon:finalize".into());
}
}
+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();
+1 -1
View File
@@ -235,7 +235,7 @@ impl Game<'_> {
}
out
}
fn difficulty_id(&self) -> i32 {
pub(super) fn difficulty_id(&self) -> i32 {
use horizon_world::food::Difficulty;
match self.world.health_rules.difficulty {
Difficulty::Peaceful => 0,
@@ -338,6 +338,25 @@ impl Game<'_> {
ui.client_known = Some(known.clone());
ui.client_version += 1;
}
/// Every entry of the book, in its order (capability `recipes`).
#[cfg(feature = "mods")]
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
self.ensure_book();
self.recipe_ui
.book
.as_ref()
.map_or_else(Vec::new, |b| b.entries.clone())
}
/// The display ids the player's book holds, and a number that changes
/// whenever they do (capability `recipes`).
#[cfg(feature = "mods")]
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
self.sync_client_book();
(
self.recipe_ui.client_version,
self.recipe_ui.client.displays(),
)
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
let mut book = Book::build(self.res.recipes(), self.res.items());
@@ -826,6 +845,34 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(book_type, entry_index, use_max);
}
/// Whether the open screen takes `recipes.place`: a crafting grid's
/// book (the inventory's or a crafting table's).
#[cfg(feature = "mods")]
pub(super) fn mod_recipe_screen(&self) -> bool {
self.screen == Screen::Inventory
&& self.active_book() == Some(BookType::Crafting)
&& !self.spectator()
}
/// `recipes.place`: the book's click on the recipe with this display
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
/// not in the player's book; an impossible one in `place_recipe`).
#[cfg(feature = "mods")]
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
if !self.mod_recipe_screen() || !self.recipe_ui.client.displays().contains(&display_id) {
return;
}
self.ensure_book();
let Some(index) = self
.recipe_ui
.book
.as_ref()
.and_then(|b| b.entries.iter().position(|e| e.display_id == display_id))
else {
return;
};
self.recipe_ui.ghost.clear();
self.place_recipe(BookType::Crafting, index, all);
}
/// `ServerboundPlaceRecipePacket`: the server places the recipe
/// (`ServerPlaceRecipe` on the menu engine) and answers with a ghost
/// recipe when the items are missing.
+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;
}
}
}
@@ -2135,9 +2146,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
@@ -3141,6 +3176,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);
@@ -3331,6 +3368,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);
}
_ => {}
}
}
@@ -3507,6 +3551,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);
}
@@ -3517,6 +3571,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;
}
@@ -4702,7 +4760,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()),
@@ -4792,26 +4850,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) {
@@ -161,6 +161,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;
@@ -19,33 +19,7 @@ pub const HELP: &[&str] = &[
impl Game<'_> {
/// Parse `name[k=v,...]` into a registry state (defaults for missing properties).
fn parse_state(&self, text: &str) -> Result<BlockStateId> {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let registry = &self.pack.assets.registry;
let default = registry
.default_state(&name)
.ok_or_else(|| format!("unknown block {name}"))?;
let mut state = registry
.state(default)
.ok_or("missing default state")?
.clone();
for pair in props.split(',').filter(|p| !p.is_empty()) {
let (k, v) = pair
.split_once('=')
.ok_or_else(|| format!("bad property {pair}"))?;
if !state.properties.contains_key(k) {
return Err(format!("{name} has no property {k}").into());
}
state.properties.insert(k.to_owned(), v.to_owned());
}
Ok(registry.resolve(&state)?)
parse_state(self.pack.assets.registry, text)
}
pub(super) fn render_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
let int = |i: usize| -> Result<i32> {
@@ -137,3 +111,36 @@ impl Game<'_> {
}
}
}
/// Parse `name[k=v,...]` into a registry state (defaults for missing properties).
pub(super) fn parse_state(
registry: &horizon_world::registry::BlockRegistry,
text: &str,
) -> Result<BlockStateId> {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
let default = registry
.default_state(&name)
.ok_or_else(|| format!("unknown block {name}"))?;
let mut state = registry
.state(default)
.ok_or("missing default state")?
.clone();
for pair in props.split(',').filter(|p| !p.is_empty()) {
let (k, v) = pair
.split_once('=')
.ok_or_else(|| format!("bad property {pair}"))?;
if !state.properties.contains_key(k) {
return Err(format!("{name} has no property {k}").into());
}
state.properties.insert(k.to_owned(), v.to_owned());
}
Ok(registry.resolve(&state)?)
}
@@ -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> {
+151 -3
View File
@@ -47,19 +47,72 @@ impl Game<'_> {
) -> bool {
false
}
pub(super) fn apply_mod_block_writes(&mut self) {}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::chunk_map::ChunkMap;
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer, WorldView};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
use horizon_world::registry::{BlockRegistry, BlockStateId};
/// Blocks queued for levels that don't tick (another dimension, in-frame)
/// are kept up to this many; the oldest go first.
const MAX_QUEUED_WRITES: usize = 1 << 16;
/// Capability `world`'s view during a call: the levels' chunks, moved out
/// of the game for the call and back after it (no copy).
struct LentWorld {
registry: &'static BlockRegistry,
biomes: std::sync::Arc<horizon_client::biome::Biomes>,
dims: Vec<(&'static str, ChunkMap)>,
}
impl WorldView for LentWorld {
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(state_string(self.registry, chunk.block(x & 15, y, z & 15)))
}
fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(self.biomes.get(chunk.biome(x, y, z)).name.clone())
}
fn has_dimension(&self, dimension: &str) -> bool {
matches!(
dimension,
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
)
}
fn check_state(&self, state: &str) -> Result<(), String> {
crate::game::render_control::parse_state(self.registry, state)
.map(drop)
.map_err(|e| format!("{state}: {e}"))
}
}
/// `minecraft:oak_log[axis=y]`.
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
let Some(s) = registry.state(state) else {
return "minecraft:air".into();
};
if s.properties.is_empty() {
return s.name.clone();
}
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
format!("{}[{}]", s.name, props.join(","))
}
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
@@ -144,6 +197,7 @@ mod run {
let players = self.server_players(None);
let snapshot = self.mod_snapshot_with(players.clone());
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let before = std::mem::take(&mut self.mods.players);
for p in before
.iter()
@@ -197,8 +251,80 @@ mod run {
}
}
self.mods.mods.server_tick(self.ticks);
self.return_world();
self.mods.players = players;
self.show_mod_output();
self.apply_mod_block_writes();
}
/// Lends the levels' chunks to capability `world` for the calls up to
/// [`Game::return_world`], when a running server mod has it.
fn lend_world(&mut self) {
if !self.mods.world {
return;
}
let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))];
for (dim, chunks) in self.levels.parked_chunks() {
dims.push((dim.id(), std::mem::take(chunks)));
}
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
self.mods.mods.lend_world(Box::new(LentWorld {
registry,
biomes,
dims,
}));
}
/// Puts back the chunks [`Game::lend_world`] lent.
fn return_world(&mut self) {
let Some(view) = self.mods.mods.take_world() else {
return;
};
let view: Box<dyn std::any::Any> = view;
let Ok(lent) = view.downcast::<LentWorld>() else {
return;
};
let mut dims = lent.dims;
if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) {
self.world.chunks = dims.swap_remove(i).1;
}
for (dim, chunks) in self.levels.parked_chunks() {
if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) {
*chunks = dims.swap_remove(i).1;
}
}
}
/// Makes the blocks server mods set in the level in place, in order,
/// as `/setblock` does. Those of other dimensions wait for their level.
pub(in crate::game) fn apply_mod_block_writes(&mut self) {
if self.mods.block_writes.is_empty() {
return;
}
let dim = self.dim.id();
let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes)
.into_iter()
.partition(|(d, _)| d == dim);
self.mods.block_writes = later;
let registry = self.pack.assets.registry;
for (_, b) in now {
let state = match crate::game::render_control::parse_state(registry, &b.state) {
Ok(state) => state,
Err(e) => {
log::warn!("Horizon: a server mod set {}: {e}", b.state);
continue;
}
};
if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) {
log::debug!(
"Horizon: a server mod's block at {} {} {}: {e:?}",
b.x,
b.y,
b.z
);
}
}
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
@@ -240,8 +366,11 @@ mod run {
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
self.show_mod_output();
self.apply_mod_block_writes();
owned
}
@@ -266,10 +395,14 @@ mod run {
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
if !self.mods.mods.server_command(&me, line) {
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
if !owned {
return false;
}
self.route_mod_output(Some(host));
self.apply_mod_block_writes();
true
}
@@ -349,6 +482,21 @@ mod run {
self.tell_server_player(host.as_deref_mut(), &p.id, line);
}
}
Output::SetBlocks {
name,
dimension,
blocks,
} => {
let queued = &mut self.mods.block_writes;
queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b)));
if queued.len() > MAX_QUEUED_WRITES {
let over = queued.len() - MAX_QUEUED_WRITES;
queued.drain(..over);
log::warn!(
"Horizon: mod {name} set more blocks than wait for their level: {over} dropped"
);
}
}
_ => {}
}
}
+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;
@@ -196,6 +196,82 @@ impl WorldLoader {
}
}
/// How long the preloaded world data outlives the world screens: a player
/// who goes back to the title or to Multiplayer and opens no world for this
/// long gets the memory back.
const PRELOAD_KEPT: std::time::Duration = std::time::Duration::from_secs(60);
enum Preload {
Idle,
/// Read, or being read, on the "World data preload" thread. `left`: when
/// the player last left the world screens.
Started {
thread: std::thread::JoinHandle<()>,
left: Option<std::time::Instant>,
},
/// A world opened: the data is the world's now, and stays.
Used,
}
static PRELOAD: std::sync::Mutex<Preload> = std::sync::Mutex::new(Preload::Idle);
/// Each frame of the title: read what every world opening loads and keeps
/// for the rest of the process (the worldgen bundle, the loot tables and the
/// registries the server sends) on a helper thread while the player is on the
/// world screens (`on_world_screens`: Singleplayer, Create World and their
/// dialogs), so it overlaps the time spent choosing a world. A world opened
/// meanwhile waits for the part being read instead of reading it again. Not
/// at the title screen itself: many players only go to Multiplayer, and
/// small machines need the memory. Dropped after `PRELOAD_KEPT` away from
/// the world screens without a world opening.
pub(super) fn preload_world_data(
on_world_screens: bool,
resources: &std::path::Path,
registry: &'static horizon_world::registry::BlockRegistry,
) {
let mut preload = PRELOAD.lock().unwrap_or_else(|e| e.into_inner());
match &mut *preload {
Preload::Used => {}
Preload::Idle if on_world_screens => {
let resources = resources.to_owned();
let spawned = std::thread::Builder::new()
.name("World data preload".into())
.spawn(move || {
horizon_threads::behind_server();
// Errors are left for the world opening to report.
let _ = horizon_client::vanillagen::shared_data(
&resources.join("worldgen"),
registry,
);
let _ = horizon_client::structure_blocks::loot_data(&resources);
let _ = super::lan::sync_data(&resources);
});
match spawned {
Ok(thread) => *preload = Preload::Started { thread, left: None },
Err(e) => log::warn!("Horizon: world data preload: {e}"),
}
}
Preload::Idle => {}
Preload::Started { left, .. } if on_world_screens => *left = None,
Preload::Started { thread, left } => {
let left = *left.get_or_insert_with(std::time::Instant::now);
if left.elapsed() >= PRELOAD_KEPT && thread.is_finished() {
horizon_client::vanillagen::forget_shared_data();
horizon_client::structure_blocks::forget_loot_data();
super::lan::forget_sync_data();
horizon_threads::release_free_memory();
*preload = Preload::Idle;
log::info!("Horizon: preloaded world data dropped, no world opened");
}
}
}
}
/// A world opens: what `preload_world_data` read is the world's from now on.
pub(super) fn world_data_used() {
*PRELOAD.lock().unwrap_or_else(|e| e.into_inner()) = Preload::Used;
}
pub(super) fn make_streaming(
res: &Resources,
pack: &PackResources,
+2 -1
View File
@@ -118,8 +118,9 @@ impl Style {
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, true, hover);
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
+32 -1
View File
@@ -468,6 +468,10 @@ fn max_workers() -> u32 {
}
impl Item {
/// False for a setting whose system is not in Horizon yet (`Opt::available`).
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -510,6 +514,9 @@ impl Item {
}
}
pub fn set_slider(self, s: &mut Settings, v: f32) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.set_slider(s, v),
Item::Horizon(h) => h.set_slider(s, v),
@@ -517,6 +524,9 @@ impl Item {
self.touched(s);
}
pub fn cycle(self, s: &mut Settings, back: bool, framebuffer: [i32; 2]) {
if !self.available() {
return;
}
match self {
Item::Vanilla(o) => o.cycle(s, back, framebuffer),
Item::Horizon(h) => h.cycle(s, back),
@@ -689,6 +699,7 @@ impl GraphicsUi {
})
.collect();
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
&& (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
@@ -700,6 +711,13 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
}
match item.slider(s) {
Some(value) => STYLE.slider(
batch,
@@ -709,7 +727,14 @@ impl GraphicsUi {
value,
hover || self.dragging == Some(item),
),
None => STYLE.button(batch, atlas, (wd.x, wd.y, wd.w), &label, hover),
None => STYLE.button(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
hover && live,
live,
),
}
STYLE.description(
batch,
@@ -732,6 +757,7 @@ impl GraphicsUi {
(wd.x, wd.y, wd.w),
&Text::literal(label),
hover,
true,
);
}
}
@@ -751,6 +777,9 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
}
/// A press (or a drag with `drag`) at `(x, y)`.
pub fn click(
@@ -788,6 +817,8 @@ impl GraphicsUi {
}
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
+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",
@@ -819,6 +835,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)
+1 -35
View File
@@ -2,7 +2,6 @@
//! defaults, `key_<name>:key.keyboard.x` persistence, display names, and the
//! translation of rebound physical keys back to the default keys the game
//! logic listens for.
use crate::text::Text;
use std::collections::BTreeMap;
use winit::{event::MouseButton, keyboard::KeyCode};
@@ -225,40 +224,7 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
_ => return None,
})
}
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
pub fn display_name(key: &str) -> Text {
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
&& !crate::lang::has(key)
{
return Text::translate("key.mouse", &[Text::literal(n)]);
}
Text::translate(key, &[])
}
pub use horizon_client_core::key_display::display_name;
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
+1
View File
@@ -66,6 +66,7 @@ pub fn block_entity_type(name: &str) -> Option<&'static str> {
"sculk_catalyst" => "minecraft:sculk_catalyst",
"sculk_shrieker" => "minecraft:sculk_shrieker",
"crafter" => "minecraft:crafter",
"potent_sulfur" => "minecraft:potent_sulfur",
"chiseled_bookshelf" => "minecraft:chiseled_bookshelf",
"trial_spawner" => "minecraft:trial_spawner",
"vault" => "minecraft:vault",
+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(
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more