Compare commits

...
Author SHA1 Message Date
Ludovic Marie 8b792957f7 Merge pull request #2286 from ludovic111/chore/site-repo-split
🤖 Agent: merging. playhorizon.dev already deploys from ludovic111/horizon-site (verified page by page); this drops website/ and moves tokens.css, roadmap.json, the benchmark fixtures and the web icons where the site's build and the tests read them. verify passed; pr_ready --quick and the telemetry server's tests pass locally.
2026-10-26 17:00:07 +01:00
Ludovic Marie 2c6b60f3fb Merge pull request #2105 from socksprox/fix/fire-smoke-no-break-particles
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `BaseFireBlock.animateTick` (three large smoke above, or two per burnable side when the block below neither burns nor is sturdy up, in that order and with the `* 0.1F` float edge), `SoulFireBlock.canBurn` always true, and `Level.destroyBlock` → `spawnDestroyParticles` → `BaseFireBlock`'s empty `spawnDestroyByEntityParticles`. `fire.igniteOdds` is in block-behaviours.json at the top level, as read. I added one rule: `FireBlock.getIgniteOdds` is 0 for a waterlogged state, so such a neighbour no longer counts as burnable. This and #2082 (the player's predicted break) cover different paths, so both stay; #2092 was already closed in favour of this one. In game, fire and soul fire show their large smoke above them. Thanks @socksprox, and @kacpherek for #2092! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:59:27 +01:00
Ludovic Marie 8f827db52d Merge pull request #2102 from kacpherek/fix/1516-vehicles-in-nether-portals
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `NetherPortalBlock.entityInside` takes any entity that `canUsePortal(false)`, and boats and minecarts travel through `teleportCrossDimension` with their data, as items and mobs already did here. A ridden vehicle stays out of this PR's scope (the player-and-vehicle case is #1486). `lan_e2e.py --check vehicles`, `boats` and `minecarts` pass in both modes. Fixes #1516. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:56:54 +01:00
Ludovic Marie 0e0b0c947f Merge pull request #2100 from kacpherek/fix/1436-item-frame-light
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemFrameRenderer.getBlockLightLevel` gives a glow frame's model at least block light 5 (the item and map inside stay full bright), and the frame model takes the entity shader's directional shading from its face normals. My screenshot run couldn't place a frame, so this rests on the code and on `lan_e2e.py --check frames`, which passes in both modes. Fixes #1436. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:54:21 +01:00
Ludovic Marie e065f2af6f Merge pull request #2097 from kacpherek/fix/1507-server-end-credits
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `EndPortalBlock.entityInside` calls `showEndCredits` on a player in the End without `seenCredits`. That sends `WIN_GAME` with 0.0 once (`wonGame`), and `handleClientCommand(PERFORM_RESPAWN)` while `wonGame` respawns the player in the Overworld keeping its data. `seenCredits` round-trips through playerdata. One difference is left for later: vanilla takes the player out of the End level (`removePlayerImmediately`) while the poem rolls, and here it stays in the level. `lan_e2e.py --check portals` passes with the host on its server thread (I pushed one fix to the check: it closes the credits on the host's first End exit). Fixes #1507. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:51:57 +01:00
Ludovic Marie 8ccf20d909 Merge pull request #2077 from RoyalPixelsDE/fix/structure-starts-3-legacy
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The remaining legacy structure pieces now save their vanilla fields. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:51:18 +01:00
Ludovic Marie 7ace1e9e8f Merge pull request #2076 from RoyalPixelsDE/fix/structure-starts-2-templates
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Template pieces now save as vanilla's TemplateStructurePiece does. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:48:38 +01:00
Ludovic Marie ebf2132885 Merge pull request #2103 from rdbtCVS/fix/1948-merchant-cost-components
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ItemCost.STREAM_CODEC` is item, count, then `DataComponentExactPredicate.STREAM_CODEC`, a list of typed components with no removal count, as now written and read. Its verify failed only on rustfmt (item_sync.rs), which I pushed. Main conflicted in item_sync.rs's tests (the new bundle test), so I merged it, keeping both tests. I also took the codec round trip back out of the trading probe: the probe's output is what the harness compares with vanilla, the round trip is already covered by your two unit tests, and its `unwrap` would abort the probe on any cost whose components name an entry the stub registry lacks. `lan_e2e.py --check hero-trading` passes in both modes. Fixes #1948. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:44:53 +01:00
Ludovic MarieandClaude Opus 5.5 4fe648b674 Move the website to its own repository (ludovic111/horizon-site)
playhorizon.dev now deploys from the private ludovic111/horizon-site, which holds website/'s history. The files
its build still reads from here move out of website/:

- the design tokens to crates/horizon-theme/tokens.css (xtask tokens writes them, tokens_css_is_up_to_date checks them);
- the roadmap's JSON to roadmap/roadmap.json (tools/roadmap.py build);
- the benchmark fixtures to crates/horizon-telemetry-server/tests/fixtures/benchmarks/ (the site keeps a copy);
- the web icons to docs/media/brand/web/ (xtask icons).

pr_ready loses its website area and the tokens special case; rust_changes, the roadmap's links and every doc
that pointed into website/ follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:43:32 +01:00
Ludovic Marie 747c22a5c2 Merge pull request #2088 from rdbtCVS/rdbt/fix-1491-guardian-beams-20261026
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `Guardian.DATA_ID_MOVING` (16) and `DATA_ID_ATTACK_TARGET` (17, the target's entity id, 0 for none) are synced, `onSyncedDataUpdated` zeroes `clientSideAttackTime`, and the client's `aiStep` advances it up to `getAttackDuration` (80, elder 60), which drives the beam. The in-frame tick now shares `client_tick`. Main conflicted (the bat flags from #2003, the sonic and cushion checks), so I merged it into the branch, keeping both sides. `lan_e2e.py --check guardian-beams` passes with the host in-frame and on its server thread (I pushed one fix to the check: the host reads the guardian from the level that ticks it). Fixes #1491. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:41:53 +01:00
Ludovic Marie cab57a158f Merge pull request #2072 from RoyalPixelsDE/fix/proto-block-entity-id
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Block entities placed in proto chunks now save with their id, as vanilla's ProtoChunk does, so vanilla loads them. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:41:09 +01:00
Ludovic Marie 9cbf93a193 Merge pull request #2070 from RoyalPixelsDE/fix/dimension-height
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Nether and End chunks now save with their own min_y/height and sky light, as ChunkSerializer does per dimension: vanilla reads them back with every section in place. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:38:29 +01:00
Ludovic Marie b1afe112a4 Merge pull request #2281 from ludovic111/fix/launcher-proxy-redact
🤖 Agent: Merged by Ludovic's AI agent (Claude). This keeps proxy credentials out of the launcher's messages and fixes the launcher build that broke on main (download::agent call in browser/horizon.rs). Launcher lib tests 137/137, horizon-port-pack 68/68. Thanks again to @zhykowo for the proxy support in #2059.
2026-10-26 16:36:08 +01:00
Ludovic Marie b107b8539d Merge pull request #2101 from kacpherek/fix/server-thread-chest-minecart
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `ContainerEntity.isChestVehicleStillValid` is `!isRemoved() && player.isWithinEntityInteractionRange(getBoundingBox(), 4.0)`, which is entity interaction range (3, creative 5) plus 4 measured from the eye to the box, as here. The menu had closed on the tick it opened because the placeholder key was checked as a block. This is also the Discord report of a chest minecart that plays its sound but opens no screen. `lan_e2e.py --check minecarts` passes in both modes, and through horizon-ctl a chest minecart's screen is still open 60 ticks after it opens. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:35:45 +01:00
Ludovic Marie df518c1683 Merge pull request #2071 from RoyalPixelsDE/fix/trial-spawner-spawn-data
🤖 Agent: Gate check by Ludovic's AI agent (Claude). A trial spawner with nothing to spawn no longer writes an empty spawn_data, matching TrialSpawnerStateData's codec. Checked with a vanilla save of a generated world (overworld, Nether, End) taken through Horizon and back to vanilla 26.3 on the server thread and in-frame. On an integration tree with the whole stack, `save_roundtrip`, `world_reopen`, `chunk_generation`, `structure_start_nbt`, `structure_blocks`, `structure_layout`, `trial_spawner` and `vault` all pass. Thanks @FusselTV!
2026-10-26 16:35:11 +01:00
Ludovic Marie 2aa41f867e Merge pull request #2068 from lazy-importer/fix/2039-custom-item-name-tooltip
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The item_name component now sits on items and tooltips as vanilla's Item.getName/getStyledHoverName read it (custom_name first, then item_name, then the translation key). I pushed d983ef89b so item_name stays a component and is not folded into custom_name. `tooltip_display`, `lore_tooltip` and `firework_tooltip` pass on an integration tree with the other open contributor PRs. Fixes #2039 is right. Thanks @lazy-importer!
2026-10-26 16:32:32 +01:00
Ludovic Marie 8741f4d590 Merge pull request #2015 from vlesmentie/feat/campfire-cooking
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Campfire cooking (CampfireBlockEntity.cookTick/cooldownTick, placing food, dropping the result, the arm swing) matches 26.3, and food cooks in game on the server thread and in-frame. Since campfires now tick, the save round trip saw the fixture's lit campfire advance its CookingTimes between load and save, as the lit furnace already did. I added that one ignore rule (ba797f60a); `save_roundtrip` then passes with 0 unexplained differences. Thanks @vlesmentie!
2026-10-26 16:29:52 +01:00
Ludovic Marie d880b23fec Merge pull request #2278 from ludovic111/feat/sculk-banner-launcher
🤖 Agent: Sculk partner banner in the launcher (Servers and Home), as Ludovic asked. Launcher fmt, clippy -D warnings and tests pass; checked in dark and light on the Mac mini; verify passed on this head.
2026-10-26 16:26:53 +01:00
Ludovic Marie c97be88ae5 Merge pull request #2094 from rdbtCVS/rdbt/fix-1478-firework-bursts-20261026
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `FireworkRocketEntity.explode` broadcasts entity event 17 before damage and `discard`, and `handleEntityEvent(17)` calls `createFireworks` with the rocket's movement and its item's explosions, so sending the event before the tracker drops the rocket is the right order. Main conflicted (fireworks.rs, lan_e2e.py), so I merged it, keeping main's rocket damage and your `remote_firework_explosion`. `lan_e2e.py --check fireworks` passes in both modes. Fixes #1478. Thanks @rdbtCVS! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:26:25 +01:00
Ludovic MarieandClaude Opus 5.5 600b2a1dda Launcher proxy: keep a proxy's credentials out of messages; build again
An address the launcher can't use as a proxy is printed to stderr by
horizon-port-pack and shown under the Settings field or by `settings set`:
a user name and password in it now read `***`.

The registry's star ratings called download::agent() without the address
it now takes (#2059 and the ratings landed side by side), so the launcher
didn't build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:25:16 +01:00
Ludovic Marie 03b52db24e Merge pull request #2096 from kacpherek/fix/2091-recipe-book-tabs
🤖 Agent: Gate check by Ludovic's AI agent (Claude). Checked against 26.3: `AbstractRecipeBookScreen.mouseClicked` offers the click to `recipeBookComponent` before anything else, and the tabs sit left of the book, outside the strip `hasClickedOutside` counts, so the outside branch has to ask the book first. `lan_e2e.py --check recipes` passes in both modes. Fixes #2091. Thanks @kacpherek! Gate: this batch merged with wave 1 in one tree: clippy `-D warnings` clean, 1164 horizon-client lib tests pass, and of the 88 harnesses `parity.py --changed-files` picked, 87 pass (`block_entity_render` fails from #2099, which is held).
2026-10-26 16:23:43 +01:00
Ludovic Marie 27f9120510 Merge pull request #2021 from senjinthedragon/hdr-output
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I merged main and added `crates/horizon-hdr` to the workspace, and pushed caae02641 so that native material packs keep a light-emitting translucent face (nether portal) at its own alpha. HDR stays off by default. With it off, screenshots match main within noise, and the frame time does too (A/B, two runs each: p50 0.326 to 0.329 ms vs 0.317 to 0.345 ms). `section_mesh` passes. HDR on was not tested here (no HDR display on the Mac mini). Thanks @senjinthedragon!
2026-10-26 16:23:13 +01:00
Ludovic Marie 0a699d34f5 Merge pull request #2049 from vlesmentie/fix/remote-ender-dragon
🤖 Agent: Gate check by Ludovic's AI agent (Claude). With the default server thread, the End's dragon is now drawn (screenshots in the End: invisible on main, visible with this PR), and attacks on its parts reach the server. `server_dragon` passes on an integration tree with the other open contributor PRs. Fixes #1530 is right. Thanks @vlesmentie!
2026-10-26 16:22:28 +01:00
Ludovic MarieandClaude Opus 5.5 96afc48ce2 Merge main into fix/1948-merchant-cost-components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 804a288214 Merge main into rdbt/fix-1491-guardian-beams-20261026
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:18:38 +01:00
Ludovic Marie 1851dc87a7 Merge pull request #2086 from kacpherek/fix/thrown-eye-and-pearl
🤖 Agent: Gate check by Ludovic's AI agent (Claude). rdbt's approval counts as the first review (trusted reviewer). In the integration tree with the other open PRs, `lan_e2e --check thrown` and `--check projectiles` pass in-frame and on the server thread, and the `pearl_death` harness passes. Fixes #1826 is right. Thanks @kacpherek!
2026-10-26 16:17:59 +01:00
Ludovic MarieandClaude Opus 5.5 ba797f60a1 save_roundtrip: the lit campfire keeps cooking while Horizon runs
Campfires now tick, so the fixture's campfire advances its CookingTimes
between load and save, as the lit furnace does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:17:25 +01:00
Ludovic Marie 95f9ad56b2 Merge pull request #2270 from ludovic111/fix/mob-ai-oracle-heap
🤖 Agent: verify passed; this only raises the mob_ai oracle's heap (tools only). The oracle passed alone at 4g.
2026-10-26 16:17:23 +01:00
Ludovic MarieandClaude Opus 5.5 528d65ab12 launcher: Sculk partner banner on Servers and Home
Sculk, a server host, sponsors Horizon. Its banner (a 50 KB WebP,
1200x441, bundled with include_bytes) sits under a muted "Partner"
label at the end of the Servers page and of Home, below Play. A click
opens https://my.sculkhosting.com/ref/horizon; hovering lifts it 2 px
and turns the label to ink; it is reachable with Tab.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:15:57 +01:00
Ludovic Marie f8eb2f086e Merge pull request #1990 from Joansitoh/fix/server-text-caret
🤖 Agent: Gate check by Ludovic's AI agent (Claude). This needs no decision from Ludovic: every field now edits at the caret, as vanilla's EditBox does. rdbt's line-break review: the book page is a MultiLineEditBox in 26.3 (javap), so I pushed MultilineTextField's own word rule. getPreviousWord/getNextWord split words at any Character.isWhitespace and start from the cursor clamped to the last character. The single-line EditBox.getWordPosition keeps counting spaces only. I also merged main in twice (multiplayer.rs labels, stations.rs). Integration tree: `lan_e2e --check books`, `signs` and `anvil` pass in-frame and on the server thread, the `creative` and `container_screen` harnesses pass, and the ui/creative/title/clipboard unit tests pass. Thanks @Joansitoh!
2026-10-26 16:15:00 +01:00
Ludovic Marie 26a2aa2fdc Merge pull request #2047 from ESHAYAT102/fix/cushion-placement
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I checked CushionItem.useOn and Cushion.interact against the 26.3 bytecode with rea_vanilla and pushed three leftovers. (1) An occupied cushion now returns PASS, as Cushion.interact does. (2) A cushion placed in fire burns at once (destroyIfInFire). (3) Placing always posts the entity_place game event before consuming the item. I also merged main into the branch (lan_e2e.py). The suffocation check matches vanilla's block definitions except closed shulker boxes, which are approximated as always suffocating (noted in a comment). Integration tree: `lan_e2e --check cushions` passes in-frame and on the server thread, and the `cushion_placement` and suffocation harnesses pass. Thanks @ESHAYAT102!
2026-10-26 16:14:15 +01:00
Ludovic Marie 006006f1d5 Merge pull request #2059 from zhykowo/feat/launcher-proxy
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The launcher contacts no new host: the existing requests (releases, Mojang files, Modrinth, CurseForge, the mod registry, pictures, Microsoft sign-in, Tailscale's API, modpack jars) now go through the player's HTTP proxy, or HTTPS_PROXY/HTTP_PROXY/ALL_PROXY when unset. Loopback and NO_PROXY hosts stay direct, and the Servers page's local API never uses the proxy. Tokens (Microsoft, Tailscale) only cross the proxy inside TLS (CONNECT), so the proxy can't read them, and none are logged. rdbt's NO_PROXY review is addressed: `horizon-port-pack`'s agents apply the same rules (`no_proxy_lists_hosts_and_their_subdomains`). On Linux at da54f988d: launcher lib tests 132 passed, `cargo clippy --all-targets -D warnings` clean, `horizon-port-pack` 68 tests passed and clippy clean. Labelled feature. One small note for later: an unusable proxy address is printed to stderr as typed, so a `user:pass@` in it would show in the log. Thanks @zhykowo!
2026-10-26 16:10:03 +01:00
Ludovic MarieandClaude Opus 5.5 453ee22376 Merge main into fix/server-text-caret
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:01:24 +01:00
Ludovic Marie db4ea8a200 Merge pull request #2038 from levzzz5154/fix/bundle-tooltip
🤖 Agent: Gate check by Ludovic's AI agent (Claude). `tools/bundle_tooltip_e2e.py` passes in-frame. On the server thread (what players run) it showed every bundle full, holding more bundles, so I pushed two commits: (1) BundleContents' stream codec (component 52, a list of ItemStackTemplate, checked with javap) plus sending it to clients in `network_stack`; (2) `Hub::stack` gave every stack inside an item the outer item's id, which also turned a crossbow's charged projectiles into crossbows on clients. It is fixed now: each inner stack looks up its own id. With both commits the e2e passes on the server thread and in-frame (306 px tooltip frame; main shows 117 px and fails). The screenshot shows 32 stone with a half-full bar, as in vanilla. Unit tests: `bundle_contents_are_item_stack_templates` and `a_bundle_keeps_its_contents_across_the_wire`. Thanks @levzzz5154!
2026-10-26 15:58:10 +01:00
Ludovic Marie d48e56778e Merge pull request #2037 from levzzz5154/fix/loot-enchant-functions
🤖 Agent: Gate check by Ludovic's AI agent (Claude). I checked the loot functions against vanilla 26.3 directly. A vanilla server placed 48 chests with fixed LootTableSeed values: 16 tables (end city, stronghold library, ancient city, mansion, dungeon, trial chamber rewards and more) times 3 seeds, then rolled them. Horizon then opened the same world. Every chest got the same items, counts, slots and enchantments as vanilla, enchant_randomly and enchant_with_levels included: 48/48 on the server thread and 48/48 in-frame. The only difference is the order of keys inside the enchantments map, which vanilla doesn't read. EnchantRandomly and EnchantWithLevels match the bytecode, and rdbt's single-level review point is covered by `a_single_level_enchantment_draws_no_level`. It is also built and run in an integration tree with the other open PRs. Thanks @levzzz5154!
2026-10-26 15:51:13 +01:00
Ludovic MarieandClaude Opus 5.5 17e4d547c7 parity: give the mob_ai oracle a 4g heap
VanillaMobAiOracle ran out of its 3g heap in PoiManager.tick on a busy CI
runner. Each scenario forces 9x9 chunks at a new origin; the live heap after
GC swings between 0.3 and 1 GB over a run, with peaks near 2 GB before GC.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:45:13 +01:00
Ludovic MarieandClaude Opus 5.5 b242a5ac15 Give the stacks inside a sent item their own ids
Hub::stack resolved every item id of a stack to the outer item's, so a
bundle's contents and a crossbow's charged projectiles reached a server's
clients as more bundles and crossbows. The tooltip then drew a bundle of
bundles, full. Each stack inside now looks its own id up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:40:50 +01:00
Ludovic Marie 8975e2370b Merge pull request #2217 from ludovic111/ci/linux-runner-ubuntu
CI: Linux runner on Ubuntu (playhorizon), tokens without gh, CPU and memory knobs
2026-10-26 15:40:06 +01:00
Ludovic Marie 9a7f75b75d Merge pull request #1894 from ludovic111/feat/server-mods-game
🤖 Agent: merged main (aec52c85e). pr_ready --quick passes (1308 horizon-client tests, clippy, 507 LOC). The welcome server mod e2e passes 35/35 in every mode: in-frame, server thread, LAN host in-frame and on its thread with a guest, dedicated with a guest. Verify is green; parity now runs on main (parity-scope). Queued.
2026-10-26 15:30:36 +01:00
Ludovic MarieandClaude Opus 5.5 a4839d322b CI: Linux runner on Ubuntu (playhorizon), tokens without gh, CPU and memory knobs
linux-runner.sh takes its packages from apt where there is no Nix, the
registration token from RUNNER_TOKEN (a machine where gh must not be logged
in), and RUNNER_CPUS / RUNNER_MEMORY for a machine that does nothing else.
playhorizon, a partner's 8-thread server, joins parity_hosts.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:28:21 +01:00
Ludovic Marie c5a2259d92 Merge pull request #2022 from mc070309/cursor/sprint-jump-impulse-68db
🤖 Agent: Gate check by Ludovic's AI agent (Claude). The server fix in this PR landed first in #1779 (levzzz's PR, same change to `hub.rs` and `agent.rs`, with the same `lan_bot` test). I merged main into the branch and kept main's side, so what this PR now adds is its two unit tests: `sprint::tests::sprint_survives_releasing_the_key_through_a_jump` (the client sprint state survives the key coming up on the jump tick) and `living_motion::tests::sprint_jump_adds_look_direction_impulse` (LivingEntity.jumpFromGround's 0.2 impulse along the look, and none for a walk). Both pass, and `lan_e2e --check sprint-fov` passes in-frame and on the server thread in the integration tree. Labelled skip-release, because players see no change. Thanks @mc070309 for tracking the cause down and for the tests.
2026-10-26 15:25:12 +01:00
Ludovic Marie 7ec3fd2b4c Merge pull request #2212 from ludovic111/fix/furnace-recipes-used-namespace
🤖 Agent: Merging. verify and parity passed; the new unit test covers the merge of both key forms, and save_roundtrip no longer sees duplicate RecipesUsed keys.
2026-10-26 15:20:16 +01:00
Ludovic MarieandClaude Opus 5.5 be73f4ac6a Keep a furnace's RecipesUsed keys namespaced, so vanilla reads Horizon's furnaces
bf81ae635 started saving recipes_used, but Horizon's recipe ids lack the
minecraft: namespace, so a furnace could hold both forms of one id and
vanilla refused it with "Duplicate entry for key" (save_roundtrip).
recipe_used_key gives the full id; loading merges the two forms from saves
written in between.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 15:10:58 +01:00
Ludovic Marie 8027cb4176 Merge pull request #2177 from ludovic111/feat/launcher-mod-ratings
🤖 Agent: Merging. verify passed, along with launcher tests (11 registry tests), the horizon-theme icon test and clippy -D warnings. #2173, which it was stacked on, is merged.
2026-10-26 15:05:13 +01:00
Ludovic Marie 91400aac5b Merge pull request #2173 from ludovic111/feat/mod-index-downloads
🤖 Agent: Merging. verify and mod-registry passed, and pr_ready --quick passed (clippy, horizon-mods and launcher tests). The index's downloads field is optional, so the schema doesn't change.
2026-10-26 15:02:11 +01:00
Ludovic Marie 26894ab3fd Merge pull request #2170 from ludovic111/feat/mods-site-stats
🤖 Agent: Merging. verify passed and the website tests (44) pass. Ratings appear once #2167's service is live; until then the pages show downloads only.
2026-10-26 14:59:28 +01:00
Ludovic Marie ae17e26009 Merge pull request #2140 from ludovic111/feat/quiet-commands-server
🤖 Agent: verify and parity pass (109 harnesses affected; parity green). The dedicated-server e2e (tools/quiet_commands_e2e.py) passes 7/7. Queued. #2171 (the mod API, stacked on it) follows once this lands.
2026-10-26 14:57:05 +01:00
Ludovic MarieandClaude Opus 5.5 ae3b844d74 Merge main into fix/server-text-caret
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:56:10 +01:00
Ludovic Marie 03c8594494 Merge pull request #2061 from lazy-importer/fix/2034-bossbar-vanish-reconfigure
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @lazy-importer!
2026-10-26 14:53:47 +01:00
Ludovic MarieandClaude Opus 5.5 9540734e29 Merge main into fix/cushion-placement
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:50:57 +01:00
Ludovic Marie 601df6c22d Merge pull request #2052 from levzzz5154/fix/chest-shift-click
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 14:50:55 +01:00
Ludovic MarieandClaude Opus 5.5 3dd17ad7d7 Launcher: registry mods show their 1-5 star rating
Horizon results and project pages show a project's rating from the
registry service (GET playhorizon.dev/api/mods/ratings, fetched with the
results, kept ten minutes): five pixel stars, the mean and how many, or
"No ratings yet". Nothing shows when the service doesn't answer.

The launcher has no GitHub account, so a project's page offers "Sign in
with GitHub to rate": it opens the site's sign-in, which comes back to the
project's page and its stars (one rating per GitHub account).

HORIZON_MOD_RATINGS (a file or https:// address) points a test registry at
its own ratings. The star icon is drawn for Horizon on the icons' grid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:50:40 +01:00
Ludovic Marie 394ce4928c Merge pull request #2044 from Domanffe/docs/roadmap-help-wanted-links
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:47:47 +01:00
Ludovic MarieandClaude Opus 5.5 0e4965845a Book pages: move by words as MultilineTextField does
BookEditScreen's page is a MultiLineEditBox. Its getPreviousWord and
getNextWord split words at any whitespace (Character.isWhitespace, line breaks
included) and start from the cursor clamped to the last character. The
single-line EditBox.getWordPosition keeps looking at spaces only, so the line
break rule moves to the multiline box alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:47:17 +01:00
Ludovic Marie 8fa967ac07 Merge remote-tracking branch 'origin/main' into feat/server-mods-game 2026-10-26 14:45:29 +01:00
Ludovic Marie aec52c85e9 Merge pull request #2041 from Domanffe/perf/uniform-values-reuse
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:44:52 +01:00
Ludovic MarieandClaude Opus 5.5 a0a170064e Merge main into sprint-jump-impulse
Main's #1779 already keeps the sprint through PlayerInput (hub.rs, agent.rs)
and has the same lan_bot test, so this keeps main's side: what the branch adds
is the two unit tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:44:08 +01:00
Ludovic MarieandClaude Opus 5.5 caae026416 HDR: keep native material packs' vertex tint opaque
The mesher now keeps a face's light emission in its colour's alpha byte. The
terrain shader drops it, but the native material path (merged since) passed
the colour through as the tint, so a light-emitting translucent face such as
a nether portal drew a little more transparent with HDR off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:42:00 +01:00
Ludovic MarieandClaude Opus 5.5 92ed365a19 Merge main into hdr-output
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:41:59 +01:00
Ludovic Marie c65338b3cb Merge pull request #2029 from Domanffe/perf/subtitle-width-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:41:27 +01:00
Ludovic MarieandClaude Opus 5.5 590ca775c2 Mod registry: download counts in the index and the launcher's browser
The registry's publish job (ludovic111/horizon-mods#7) writes each
project's `downloads` into index.json, summed from GitHub's counts of its
release assets every hour. `index::Project` reads it (absent: 0, never
written when 0, so older readers and the schema are unchanged).

The launcher's Horizon results show "12.3k downloads" like Modrinth's, and
list the most downloaded first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:37:12 +01:00
Ludovic Marie 38bea19137 Merge pull request #2167 from ludovic111/feat/mod-ratings-api
🤖 Agent: Merging. The verify and mod-registry checks (fmt, clippy -D warnings, tests) passed. The registry-data volume is already mounted at /data.
2026-10-26 14:36:12 +01:00
Ludovic MarieandClaude Opus 5.5 282556870f Cushions: pass when occupied, burn when placed in fire, entity_place
Checked against the 26.3 bytecode (tools/rea_vanilla.py):
- Cushion.interact passes (PASS) when the cushion already has a rider;
  CONSUME is only for a startRiding that fails.
- CushionItem.useOn calls destroyIfInFire right after addFreshEntity, and
  posts GameEvent.ENTITY_PLACE from the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:34:55 +01:00
Ludovic Marie a03940e0a8 Merge pull request #2166 from ludovic111/ci/parity-main-flake-green
🤖 Agent: parity-main counts a failure that passes again at HEAD as a pass; tests assert each exit code, YAML parses.
2026-10-26 14:33:41 +01:00
Ludovic Marie 4f70efcfa9 Merge pull request #2027 from Domanffe/perf/sign-first-line
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:33:10 +01:00
Ludovic MarieandClaude Opus 5.5 a83d6f33be Campfire: a full campfire's use does not swing the arm
CampfireBlock.useItemOn returns SUCCESS_SERVER (a swing) only when
placeFood took the item, and CONSUME (no swing) when all four slots are
taken.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:31:50 +01:00
Ludovic Marie 9c26db87e6 Merge pull request #2024 from levzzz5154/fix/remote-cooldown-tick
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 14:30:19 +01:00
Ludovic MarieandClaude Opus 5.5 f49d5d91e6 Site: downloads and 1-5 star ratings on /mods
- The list shows each project's rating (stars, mean and count) and its
  downloads ("12.3k downloads"), ordered by downloads by default; an Order
  menu also sorts by rating or name (?sort= too).
- A project's page shows the same, then a rating control: five stars when
  signed in (the account's own rating lit), "Sign in with GitHub to rate"
  (coming back to the page) when not, a note for its owners.
- Ratings come from the registry service's GET /api/mods/ratings; a rating
  is POSTed with the session's CSRF token. Until the service answers, the
  pages show downloads only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:29:31 +01:00
Ludovic Marie ec7a2e22a7 Merge pull request #2012 from Domanffe/perf/poses-layer-reuse
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:27:29 +01:00
Ludovic MarieandClaude Opus 5.5 3caf8f7014 lan_e2e portals: close the credits on the host's first End exit
The first exit from the End now rolls the poem and credits, and the host
goes home once they close (PERFORM_RESPAWN); the check asserts they show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:24:09 +01:00
Ludovic MarieandClaude Opus 5.5 8d6d1f26a4 Send a bundle's contents to a server's clients
BundleContents had no stream codec here: the host never sent what a bundle
holds, so on the server thread, LAN and a dedicated server the new tooltip
showed every bundle empty, and a vanilla server's bundle made its packet
undecodable. Its STREAM_CODEC is a list of ItemStackTemplate, the same as
ChargedProjectiles' (component 52).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:20:25 +01:00
Ludovic Marie b78daab11c Merge pull request #2081 from kacpherek/fix/performance-keeps-animated-textures
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 14:18:21 +01:00
zhykowoandWorkBuddy AI da54f988d4 Proxy: one copy of the rules, none of it per request
Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 21:17:28 +08:00
Ludovic MarieandClaude Opus 5.5 455817204f Mod registry: 1-5 star ratings, one per GitHub account
The registry service keeps players' ratings of published projects in one
SQLite file on a volume (REGISTRY_DB, /data on Railway):
- GET /api/mods/ratings: every rated project's average and count, cached a
  minute; never who rated.
- GET /api/mods/{id}/rating: the signed-in account's own rating.
- POST /api/mods/{id}/rating: sets or changes it, checked like a submission
  (origin, session, CSRF token), 30 per account and 600 in all per hour, only
  for projects on the registry's main, never by their owners.
- The sign-in takes ?back=/mods/<id> to come back to a project's page.

The image hands the volume to nobody before running as nobody.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:15:46 +01:00
Ludovic Marie aadb8b6727 Merge pull request #2079 from nirajkhetre/fix/enderman-death-message
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @nirajkhetre!
2026-10-26 14:14:44 +01:00
Ludovic Marie 7b94de404e Merge pull request #2010 from Domanffe/perf/skip-lookup-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:13:29 +01:00
Ludovic MarieandClaude Opus 5.5 9e76f8c4f0 parity-main: a failure that passes again at HEAD counts as a pass
Twice today (runs 40301239508, 40311712302) mob_ai failed in parity-main, passed when bisect ran it again at
the same HEAD, and the run still ended red, so no game release could follow main for three hours. The
harness step now continues on error and the bisect step gives the verdict: 0 when every failed harness
passes again at HEAD, 1 when they fail at the base too or a merge is named.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:12:04 +01:00
Ludovic Marie 8087d8bf58 Merge pull request #2009 from vlesmentie/fix/villager-new-profession-trades
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @vlesmentie!
2026-10-26 14:10:43 +01:00
Ludovic Marie 659e6d7896 Merge pull request #1993 from Domanffe/perf/no-double-cracks-and-ticker-copy
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:09:57 +01:00
Ludovic MarieandClaude Opus 5.5 ae63b23dd6 lan_e2e guardian-beams: the host reads the guardian's own level
horizon-ctl's mobs answers from the server's level when the host runs on
its server thread, where the targeted player is Player, as in-frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:08:15 +01:00
Ludovic Marie a3f5615ec7 Merge pull request #2108 from ludovic111/perf/worldgen-data-parallel-load
🤖 Agent: pr_ready passed (horizon-world and client tests, affected harnesses), lan_e2e passed in-frame and on the server thread. GeneratorData::load on the ThinkPad 0.64 → 0.45 s warm. Queued for merge.
2026-10-26 14:07:33 +01:00
Ludovic Marie b244c5d26e Merge pull request #2042 from Domanffe/perf/tooltip-component-compare
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 14:06:20 +01:00
Ludovic MarieandClaude Opus 5.5 d983ef89b4 Keep item_name as its component: translate it when drawn
The decoded plain string replaced the component on save and on the way to
LAN guests, so a translatable name (explorer maps' filled_map.*, the
ominous banner in tests without a language) came back as its key, and
spill's ominous banner test failed. The component now stays in the patch
as loaded and is resolved in the current language when a name is drawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 14:05:17 +01:00
Ludovic Marie 9af6d0be5c Merge pull request #2118 from ludovic111/fix/localize-server-screens
🤖 Agent: contrib-gate-17 verified this head (integration tree build, clippy, tests, parity; details in the PR comment). Re-opened from kohad1ck's PR; thanks to kohad1ck.
2026-10-26 14:03:06 +01:00
Ludovic Marie 034c0f0228 Merge pull request #2117 from ludovic111/fix/font-glyphs-atlas-rebuild
🤖 Agent: contrib-gate-17 verified this head (integration tree build, clippy, tests, parity; details in the PR comment). Re-opened from kohad1ck's PR; thanks to kohad1ck.
2026-10-26 14:00:01 +01:00
Ludovic Marie dec94b6c61 Merge pull request #2053 from rdbtCVS/rdbtcvs/fix-2019-recipe-screen-cleanup
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:59:13 +01:00
Ludovic Marie c1e7a86622 Merge pull request #2020 from levzzz5154/fix/guest-chat-visibility
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @levzzz5154!
2026-10-26 13:58:21 +01:00
Ludovic Marie 6ee319c607 Merge pull request #2018 from rdbtCVS/fix/peaceful-mob-despawn-1942
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:55:47 +01:00
Ludovic Marie fc5cd8060d Merge pull request #2007 from rdbtCVS/fix/1962-passenger-animation
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:55:12 +01:00
Ludovic Marie 0555120244 Merge pull request #2048 from rdbtCVS/fix/1490-sonic-boom-delivery
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:52:47 +01:00
Ludovic Marie 9feb4da17c Merge pull request #2003 from rdbtCVS/fix/bat-wing-animation-2000
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:51:59 +01:00
Ludovic Marie f8f49a033f Merge pull request #1992 from DanielGodriNeto/fix/benchmark-wait-loaded
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @DanielGodriNeto!
2026-10-26 13:51:04 +01:00
Ludovic Marie 245206203d Merge pull request #1999 from rdbtCVS/t3/fix-critical-hit-particles
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @rdbtCVS!
2026-10-26 13:48:23 +01:00
Ludovic Marie 1da8c57e72 Merge pull request #1987 from socksprox/fix/world-text-parity
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @socksprox!
2026-10-26 13:47:04 +01:00
Ludovic Marie 11635c3bdf Merge pull request #2085 from kacpherek/fix/2084-enderman-teleport
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 13:44:44 +01:00
Ludovic Marie ba6ca28577 Merge pull request #2082 from kacpherek/fix/fire-extinguish-no-break-particles
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @kacpherek!
2026-10-26 13:44:08 +01:00
Ludovic Marie 612d5f33ea Merge pull request #2031 from Domanffe/perf/debug-line-width-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 13:43:34 +01:00
Ludovic Marie f6b3e9935d Merge pull request #2011 from Domanffe/perf/vehicle-set-once
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @Domanffe!
2026-10-26 13:42:53 +01:00
Ludovic Marie 749a8a2a64 Merge pull request #1986 from some-du6e/fix/prepare-atlauncher
🤖 Agent: contrib-gate-17 verified this head (26.3 sources, integration tree build, clippy, tests, parity; details in the PR comment). Thanks @some-du6e!
2026-10-26 13:42:23 +01:00
Ludovic Marie 175419c6e7 Merge pull request #2139 from ludovic111/fix/registry-dockerfile-shader-api
🤖 Agent: the registry image copies horizon-shader-api (every Railway build failed without it); the same context builds locally with --locked.
2026-10-26 13:42:01 +01:00
Ludovic Marie aea0df9770 Merge pull request #2122 from ludovic111/feat/native-material-stage
🤖 Agent: verified: pr_ready --quick green at this head, 33/33 affected harnesses pass locally, CI verify green, A/B shows no frame cost without a pack. A Horizon shader pack's material stage now colours terrain in the game.
2026-10-26 13:38:03 +01:00
Ludovic MarieandClaude Opus 5.5 145c4c877b Quiet commands: the server runs a mod's batch of commands with no chat
A Horizon client sends a client mod's batch of command lines as one
`horizon:quiet_commands` payload, which Horizon's server announces only to
Horizon clients. Each line runs exactly as the player typing it would
(the same checks, their own permission level), with vanilla's suppressed
output: no feedback and no failure in anyone's chat. Batches wait in a
queue: at most 64 lines run per player per tick, and at most 4096 wait.
Each finished batch is one line in the server's log.

tools/quiet_commands_e2e.py checks it on the dedicated server: a non-op's
quiet setblock is refused, an op's runs, and nothing reaches the chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:37:04 +01:00
Ludovic MarieandClaude Opus 5.5 4b3af580d9 mob_ai: drop the Peaceful type scenarios that diverge for other reasons
A slime made without finalizeSpawn keeps 20 health where the probe's has 1,
the probe doesn't report a piglin's brain, and a NoAI bee's flower cooldown
runs one tick apart; none of that is the Peaceful despawn rule.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:34:52 +01:00
Ludovic MarieandClaude Opus 5.5 a34cad379f mod registry image: copy horizon-shader-api, which horizon-mods now depends on
Every Railway build of the registry has failed since horizon-mods gained the optional horizon-shader-api
dependency ("no matching package named horizon-shader-api"). Built the same context locally: OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:32:27 +01:00
Ludovic Marie a27579dd72 Merge remote-tracking branch 'origin/main' into feat/server-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-26 13:29:17 +01:00
Ludovic Marie 23c748b2f3 Merge pull request #2127 from ludovic111/fix/ops-feedback-clock
🤖 Agent: server_ops_feedback on the server clock (stall-injector proof by ci-flakes-1); CI parity runs the harness.
2026-10-26 13:17:23 +01:00
Ludovic MarieandClaude Opus 5.5 ddbf2ce211 Trading probe: compare generated offers only
The probe's output is what the harness compares with vanilla; the codec
round trip lives in the unit tests, and its unwrap on a cost whose
components name an unmapped registry entry could abort the probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:16:22 +01:00
Ludovic Marie 5a9e07301b Merge pull request #2113 from ludovic111/feat/mod-raycast
🤖 Agent: game.raycast, mod API level 2. CI is green on c23b4e8e4. In game, in-frame and on the server thread, the ui guest's /raycast passes all four checks: glass at 2 blocks, nothing at 1, gold after /setblock, the sky is no hit. The ported Orbital Laser aims with it and fires in both modes. Queued.
2026-10-26 13:13:29 +01:00
Ludovic MarieandClaude Opus 5.5 e2e591858e Fire smoke: a waterlogged neighbour doesn't burn
FireBlock.getIgniteOdds returns 0 for a waterlogged state, so canBurn is
false there and the smoke takes the other branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:11:49 +01:00
Ludovic MarieandClaude Opus 5.5 6a7951e150 mob_ai oracle: create a Peaceful scenario's hostile mobs past canSpawn
EntityType.create returns null for a type that isn't allowedInPeaceful once
the difficulty is Peaceful, so the peaceful_* scenarios threw in the oracle.
Their mobs stand for ones already in the world when the difficulty changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:09:28 +01:00
Ludovic MarieandClaude Opus 5.5 e944bd2a0e Merge main (#2114 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 13:09:24 +01:00
Alexander Krylov 51425c9c22 Merge remote-tracking branch 'origin/main' into docs/roadmap-help-wanted-links 2026-10-26 15:07:19 +03:00
Alexander Krylov dfefb6d46a Merge remote-tracking branch 'origin/main' into perf/tooltip-component-compare 2026-10-26 15:07:16 +03:00
Alexander Krylov 02f147f137 Merge remote-tracking branch 'origin/main' into perf/uniform-values-reuse 2026-10-26 15:07:14 +03:00
Alexander Krylov 54e4274b87 Merge remote-tracking branch 'origin/main' into perf/debug-line-width-once 2026-10-26 15:07:11 +03:00
Alexander Krylov c2d137ed06 Merge remote-tracking branch 'origin/main' into perf/subtitle-width-once 2026-10-26 15:07:08 +03:00
Alexander Krylov 81e4272126 Merge remote-tracking branch 'origin/main' into perf/sign-first-line 2026-10-26 15:07:05 +03:00
Alexander Krylov 7e047c409c Merge remote-tracking branch 'origin/main' into perf/poses-layer-reuse 2026-10-26 15:07:01 +03:00
Alexander Krylov d3a1ce1d7a Merge remote-tracking branch 'origin/main' into perf/vehicle-set-once 2026-10-26 15:06:56 +03:00
Alexander Krylov 11eb74b0b6 Merge remote-tracking branch 'origin/main' into perf/skip-lookup-once 2026-10-26 15:06:45 +03:00
Alexander Krylov 68f23b9916 Merge remote-tracking branch 'origin/main' into perf/no-double-cracks-and-ticker-copy 2026-10-26 15:06:38 +03:00
Ludovic Marie 43929f3ff8 Merge pull request #2090 from ludovic111/perf/teleport-drop-off-frame
🤖 Agent: pr_ready passed (79 harnesses, 1299 client tests), lan_e2e passed in-frame and on the server thread (portals, levels, distance). Mass-drop frame at RD 32: 26-40 → 7-8.5 ms. Queued for merge.
2026-10-26 13:02:03 +01:00
Ludovic Marie f362f15212 Merge pull request #2114 from ludovic111/fix/harness-await-flakes
🤖 Agent: harness-only change (skip-release). The old harnesses fail under server stalls, the new ones pass; 20 affected harnesses pass locally, and the two changed ones rerun after merging main.
2026-10-26 12:53:58 +01:00
Ludovic Marie 68fa7714bb Merge remote-tracking branch 'origin/main' into feat/native-material-stage
# Conflicts:
#	docs/shaders.md
2026-10-26 12:48:15 +01:00
Ludovic MarieandClaude Sonnet 5.5 f0649da024 parity: server_ops_feedback ends steps on the server's clock, not a 2 s sleep
Built on the Client of the harness-await branch (#2114). Under 2.5 s server stalls the old harness fails (a command's
feedback filed under the next step) and the new one passes 3 of 3.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 12:46:11 +01:00
Ludovic MarieandClaude Opus 5.5 fcbadbaa30 Merge main into rdbt/fix-1478-firework-bursts-20261026
fireworks.rs keeps main's rocket blast damage next to the client burst;
lan_e2e.py takes main's CHECKS and server-thread default plus 'fireworks'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:37:15 +01:00
kohad1ck b279758c7c Localization: server screens and the new world name follow the language
The Add/Edit Server, Direct Connection and Friends screens and Create New\nWorld built their titles, field labels, defaults and buttons from English\nliterals, so every non-English language showed English there. Route them\nthrough the current language with vanilla's keys:\n\n- ManageServerScreen: manageServer.add.title/edit.title, enterName, enterIp,\n  selectServer.defaultName, gui.done and gui.cancel.\n- DirectJoinServerScreen: manageServer.enterIp (the addServer.enterIp key is\n  not in 26.3), selectServer.select and gui.cancel.\n- WorldCreationUiState: selectWorld.newWorld.\n- Select World: selectWorld.title and selectWorld.versionUnknown.\n- DisconnectedScreen: disconnect.lost and gui.toMenu.\n\nA language missing a key keeps the English fallback, so en_us is unchanged.\n\nCo-Authored-By: ZCode Agent <[email protected]>
2026-10-26 12:35:25 +01:00
kohad1ck 5ec21ff5f1 Font: menus keep their run-time glyphs when the renderer rebuilds its atlas
Menu text drawn with characters the bitmap sheets lack (Chinese, Japanese,
Korean) is rasterized into a reserve of the shared atlas at run time, and the
font caches the atlas rect it got. The loading screen hands over to the game
about two seconds after the title shows by building a new renderer, whose
atlas texture is made from HudAtlas.pixels alone: those never hold a
run-time glyph, while the cache still answers with the old rects, so every
such character already drawn went blank. Latin text, straight from the
sheets, stayed. build_renderer and loading_renderer now forget the cache
before the new texture exists, so the characters that are drawn again are
placed into it, as ui_theme clears its own cache when the atlas changes.

Fixes #1824, fixes #2040.
2026-10-26 12:35:21 +01:00
Ludovic MarieandClaude Opus 5.5 db5c10ba20 Merchant offers: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:35:09 +01:00
Ludovic MarieandClaude Opus 5.5 f4b4f944e3 Capture test: the fixture's Game resets its inventory clicks too
capture(true) now calls reset_inventory_clicks; the fixture compiles the
method body against a stub Game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:33:08 +01:00
c23b4e8e41 Mod API: game.raycast, the block the player looks at (level 2)
`game.raycast(distance)` returns the first block along the player's view
(fluids pass), up to 1024 blocks: through the loaded chunks as the
crosshair's ray goes, then across the distant terrain's ground heights
(`Lod::ground`, from Stink-O's fork). The game casts it only while a running
mod imports the function. It is API level 2, so older Horizons refuse a mod
that calls it with "update Horizon".

Co-authored-by: Stink-O <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:27:06 +01:00
zhykowoandWorkBuddy AI a9453b7c93 Modpack downloads honour NO_PROXY too
The jars an import fetches went through the proxy whatever the host, so a host
that only answers directly failed there while the launcher's own requests to it
went direct. net::agent now takes the URL and applies the same NO_PROXY and
loopback rules the launcher's own traffic gets.

Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 19:21:09 +08:00
Ludovic MarieandClaude Opus 5.5 fe19efb917 Predicted break: test BaseFireBlock by its class, as the server does
The world side checks Class::BaseFire; the tag is a data-pack list that can
differ from the class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 12:14:59 +01:00
Ludovic Marie 5d04a5bee8 Merge remote-tracking branch 'origin/main' into fix/harness-await-flakes 2026-10-26 12:04:32 +01:00
Ludovic MarieandClaude Opus 5.5 ee83d8f247 Chat test: set the abilities through set_abilities (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:47:27 +01:00
Kacpherek 990786901a Let empty boats and minecarts enter Nether portals 2026-10-26 11:46:16 +01:00
Kacpherek 1f51e3d18c Server players: chest minecart and chest boat menus stay open
A vehicle's items are kept under a placeholder key far outside the world, and the guest container tick checked the player's reach to that key as a block, so the menu closed the tick it opened. Vanilla checks reach to the vehicle (ContainerEntity.isChestVehicleStillValid).
2026-10-26 11:46:10 +01:00
Kacpherek 49b167ce22 Item frames: shade the frame model by face and light glow frames from 5 2026-10-26 11:45:56 +01:00
Kacpherek 1e6884c591 Show the poem and credits on a player's first End exit in server-thread mode 2026-10-26 11:43:37 +01:00
Ludovic MarieandClaude Opus 5.5 cdbe1edcda Text displays: keep the background at vanilla's -1..width, -1..height
TextDisplayRenderer.submitInner calls submitTextBackground(-1, -1, width,
height), and Font.prepareBackground's effect glyph spans x0..x1, y0..y1, so the
fill is width + 1 by height + 1 from (-1, -1), as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:42:48 +01:00
Ludovic MarieandClaude Opus 5.5 2bfa7f57ba Merge main into fix/world-text-parity
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:42:48 +01:00
Kacpherek 76e2c84103 Recipe book: a click on a category tab reaches the book before the outside test 2026-10-26 11:41:21 +01:00
Ludovic MarieandClaude Opus 5.5 7abae79d41 Merge main into fix/prepare-atlauncher
sounds.rs: main's Assets::load reads the launcher's files through game_files.
The rewritten-manifest test takes main's message; main's new test passes
GameFiles. ATLauncher is also looked for where the Horizon launcher looks
(~/Library/Application Support/ATLauncher, the Flatpak's data/atlauncher).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:41:03 +01:00
socksproxandDevin 0a2de159fd Fire: ambient smoke particles and no mob-break crumbs
BaseFireBlock.animateTick adds large smoke above a fire (three puffs in
its top half), or two puffs at each burnable neighbour's face when the
fire sits on nothing burnable or sturdy. SoulFireBlock.canBurn is
always true, so soul fire always takes the top-half branch. The
burnable table mirrors FireBlock.canBurn (fire.igniteOdds above zero)
from block-behaviours.json.

Mobs breaking blocks (MobEvent::BreakBlock) map to Level.destroyBlock,
whose Block.spawnDestroyParticles BaseFireBlock overrides to nothing:
a wither or another mob destroying fire no longer emits break
particles. Player breaks already route through player_destroy (1009);
the client-side predict_break particles are PR #2082's.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 11:39:08 +01:00
Ludovic MarieandClaude Opus 5.5 f19390bae1 Sonic boom: take the LAN host in the same let-else as the particle
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:39:07 +01:00
Ludovic MarieandClaude Opus 5.5 78bfe74465 Container clicks: the stuck-drag test joins the module's tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:38:39 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol d25d0f025b Merchant offers: preserve required potion components
Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:38:26 +00:00
Ludovic MarieandClaude Opus 5.5 fb74b6038f Chat: system and player lines share the window's chat code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:38:17 +01:00
Ludovic MarieandClaude Opus 5.5 bde76794ee Mobs: no rain or thunder outside the overworld
Level.canHaveWeather is false in the Nether and the End, so their rain and
thunder levels stay at 0. Set that once in the mob world instead of checking
the End at two of the rain sites: bees, dolphins, wolves and happy ghasts read
the same flag.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:37:43 +01:00
Ludovic MarieandClaude Opus 5.5 2918f31811 Poses::reset clears the scratch pose too
A missing part's writes land in `dummy`; a fresh Poses started it at zero,
so a reused one must too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:36:50 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 52a0a5a89c Fix firework bursts on server clients
Send the rocket explosion event before tracked removal and render it with the synchronized firework item. Cover colored bursts and later trail frames in both hosting paths.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:35:33 +00:00
Ludovic MarieandClaude Opus 5.5 c813261574 Horizon shader packs: the material stage runs in the game
A pack's `fn material(input: MaterialInput) -> Surface` now gives the lit
colour of every terrain surface: sections and distant tiles, solid,
cutout and translucent, in both the instanced and the flat draws. Terrain
keeps Horizon's meshes, culling and draw lists; only its pipelines change.
Their shader is the pack's stage, a prefixed copy of terrain.wgsl's
terrain section, and entries that hand the pack the texture, the vertex
tint, the light levels and the lightmap's colour apart, then apply the
alpha test and fog as `fs_solid` and `fs_translucent` do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:35:15 +01:00
rdbtCVSandOpenAI Codex GPT-6.1 Sol 8c6ec16c99 Fix guardian charging beams on server clients
Sync guardian moving and active attack-target metadata, resolve the actual targeted player, and tick the client's visual charge clock. Cover charge completion and target loss for guardians and elders. Fixes #1491.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-26 10:21:50 +00:00
socksproxandDevin 6b8fde092f Keep 26.3's name-tag and text-display values
Review caught three regressions against the pinned client jar: submitInner
lines stay lineHeight+1 (10 px), submitNameTag fades discrete and
see-through names with the same 0x7F, and lightCoordsWithEmission floors
both light channels at 2.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 11:20:39 +01:00
levzzzandHermes Agent 7a2de4e328 Render bundle contents in station inventory tooltips
Keep hovered item stacks through draw_station and use draw_stack_tooltip, while retaining text-only station hints. Covers anvil and brewing-stand player inventory slots and stonecutter result previews.\n\nAdd an offscreen render regression which fails on the old binary for both reported screens and passes on the fix in server-thread and in-frame modes.

Co-Authored-By: Hermes Agent (OpenAI) <[email protected]>
2026-10-26 15:18:17 +05:00
Ludovic MarieandClaude Opus 5.5 08f4fd5c8e Teleports free their dropped columns off the frame and in batches
A teleport far away (or a storage that moves by more than its size) drops
every column in one frame: 3,725 at RD 32. That frame freed every column's
sections, then removed each section from the renderer one at a time, with
a few hash lookups per section and a free-list tree update per mesh range.

- The dropped columns, and a left dimension's whole chunk map, are freed on
  a short-lived thread of their own ("Chunk freeing").
- Renderer::remove_columns removes whole columns: one lookup per column for
  the tables kept by column, and the meshes' ranges go back to the arena in
  one sorted merge per page (FreeList::give_all) instead of a tree update
  each. Per-section Renderer::remove and Occlusion::remove had no other
  callers and are gone.
- The renderer's block-entity and detail-column tables use FxHashMap.

Mac mini (M4 Pro), RD 32, explored world, server thread, 3 alternated runs,
two long teleports each: the drop took 26.1-39.7 ms, now 6.9-8.5 ms.
Entering the Nether: 9.7-13.4 ms, now 4.0 ms or under 6 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:10:25 +01:00
Kacpherek 6b60056637 Thrown projectiles: fly on the client between server updates, eye shatter particles, potion motion order
On a server, arrows, snowballs, eggs, ender pearls, potions and eyes of ender only eased towards the tracker's position, which comes every 10 to 20 ticks, so they moved in jerks. Like vanilla (Entity's NO_OP InterpolationHandler) the server's position now replaces theirs at once and they fly on by themselves each tick.

A shattered eye of ender now sends level event 2003 with its item crumbs and portal rings.

Thrown potions applied gravity and drag after the move; ThrowableProjectile applies them before.
2026-10-26 11:07:37 +01:00
Ludovic MarieandClaude Sonnet 5.5 34d8d94022 parity: server harnesses wait on the server's clock and replies, not on sleeps
server_knockback, locator_bar, server_dimension_players and their one-player users now end a step by the server's
game clock (a sync round trip, N ticks, a last sync) the way server_player_targets does, so a server that stalls
under load still gets all its ticks inside the step. server_knockback's frozen section waits for the stepped tick,
the stopped clock and the landed hit; dedicated_server waits for the player's leaving before /list.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 11:06:02 +01:00
Kacpherek 311c0f7392 Stop endermen teleporting nonstop in the End
The End has a fixed time and no precipitation, so endermen there must not
flee daylight or take rain damage.
2026-10-26 11:05:49 +01:00
Ludovic MarieandClaude Opus 5.5 83f0d5bf58 Worldgen data loads its three biggest parts in parallel
GeneratorData::load read everything on the server thread while the chunk
workers waited: the block tables, the structure templates and the block
behaviours now load on threads of their own, and BlockBehavior::load reads
its cached-support table beside the other two. The behaviour exports
(gameplay-facts.json is 55 MB) parse from bytes in memory instead of
through a reader.

ThinkPad (2 cores), 3 alternated runs: 0.63 -> 0.45 s with the behaviour
snapshot cached, 2.40 -> 2.07 s on a first start.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:04:57 +01:00
SenjinandClaude Opus 5.5 728c6a62b3 HDR: the emission mark in the vertex colour's alpha, not a layer bit
EMISSIVE_LAYER took bit 14 of TerrainVertex::layer, halving the sprite
layers terrain vertices can index (32768 to 16384) and leaving no flag
bit free. The colour's alpha byte was always 0xFF: its low four bits now
hold 15 minus the light level the face emits (EMISSION_BITS), so 0xFF
still means none and the other four bits stay free. terrain.wgsl takes
only the colour's rgb and passes the level on; Java shader packs get
alpha 1; the section mesh fixture restores vanilla's 0xFF.

The HDR pass still lifts any emitting face the same way; the level is
there for scaling the lift by it later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:03:16 +01:00
SenjinandClaude Opus 5.5 9b863d6093 HDR output: scRGB swapchain, HDR metadata and emissive highlights
HDR (Graphics > Display, off by default) presents to an scRGB swapchain
where the surface offers it. The frame is drawn as in SDR, in vanilla's
gamma space; a last pass decodes it at the Paper White and lifts light
sources (blocks that emit light, lava, the sun and the moon) evenly
towards the Peak Brightness.

The mesher marks emitting faces with EMISSIVE_LAYER (bit 14 of the
layer); the frame's alpha carries the mark to the HDR pass, which lifts
only pixels that still show the world before the hand and HUD.

horizon-hdr sends HDR metadata through VK_EXT_hdr_metadata, which wgpu
doesn't: without it KWin clips scRGB content above 1.0 (80 nits).

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 11:03:16 +01:00
Kacpherek c80b3b4950 Multiplayer: no break particles or sound when a player puts out fire
The predicted break played the block's break sound (wool for fire) and its destroy particles. BaseFireBlock skips both (spawnDestroyByEntityParticles does nothing); players only hear the server's extinguish sound.
2026-10-26 10:58:41 +01:00
Kacpherek e7f41e7d13 Performance preset: keep animated textures on
The preset turned them off, which froze fire, water, lava and portals. Players reported fire with no animation (#2064). The option stays in Advanced Graphics for anyone who wants it off.
2026-10-26 10:51:26 +01:00
Niraj Khetre 290e2e1ac7 The ender dragon's hits and breath name it in the death message
The dragon's wing and head hits and its breath clouds hurt the player
without recording the dragon as the causing entity, and the dragon is
not one of the mobs note_attacker looks up. death.attack.mob and
death.attack.indirectMagic then got only the victim's name, so the
template showed through: "%1$s was killed by %2$s using magic".

Record the dragon before its damage lands (EnderDragon.hurt and
knockBack use mobAttack(dragon); its breath clouds apply
indirectMagic(cloud, dragon)).

Fixes #2062

Assisted-by: Claude Opus 5.5
2026-10-26 15:14:37 +05:30
levzzzand[CC] <deepseek-v4.1-flash> 941f148957 Loot: a single-level enchantment draws no level
Review of #2037 (rdbtCVS): `EnchantRandomlyFunction.enchantItem` calls
`Mth.nextInt(random, minLevel, maxLevel)`, which returns `min` without
drawing when `min >= max`. The arm here drew unconditionally, so choosing
Mending (or any one-level enchantment) shifted every roll after it.

`horizon_world::loot`'s copy and `merchant.rs`'s already guard this; only the
loot dispatcher in `items.rs` did not.

Vanilla with `LegacyRandom(11)`: a Mending book followed by
`set_count` uniform 1..64 is 46; the extra draw made it 28.
`a_single_level_enchantment_draws_no_level` pins that (and the stored
Mending level).

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 14:30:45 +05:00
lazy-importerandClaude Opus 5.5 ad7203fc8a Model item_name component on items and tooltips
In vanilla 1.20.5+, servers and datapacks customize base item names using
the `minecraft:item_name` data component rather than `minecraft:custom_name`.
Vanilla's `ItemStack.getHoverName()` checks `custom_name` first, then
falls back to `getItemName()`, which retrieves `DataComponents.ITEM_NAME`.

Previously, Horizon did not model `item_name` in `StackData`, causing items
with custom base names from servers or resource packs to fall back to
their default translation keys instead of the server-provided name.

This adds `item_name` to `StackData`, decodes and encodes it across
network item sync, and prioritizes it in `name_line` and hover tooltips.

Fixes #2039.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 10:21:00 +01:00
JoansitohandClaude Sonnet 5.5 fa8291fc97 Treat a line break as a word boundary in text fields.
Ctrl+Backspace on a book page with the caret after `two` in `one\ntwo` removed both lines because the word helper only knew spaces.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-26 10:06:48 +01:00
Ludovic Marie 593b99a68b Merge branch 'feat/server-mod-test-scripts' into feat/server-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-26 10:01:50 +01:00
lazy-importerandClaude Opus 5.5 c675329208 Reset boss overlay on server reconfiguration
When switching sub-servers on a proxy network (Velocity/BungeeCord), the
network thread enters configuration mode and emits Event::Reconfigured.
Vanilla's ClientPacketListener.handleConfigurationStart clears the client
level, which calls Hud.onDisconnected() and resets BossHealthOverlay.
Without this reset, bossbars from previous servers remained visible on
screen.

Fixes #2034.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:42:35 +01:00
FusselTVandClaude Opus 5.5 ad50996f7c Structures: save the remaining legacy pieces as vanilla does
Desert and jungle temples, swamp huts, buried treasure, ocean
monuments, mineshafts, fortresses and strongholds save the tag their
StructurePiece subclass writes, with the state placement leaves: the
height a scattered feature settled on, the buried treasure chest,
placed chests, traps and spawners, and the swamp hut's witch and cat.
With these every StructurePieceType is saved.

test_structure_start_nbt_parity gets one site of each of these kinds,
and a badlands mineshaft.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 b7b36836af Structures: save template pieces as vanilla does
Igloo, shipwreck, ocean ruin, ruined portal, nether fossil, End City
and mansion pieces save the tag TemplateStructurePiece and its
subclasses write, with the state placement leaves: the height a
shipwreck or ocean ruin settled on and the igloo's moved box. Ruined
portals save all six properties, End City pieces their genDepth tag.

test_structure_start_nbt_parity gets one site of each of these kinds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 9a0fcda3a9 Proto chunks: save placed block entities with their id
Block entity data a structure places can lack its type: a brushable
block's is only its loot table. Finished chunks get the id from the block,
saved proto chunks did not. Vanilla and Paper skip a pending block entity
without one when they finish the chunk ("Skipping block entity with
invalid type"), so suspicious sand and gravel at the edge of a
pre-generated area lost their archaeology loot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 df3ac51447 Trial spawners: no empty spawn_data on save
The block entity canonicaliser added spawn_data {entity: {}} to every
trial spawner that had none. Vanilla only writes spawn_data once the
spawner has picked its next mob (TrialSpawnerStateData.Packed reads it as
Optional.empty). A spawner saved while mob spawning was off, as worlds
pre-generated in peaceful or with spawner_blocks_work false are, kept the
empty entity for good: getOrCreateNextSpawnData returns it, it names no
mob, and the spawner never spawns, in Horizon and in vanilla or Paper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 c776d6586e Structures: save jigsaw starts as StructureStart.createTag does
Horizon kept no saveable form of its structure starts, so its saves carry
an empty structures.starts and every structure it generates is invisible
to vanilla and Paper (locate, structure spawns, location predicates,
advancements) and cannot be continued past the generated area.

StructureStart::save and Piece::save write the tag vanilla writes. This
covers the pieces of jigsaw pools (villages, pillager outposts, ancient
cities, trail ruins, trial chambers, abandoned camps, bastions) with
their pool element, junctions and liquid settings. Junctions keep
delta_y and dest_proj, pool elements their processors as written.
LegacyPiece::save defaults to None; a start with such a piece is not
saved, as before.

test_structure_start_nbt_parity compares the tags with the pinned
server's own for one site of every jigsaw structure kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:34 +01:00
FusselTVandClaude Opus 5.5 1bbe64265c Saves: Nether and End chunks keep their own height
Chunk holds the Overworld's 24 sections in every dimension, and the save
wrote them as they are: yPos -4, sections -4 to 19 and heightmaps counted
from y=-64. Vanilla and Paper trust those heightmaps, so a Horizon-made
Nether reads its surface at y=192, above the bedrock roof, and the End 64
blocks too high. Re-saving a vanilla Nether chunk rewrote its yPos too.

LevelShape (skylight, min_y, height per dimension folder) replaces the
save path's sky_light flag. yPos, the saved sections, the PostProcessing
lists and the heightmaps follow it, as SerializableChunkData.write does.

LAN clients in the Nether and End now get the chunk's heightmaps; the
heightmap check failed there and they were sent empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 09:33:33 +01:00
zhykowoandWorkBuddy AI 3e79b22c43 Launcher downloads go through the proxy the player sets
Everything the launcher fetches now goes through one HTTP proxy when there is
one: Horizon's releases and the launcher's own update, Minecraft's files and
Java from Mojang, Modrinth, CurseForge, the mod registry, pictures, Microsoft
sign-in, Tailscale's API, and the jars a modpack import downloads. It is
Settings' Proxy row (`proxy` in settings.json), or `horizon-launcher settings
proxy http://host:port`; without one, HTTPS_PROXY, HTTP_PROXY and ALL_PROXY
apply, with NO_PROXY naming the hosts to reach directly.

A local address never goes through it, and the Servers page's calls to a game
server's API stay direct: they are on loopback and asked every second.

horizon-port-pack builds its own agents, so the launcher hands it the address
(net.rs); run on its own, it reads the same variables.

Co-Authored-By: WorkBuddy AI (DeepSeek-V4.1-Flash) <[email protected]>
2026-10-26 16:28:24 +08:00
levzzz d1c1af598b Bundles show what they hold
Hovering a bundle showed only its name and `BundleItem.appendHoverText`'s
"Can hold a mixed stack of items": nothing drew the contents, so a player
could not see what was inside or how full it was (#2001).

`ClientBundleTooltip` is ported to `bundle::tooltip_layout`: the 96 pixel
column, the four-wide grid filled bottom-up and right-to-left from
`getNumberOfItemsToShow` (eleven stacks and a `+N` when it overflows), the
progress bar at `Mth.mulAndTruncate(weight, 94)` with the full sprite and the
`item.minecraft.bundle.empty`/`.full` labels, and an empty bundle's wrapped
description. The game draws it under the stack's own tooltip
(`Game::draw_stack_tooltip`), which the inventory, creative, container,
beacon and trading hovers now use.

Verified in game: a bundle with 64 stone shows one slot and a full red bar
labelled "Full!"; one with 32 stone and 8 dirt shows two slots (32 stone
first, as the next out) over a half-filled blue bar; an empty one shows the
description and "Empty".
2026-10-26 13:22:31 +05:00
levzzzand[CC] <deepseek-v4.1-flash> 87e3df5331 Container clicks: a closed screen drops its press state
`AbstractContainerScreen` keeps `isQuickCrafting` and the drag it belongs
to on the screen. Horizon keeps both on `Game` (`inv_clicks`, `inv_drag`,
`last_inv_click`), and nothing cleared them when a screen closed: a press
whose release never reached the screen — the pointer left the window, or the
screen closed between the press and the release — left a quick-craft drag
running, and `Clicks::press` answers `None` for every later press, so clicks
went dead on every screen after it.

`Game::reset_inventory_clicks` now runs when a container closes and when the
pointer is grabbed again (the inventory's screens are behind us), which is the
lifetime vanilla gives that state.

Refs #1737

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 12:52:42 +05:00
rdbtCVSandCodex GPT-6.1 Sol cd7b6e025e Fix recipe book overlays surviving remote screen changes
Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 07:49:37 +00:00
vlesmentieandClaude Opus 5.5 955b6c2134 The server thread's client draws the Ender Dragon
On a server's client (single player on its server thread, LAN guests,
dedicated servers) the dragon was tracked but never built: spawn_entity
only makes mobs from mob_types(), which has no dragon, and
dragon_geometry draws Game::dragon. The crystals had their own remote
constructor; the dragon had none.

The client now keeps the server's dragon in Game:🐉
handleAddEntity builds it (full health, hovering, as its constructor
does), SetEntityData applies DATA_HEALTH_ID and DATA_PHASE,
SetEntityMotion its velocity, and its moves interpolate like other
living entities. tick_remote_dragon is the client side of
EnderDragon.aiStep: wing flaps and their sound, the growl,
checkCrystals' nearest-crystal search for the healing beam, the flight
history and the parts; a dead dragon rises and bursts as in tickDeath.
Hurt animations flash it, removal and level changes drop it, and the
player's attacks pick its parts and send the server their ids.

The in-frame fight is unchanged; its nearest-crystal search moved into
nearest_crystal_to, shared with the client.

Fixes #1530.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 04:45:47 -03:00
rdbtCVSandCodex b6af258465 Fix warden sonic boom particle delivery to connected players
Send each sonic ray point through the existing particle packet path with vanilla recipient filtering. Cover nearby and distant clients, damage, and local rendering in the LAN harness. Fixes #1490.

Co-Authored-By: Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 07:29:40 +00:00
Eshayat Al-WasiuandOpenAI GPT-6.1 Sol f4b04218f1 Implement cushion placement and synchronize placed cushions
Add cushion entities, shape-based placement and support checks, colored rendering, server tracking, sitting, destruction and save/load support. Compare placement rules against vanilla and exercise host/guest interactions.

Co-Authored-By: OpenAI GPT-6.1 Sol <[email protected]>
2026-10-26 13:29:16 +06:00
Alexander KrylovandHermes Agent 4a7173918b Roadmap: finished help-wanted items link the pull requests that did them
check still found five Help wanted items whose only links were closed
issues. Each was finished by a merged pull request, so the item now links
that pull request and waits in Now until a release carries it.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 10:12:08 +03:00
Alexander KrylovandHermes Agent 73bbbd3ee8 Tooltips: compare the hidden component id without a UTF-16 copy
TooltipDisplay.shows built a JavaString from the id on every call just to
test membership in the NBT list. A hovered tooltip asks this a dozen times
per frame, so the hover allocated a dozen short vectors per frame for a read.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:55:58 +03:00
Alexander KrylovandHermes Agent b59d9ada27 Shaders: the frame uniforms keep their buffer between frames
update rebuilt the values vector every frame for a fixed-length list: one
allocation and one free per frame for nothing. eval_custom already treats
the same vector as a buffer to truncate and extend.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:49:41 +03:00
vlesmentieandClaude Opus 5.5 fdba6b4153 Campfire timers mark their chunk for saving
CampfireBlockEntity.cookTick and cooldownTick call setChanged whenever
a timer moves (cookTick while any slot holds food, cooldownTick while
any progress is above zero). tick_campfire only marked the chunk when
an item finished, so after a save partway through cooking, an unload
saved nothing and the progress since went back to the saved value.
The chunk is now marked modified on those ticks, with no update to the
clients, who don't see the timers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 03:43:02 -03:00
Alexander KrylovandHermes Agent dd45bf7740 F3: measure each line once per frame
The overlay measured every line to place it and measured it again for its
background box, about thirty extra glyph walks per frame on a full screen.
The width that placed the line is carried to the background pass.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 09:08:18 +03:00
Alexander KrylovandHermes Agent 9dc72bf3ac Subtitles: measure each line once per frame
The max-width pass built a Text for every audible subtitle and dropped the
width it measured, then the draw loop built the same Text again to measure
it a second time. The row loop uses the number the first pass already had.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:58:50 +03:00
Alexander KrylovandHermes Agent 1e9644cc15 Signs: cut the first line on byte offsets, not through a char copy
first_line collected the whole line into a Vec<char> and then re-collected
a prefix of it, eight times per sign per frame, front and back. Every cut
point it needs is already a byte offset in the input.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:51:52 +03:00
Alexander KrylovandHermes Agent 77def1f2dd Mob models: a skipped part still draws its children
The early return skipped the child loop as well, while skipDraw only hides
the part's own cubes. A rolled armadillo skips its body and still renders
the parts below it.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 08:40:00 +03:00
levzzz c68e55438f Loot: the enchantment functions reach every table that rolls
`/loot give @s loot minecraft:chests/end_city_treasure` handed out bare gear: the
loot dispatcher in `items.rs` (the path `/loot`, bartering, raids, dispenser
tables, advancements and mob drops use) knew no `minecraft:enchant_with_levels`
or `minecraft:enchant_randomly`, so both fell through its arm untouched. Only the
chunk-generation path, which uses `horizon_world::loot`, enchanted anything —
which is why #1911 and #1437 looked fixed.

The dispatcher now runs both, as vanilla does: `EnchantWithLevelsFunction` picks
from `EnchantmentHelper.enchantItem`'s results (a book becomes one enchanted
book), `EnchantRandomlyFunction` picks one compatible enchantment at a level
between its min and max, and both resolve `options` as a tag, a list or one id,
else the whole registry. `Items` carries that registry now
(`Items::enchantments`), and `StationData` shares the same copy instead of
loading a second one.

Verified in game: six `/loot give … end_city_treasure` rolls gave 10 pieces of
gear, all 10 enchanted (unbreaking 3, mending 1 + protection 4, …), where all 10
were bare before.

Refs #1660
2026-10-26 10:34:21 +05:00
levzzzand[CC] <deepseek-v4.1-flash> 276f8d49c1 Chat: a LAN guest's line follows the host's Chat option
Review of #1932 (rdbtCVS): with `--in-frame`, opening a world to LAN and
setting Commands Only still showed a guest's ordinary chat on the host. The
hub's `HostEvent::Chat` went through `system_message`, which asks for
`receive_system_messages` — true at Commands Only — so the line passed.

`Game::player_message` shows a chat line as `ChatSource::Player`, which is
the permission `ChatListener` checks for player chat; `lan_log_event`
routes `HostEvent::Chat` there. `/say` and `/me` arrive as chat messages
too (say_command, emote_command), so they follow the same permission as they
do for the guests' own packets; command feedback such as `/list` is a system
message and keeps the system-message permission.

Reproduced on the pre-fix build: host at Commands Only, guest sends
`baseline-visible` → the host shows `<Guest9> baseline-visible`. With this
change the same line stays hidden at Commands Only and shows at Shown.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 10:03:06 +05:00
levzzzand[CC] <deepseek-v4.1-flash> bf2961834b Item cooldowns: tick them on the server client's path too
Review of #1933 (rdbtCVS): `Game::tick` returns through `tick_remote` whenever
`remote` is set — every multiplayer session and the default single player — so
the `tick_world` call that advanced the entries never ran there. A thrown
pearl's slot stayed at full shade until the server's duration-0 packet arrived
instead of fading over the second.

`tick_remote_level` advances them now (Player.tick's ItemCooldowns.tick); the
groups that end are dropped silently, as the client's plain ItemCooldowns
does.

Co-Authored-By: [CC] <deepseek-v4.1-flash> <[email protected]>
2026-10-26 09:52:00 +05:00
rdbtCVSandOpenAI Codex 03a3c48a71 Fix Peaceful removal of existing hostile mobs
Apply the vanilla Mob.checkDespawn Peaceful gate before persistence and player distance checks. Extend the mob AI parity harness with difficulty, persistence, name-tag and NoAI regressions and allowed-type controls.

Fixes #1942

Co-Authored-By: OpenAI Codex (GPT-6.1 Sol) <[email protected]>
2026-10-26 04:18:54 +00:00
rdbtCVSandOpenCode f70b39290d Merge origin/main into t3/fix-critical-hit-particles
Include main's latest mod-input changes before validation.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 04:14:52 +00:00
rdbtCVSandOpenCode 70f1b71de1 Merge origin/main into t3/fix-critical-hit-particles
Keep both critical-hit and sculk container regressions.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 04:14:11 +00:00
vlesmentieandClaude Opus 5.5 3d401e55f4 Merge origin/main into feat/campfire-cooking
Keeps both new block entities: BlockEntity::Campfire (this branch) and
BlockEntity::Skull (main), in containers.rs's enum, id() and the menu
lists in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 01:10:29 -03:00
vlesmentieandClaude Opus 5.5 5febf0d957 Campfires cook food
Horizon had no CampfireBlockEntity, so right-clicking a campfire with raw
food did nothing. Now:

- BlockEntity::Campfire keeps four slots with their cookingProgress and
  cookingTime, saved as Items, CookingTimes and CookingTotalTimes; both
  campfires share the minecraft:campfire block entity type. It is not a
  Container (only Clearable), so hoppers don't reach in.
- CampfireBlock.useItemOn: a campfire_cooking input goes into the first
  free slot (placeFood) with its recipe's time; the use is consumed
  whether or not there was room, the item is used up outside creative,
  interact_with_campfire is awarded and a block_change goes out. A client
  of a server leaves it to the server.
- cookTick (lit): each item's progress rises; a finished item's result
  drops at the block (Containers.dropItemStack), the slot empties and a
  block_change goes out. cooldownTick (unlit): progress falls by 2.
- Breaking the campfire drops its items (preRemoveSideEffects).
- The update tag (Items) reaches a server's clients, and CampfireRenderer
  draws the items on the fire.

Fixes #1988.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 00:24:10 -03:00
Alexander KrylovandHermes Agent d41aa95dbd Mob models: one Poses per mob per frame, not one per equipment layer
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 06:04:06 +03:00
Alexander KrylovandHermes Agent dcc5899692 Name tags: collect the ridden mobs once, not per tag
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 06:00:36 +03:00
Alexander KrylovandHermes Agent 51911080f0 Mob models: one skip lookup per part, not one per polygon
Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 05:58:53 +03:00
Alexander KrylovandHermes Agent 1dcb33f111 Merge main into perf/no-double-cracks-and-ticker-copy
The profiling marks that landed in main (mark_entity, mark_stage) collide with
the cached cracks scan; both are kept.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 05:55:32 +03:00
vlesmentieandClaude Opus 5.5 9572330ee4 A villager's new profession rolls its trades
Villager.setVillagerData sets offers to null when the profession changes,
and getOffers rolls the new profession's trades on the next look.
set_profession cleared the offers but left unset false, so ensure_offers
never rolled new ones: a villager that took a placed workstation after
losing its first wore the new profession but only shook its head.
It now marks the offers unset, as vanilla's null.

Fixes #1998.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 23:24:25 -03:00
rdbtCVSandCodex GPT-6.1 Sol 4cc134d08a Fix passenger walk animation in shared render state
Zero passenger walk position and speed like vanilla LivingEntityRenderer, covering local and remote riders. Extend entity model parity with moving passenger and on-foot render-state cases.

Fixes #1962

Co-Authored-By: Codex GPT-6.1 Sol <[email protected]>
2026-10-26 02:13:12 +00:00
rdbtCVSandOpenCode GPT-6.1 Sol 15d99a6856 Start bat animation clocks on server clients
Fixes #2000.

Co-Authored-By: OpenCode GPT-6.1 Sol <[email protected]>
2026-10-26 02:11:48 +00:00
rdbtCVSandOpenCode be42e67e9d Merge origin/main into t3/fix-critical-hit-particles
Keep both critical-hit and replant regressions.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 01:45:16 +00:00
rdbtCVSandOpenCode 5e2871dbfd Show critical-hit particles on server clients
Queue successful mob and player crit animations until the hub can send them to the attacker and tracking clients. Cover falling, grounded and refused attacks in both host modes. Fixes #1898.

Co-Authored-By: OpenCode (agentrouter/gpt-6.1-sol) <[email protected]>
2026-10-26 01:39:30 +00:00
Alexander KrylovandHermes Agent 258dd8561d Frames: one scan for breaking progress, and no name formatting per crack
`entity_geometry` ran `cracks()` twice per frame: once for the block-entity
pass that draws `render_breaking`, once for the overlay near the end of the
same function. Each run walked `mining` and the `destroying` map, and formatted
`block/destroy_stage_<n>` for every crack it found before looking that string up
in the atlas.

The list is computed once, at the top of the function, and the passes read it.
The ten stage names are a const table, so a crack costs one lookup and no
allocation. Nothing between the two passes touches `mining` or `destroying`,
which are only read there, so the list the second pass used to rebuild was
always identical to the first one.

No behaviour change: the same ten sprite names, and a stage above 9 still finds
no sprite and skips the crack.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-26 03:34:33 +03:00
Joansitoh 060a37dc85 Edit the remaining text fields at the caret.
Those fields only appended, so the cursor, selection and arrow keys did nothing. A sign paste strips formatting first, matching TextFieldHelper.getClipboardContents.
2026-10-26 01:29:07 +01:00
DanielGodriNetoandClaude 467413df5b fix(benchmark): wait for the post-teleport load before measuring
wait_loaded declared a scene loaded during the idle second right after a teleport, so village and mountain were measured with 0 chunks. Port the guard from tools/perf_bench.py: counts must change once (or stay full for 5 s) and resident chunks must reach 80% of the view circle.

Co-Authored-By: Claude <[email protected]>
2026-10-25 21:13:23 -03:00
socksproxandDevin 920a728989 World text parity: same-frame glyph uploads, display lines, name-tag colours
Atlas patches rasterized while building world text (display text, name
tags, sign glyphs, map items) drained into the HUD batch before
entity_geometry ran, uploading a frame late: for that frame the quads
sampled unwritten atlas cells, so text flickered or showed the wrong
glyphs whenever content changed or new text entered view.

Display text also lined up 10 px apart where vanilla's lineHeight is 9,
its background quad stopped a pixel short of vanilla's -1..width+1 fill,
and name tags used 0x7F where renderNameTag's colours are 0x20FFFFFF
(discrete) and 0x80FFFFFF (see-through). lightCoordsWithEmission floors
only the block channel at 2 and leaves the sky alone, and a discrete
name's background draws lit in the normal pass rather than unlit.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-26 00:56:00 +01:00
some-du6eandClaude Opus 5.5 97e78e0f11 horizon-prepare: find 26.3 in ATLauncher's and the CurseForge app's folders
ATLauncher saves Mojang's version JSON as is in configs/json/minecraft and
the client jar as the library net.minecraft:client:<version>, with the
libraries at their version JSON paths and the assets and Java runtimes in
Mojang's layouts. horizon-prepare now reads that layout, in the chosen
folder, the launcher above a chosen instances/<name> folder, or, with no
folder chosen, ATLauncher's default folder on each system after the
official launcher's, then the CurseForge app's Install folder. The lock's
pins gate it as before, and nothing is downloaded. The error names every
folder looked in and the launchers Horizon reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 17:22:32 -06:00
Ludovic MarieandClaude Opus 5.5 3a35e147e5 Server mods run in the game: server thread, in-frame, LAN hosts, dedicated
The game that simulates a world now starts its server mods (API 0.3,
experimental): single player's server thread, single player in-frame
(beside client mods), a LAN host either way, and the dedicated server.
Windows that are clients of a server never run them.

game/server_mods.rs: tick_server_mods at the end of the tick diffs the
players (player_joined, player_left), delivers the tick's chat and block
events in order, then calls tick. Block events come from players' breaking
and placing (the acting player is the guest during as_guest); chat from the
local chat path and the hub's new HostEvent::PlayerChat; a guest's /command
reaches its mod before vanilla's dispatcher. tell and command replies go to
their player (the window's chat or a SystemChat); broadcast goes to every
player and the log.

--dev-mod now works on the server thread, and servers check a dev mod's
file once a second from their tick. Every hook returns at once when no
server mod runs; without the mods feature they are empty stubs.

Docs: docs/mods.md (where server mods run, when hooks run, not yet),
docs/api/mods.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 18:48:13 +01:00
260dd423c9 Keep sprint as a state so a sprint-jump keeps its forward impulse
PlayerInput was rewriting the server sprint flag from the Ctrl and forward
keys. That flag is synced back onto the client, and jumpFromGround adds its
0.2 look-direction impulse only while the flag is set, so releasing Ctrl
before the jump (or double-tapping forward) produced a plain hop. Sprint
still starts and stops through PlayerCommand.

Co-Authored-By: Grok <[email protected]>

Co-authored-by: 一水合氨NH3-H2O <[email protected]>
2026-10-25 14:30:42 +00:00
384 changed files with 11662 additions and 17854 deletions

No files matched your search

-1
View File
@@ -2,7 +2,6 @@
target/
.local/
**/node_modules/
website/
benchmarks/
packs/
mods/
+1 -1
View File
@@ -2,5 +2,5 @@
# where Git for Windows' core.autocrlf would check them out with CRLF.
docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
website/src/tokens.css text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
+6 -3
View File
@@ -48,6 +48,9 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: echo "sha=$(python3 tools/parity_main.py base)" >> "$GITHUB_OUTPUT"
- name: The harnesses the merges since then can affect
id: harnesses
# A failure here is judged by the next step: the run passes when every failed harness passes again at HEAD.
continue-on-error: true
env:
PARITY_HOSTS: ''
BASE_SHA: ${{ steps.base.outputs.sha }}
@@ -55,8 +58,8 @@ jobs:
echo "Merges since ${BASE_SHA}:"
git log --first-parent --format=' %h %s' "$BASE_SHA..HEAD"
python3 tools/parity.py --changed "$BASE_SHA" --timeout 2400
- name: The merge that broke them
if: failure()
- name: A flake, or the merge that broke them
if: steps.harnesses.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
PARITY_HOSTS: ''
@@ -65,7 +68,7 @@ jobs:
rm -rf .local/parity-main-logs && cp -R .local/parity-runner/logs .local/parity-main-logs 2>/dev/null || true
python3 tools/parity_main.py bisect "$BASE_SHA" --comment
- name: Logs of the failed harnesses
if: failure()
if: steps.harnesses.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: parity-main-logs
+2 -1
View File
@@ -54,7 +54,8 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
`docs/`, `.github/workflows/`. The website (playhorizon.dev) is the private ludovic111/horizon-site: its build
reads `README.md`, `launcher/links.json`, `docs/api/`, `roadmap/roadmap.json` and `crates/horizon-theme/tokens.css` from main.
## Build and test fast
+2 -2
View File
@@ -4,8 +4,8 @@ Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, a
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
release them. Anything else (videos, Discord and other community material) lives elsewhere.
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
Generated
+9
View File
@@ -1368,6 +1368,7 @@ dependencies = [
"horizon-core",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
@@ -1426,6 +1427,14 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
dependencies = [
"ash",
"wgpu",
]
[[package]]
name = "horizon-mod-sdk"
version = "0.1.0"
+1 -1
View File
@@ -1,6 +1,6 @@
[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-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-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"]
resolver = "2"
[workspace.package]
+68 -54
View File
@@ -134,7 +134,7 @@ Better tools for mod makers: a test runner that needs no game window, templates
`cargo horizon-mod test` runs a mod's test scripts against a fake game snapshot ([#1816](https://github.com/ludovic111/horizon/issues/1816)) and, for server mods, scripted server events ([#1837](https://github.com/ludovic111/horizon/issues/1837)); the content template and the guides are merged ([#1859](https://github.com/ludovic111/horizon/issues/1859), [#1810](https://github.com/ludovic111/horizon/issues/1810), [docs/api/stability.md](docs/api/stability.md)). Left: templates for hud, server and machine mods, typed wrappers in the SDK, a frozen 0.1 compatibility mod that every runtime must load, a CI check that fails when a stable reference entry is removed without a new major version, and the SDK on crates.io once the API is stable.
More: [website/docs/test-a-mod.md](website/docs/test-a-mod.md)
More: [https://playhorizon.dev/docs/test-a-mod](https://playhorizon.dev/docs/test-a-mod)
</details>
@@ -160,6 +160,66 @@ Translatable components resolved through the selected language for items, contai
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
More: [#1933](https://github.com/ludovic111/horizon/issues/1933)
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
More: [#1932](https://github.com/ludovic111/horizon/issues/1932) · [#1841](https://github.com/ludovic111/horizon/issues/1841)
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
More: [#1897](https://github.com/ludovic111/horizon/issues/1897)
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
More: [#1884](https://github.com/ludovic111/horizon/issues/1884) · [#1931](https://github.com/ludovic111/horizon/issues/1931)
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
More: [#1892](https://github.com/ludovic111/horizon/issues/1892)
</details>
### Rendering
#### Shader packs, written again from scratch
@@ -250,7 +310,7 @@ Horizon should run well on small machines: integrated graphics, the Steam Deck,
Partly done: an opt-in 60 FPS cap with fewer workers on battery, Far Detail Reduced for integrated GPUs and animated textures on the GPU (both shipped). Left: measured targets and fixes per class of machine (memory use, worker counts, startup) on a Steam Deck, old Intel and Apple Silicon Macs and 8 GB computers, reported on the Benchmarks page.
More: [docs/benchmark.md](docs/benchmark.md) · [website/docs/performance.md](website/docs/performance.md)
More: [docs/benchmark.md](docs/benchmark.md) · [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -540,7 +600,9 @@ You can port a mod to Horizon with your own AI agent on your own computer. Horiz
<details><summary>Technical</summary>
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide ([website/docs/agents.md](website/docs/agents.md), [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
A player runs their own agent locally with `horizon-ctl` and the MCP servers ([docs/agent-control.md](docs/agent-control.md)), `cargo horizon-mod`, `horizon-port-pack` and the parity harnesses as the check. Horizon writes the guide (playhorizon.dev/docs/agents, [docs/modpacks.md](docs/modpacks.md)) and runs no agents, accounts or keys for players. The No AI switch turns all of it off.
More: [https://playhorizon.dev/docs/agents](https://playhorizon.dev/docs/agents)
</details>
@@ -876,7 +938,9 @@ Run Horizon's built-in benchmark and share the report: every new computer makes
<details><summary>Technical</summary>
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it (playhorizon.dev/docs/performance). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
More: [https://playhorizon.dev/docs/performance](https://playhorizon.dev/docs/performance)
</details>
@@ -902,56 +966,6 @@ Armour and tools in End city chests should come enchanted, as in vanilla.
</details>
#### Cooldowns drawn on items
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
<details><summary>Technical</summary>
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
</details>
#### Chat settings and team prefixes
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
<details><summary>Technical</summary>
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
</details>
#### A pointing hand over buttons
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
<details><summary>Technical</summary>
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
</details>
#### Ctrl+Backspace and scroll bars in menus
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
<details><summary>Technical</summary>
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
</details>
#### Chickens flap while they fall
Chickens should flap their wings while they fall, not stand still in the air.
<details><summary>Technical</summary>
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
</details>
## Recently shipped
- **OpenGL for old computers** (v0.2.63): Horizon now starts on old PCs whose graphics card has no Vulkan or DirectX 12 driver: it switches to OpenGL by itself, and Options > Graphics can pick OpenGL by hand. [#1917](https://github.com/ludovic111/horizon/issues/1917) · [#1920](https://github.com/ludovic111/horizon/issues/1920) · [#1936](https://github.com/ludovic111/horizon/issues/1936)
+2 -1
View File
@@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
`crates/horizon-theme/icons/pixel/*.svg` are icons from
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`).
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
+1
View File
@@ -12,6 +12,7 @@ horizon-assets = { path = "../horizon-assets" }
horizon-core = { path = "../horizon-core" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-threads = { path = "../horizon-threads" }
horizon-hdr = { path = "../horizon-hdr" }
horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
@@ -0,0 +1,89 @@
use horizon_client::{
Result,
cushion::{self, World},
};
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
use serde_json::Value;
use std::{
collections::HashMap,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
struct Cell {
outline: VoxelShape,
collision: VoxelShape,
suffocating: bool,
}
struct Fixture(HashMap<[i32; 3], Cell>);
impl World for Fixture {
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
self.0
.get(&p)?
.outline
.bounds()
.map(|b| b.move_by(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64)))
}
fn collision_shapes(&self, _: Aabb) -> Vec<VoxelShape> {
self.0
.iter()
.map(|(p, c)| {
c.collision
.moved(Vec3::new(p[0] as f64, p[1] as f64, p[2] as f64))
})
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
self.0.get(&p).is_some_and(|c| c.suffocating)
}
}
fn shape(value: &Value) -> Result<VoxelShape> {
let mut shape = VoxelShape::empty();
for b in value.as_array().unwrap() {
let f = |i: usize| b[i].as_f64().unwrap();
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(f(0), f(1), f(2)),
Vec3::new(f(3), f(4), f(5)),
))?,
ShapeOp::OR,
)?;
}
Ok(shape)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let row: Value = serde_json::from_str(&line?)?;
let mut world = Fixture(HashMap::new());
for c in row["cells"].as_array().unwrap() {
let p = std::array::from_fn(|i| c[i].as_i64().unwrap() as i32);
world.0.insert(
p,
Cell {
outline: shape(&c[3])?,
collision: shape(&c[4])?,
suffocating: c[5].as_bool().unwrap(),
},
);
}
let p = &row["pos"];
let (x, y, z) = (
p[0].as_f64().unwrap(),
p[1].as_f64().unwrap(),
p[2].as_f64().unwrap(),
);
let bounds = Aabb::new(
Vec3::new(x - 0.5, y, z - 0.5),
Vec3::new(x + 0.5, y + 0.25, z + 0.5),
);
writeln!(
out,
"{} {}",
cushion::can_place(&world, bounds),
cushion::survives(&world, bounds)
)?;
}
Ok(())
}
@@ -9,7 +9,7 @@ use horizon_client::mesher::Vertex;
use horizon_client::mobs::{
Mob, MobData, MobTypes, Part, Species, allay,
anim::{self, Poses, State},
aquatic, body_anim as b_anim, equine, guardian,
aquatic, bat, body_anim as b_anim, equine, guardian,
keyframes::AnimationState,
render, shulker, sniffer, species, tame,
};
@@ -108,6 +108,22 @@ fn entity_state(mob: &mut Mob, species: Species, case: &Value) {
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
match species {
Species::Bat => {
mob.extra.flag = flag("isResting");
mob.anim.states[0].started = case["flyAnimationState"].as_f64().map(|v| v as f32);
mob.anim.states[1].started = case["restAnimationState"].as_f64().map(|v| v as f32);
if let Some(ticks) = case["@batTicks"].as_array() {
for (i, flags) in ticks.iter().enumerate() {
mob.age = i as u32 + 1;
bat::set_resting(mob, flags.as_i64().unwrap_or(0) & 1 != 0);
if flag("@batInFrame") {
bat::tick(mob);
} else {
bat::setup_animation_states(mob);
}
}
}
}
Species::Sheep => mob.extra.anim_ticks = num("eatTick") as i32,
Species::Guardian | Species::ElderGuardian => {
mob.anim.v[guardian::TAIL] = num("tailAnimation");
@@ -377,7 +393,7 @@ pub fn main() -> horizon_client::Result<()> {
mob.extra.age = -24000;
}
mob.age = f("ageInTicks") as u32;
let state = State {
let mut state = State {
pos: f("walkAnimationPos"),
speed: f("walkAnimationSpeed"),
age: f("ageInTicks"),
@@ -389,6 +405,20 @@ pub fn main() -> horizon_client::Result<()> {
};
let mut poses = Poses::new(&model);
entity_state(&mut mob, def.species, case);
if let Some(ticks) = case["@walkTicks"].as_array() {
for speed in ticks {
mob.previous_limb_speed = mob.limb_speed;
mob.limb_speed += (speed.as_f64().unwrap() as f32 - mob.limb_speed) * 0.4;
mob.limb_position += mob.limb_speed;
}
if !case["@alive"].as_bool().unwrap() {
mob.health = 0.0;
}
let extracted = anim::state(&mob, f("@partial"));
state.pos = extracted.pos;
state.speed = extracted.speed;
}
let flag = |k: &str| case[k].as_bool().unwrap_or(false);
let num = |k: &str| case[k].as_f64().unwrap_or(0.0) as f32;
let started = |k: &str| AnimationState {
@@ -456,14 +486,6 @@ pub fn main() -> horizon_client::Result<()> {
roll_amount: num("rollAmount"),
},
),
Species::Bat => b_anim::bat(
p,
s,
flag("isResting"),
&started("flyAnimationState"),
&started("restAnimationState"),
&animations,
),
Species::Armadillo => b_anim::armadillo(
p,
s,
@@ -590,7 +612,11 @@ pub fn main() -> horizon_client::Result<()> {
}
_ => anim::setup(&mob, s, p, &animations),
}
out.push_str(&format!("{}\n", emit(&model, &mut poses, case, layer)));
let mut result = emit(&model, &mut poses, case, layer);
if case["@walkTicks"].is_array() {
result["walk"] = json!([state.pos, state.speed]);
}
out.push_str(&format!("{result}\n"));
}
fs::write(&args[2], out)?;
Ok(())
@@ -28,6 +28,7 @@ mod cloud_fixture;
mod command_parse_fixture;
mod container_screen_probe;
mod creative_search_fixture;
mod cushion_placement_probe;
mod emitter_particles_probe;
mod enchanting_fixture;
mod entity_anim_fixture;
@@ -107,6 +108,7 @@ probes! {
block_transform_fixture,
breaking_fixture,
camera_probe,
cushion_placement_probe,
cauldron_fixture,
chat_screen_fixture,
cloud_fixture,
@@ -49,6 +49,7 @@ struct Scenario {
name: String,
ticks: i32,
time: i64,
difficulty: u8,
rain: bool,
thunder: bool,
origin: [i32; 2],
@@ -85,11 +86,13 @@ fn parse(text: &str) -> Vec<Scenario> {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
s.difficulty = 2;
for kv in &f[2..] {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k {
"ticks" => s.ticks = v.parse().unwrap(),
"time" => s.time = v.parse().unwrap(),
"difficulty" => s.difficulty = v.parse().unwrap(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
@@ -954,6 +957,10 @@ pub fn main() -> horizon_client::Result<()> {
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"named" => {
m.custom_name = Some("Peaceful regression".into());
m.persistent = true;
}
"ridden" => {
m.extra.head = Some("minecraft:red_harness".into());
m.rider = Some(horizon_client::mobs::equine::Rider::default());
@@ -1090,7 +1097,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
mesher::{MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -161,7 +161,15 @@ fn scene(
.iter()
.map(|v| {
let ([x, y, z], [u, w]) = (v.pos, v.uv());
json!([x, y, z, u, w, argb(v.color), light_coords(v.light)])
json!([
x,
y,
z,
u,
w,
argb(v.color | EMISSION_BITS),
light_coords(v.light)
])
})
.collect();
quads.push(json!({"layer": layer, "sprite": sprite, "vertices": vertices}));
+74 -3
View File
@@ -58,7 +58,10 @@ enum Animate {
BubbleColumn {
drag: bool,
},
Fire,
/// `BaseFireBlock` (`SoulFireBlock.canBurn` is always true).
Fire {
soul: bool,
},
/// An active sculk sensor.
SculkSensor,
/// Potent sulfur that is not dry.
@@ -140,7 +143,8 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"bubble_column" => Animate::BubbleColumn {
drag: prop("drag").as_deref() == Some("true"),
},
"fire" | "soul_fire" => Animate::Fire,
"fire" => Animate::Fire { soul: false },
"soul_fire" => Animate::Fire { soul: true },
"dried_ghast" => Animate::DriedGhast {
waterlogged: prop("waterlogged").as_deref() == Some("true"),
},
@@ -190,6 +194,8 @@ pub struct AnimateTables {
power_transmitter: Vec<bool>,
/// `#triggers_ambient_dried_ghast_block_sounds` (soul sand, soul soil).
dried_ghast_sounds: Vec<bool>,
/// `FireBlock.canBurn`: states with ignite odds above zero (none waterlogged).
burnable: Vec<bool>,
/// Biome id -> ambient particles and probabilities (`minecraft:visual/ambient_particles`).
ambient: Vec<Vec<(T, f32)>>,
}
@@ -220,6 +226,21 @@ impl AnimateTables {
})
.collect()
};
let behaviour: serde_json::Value =
serde_json::from_slice(&std::fs::read(root.join("block-behaviours.json"))?)?;
let ignite_odds = behaviour["fire"]["igniteOdds"].as_object();
let burnable = (0..registry.len() as u32)
.map(|id| {
// FireBlock.getIgniteOdds: a waterlogged state never burns.
registry.state(BlockStateId(id)).is_some_and(|s| {
s.properties.get("waterlogged").map(String::as_str) != Some("true")
&& ignite_odds
.and_then(|m| m.get(s.name.as_str()))
.and_then(serde_json::Value::as_i64)
.is_some_and(|odds| odds > 0)
})
})
.collect();
let mut ambient = vec![Vec::new(); biomes.list.len()];
for (name, &id) in &biomes.index {
let path = root.join(format!(
@@ -248,6 +269,7 @@ impl AnimateTables {
power_provider: by_state("minecraft:enchantment_power_provider"),
power_transmitter: by_state("minecraft:enchantment_power_transmitter"),
dried_ghast_sounds: by_state("minecraft:triggers_ambient_dried_ghast_block_sounds"),
burnable,
ambient,
})
}
@@ -1103,7 +1125,7 @@ fn block_animate_tick(
}
}
}
Animate::Fire => {
Animate::Fire { soul } => {
let r = engine.random();
if next_int(r, 24) == 0 {
let (volume, pitch) = (1.0 + r.next_float(), r.next_float() * 0.7 + 0.3);
@@ -1115,6 +1137,55 @@ fn block_animate_tick(
pitch,
);
}
// BaseFireBlock.animateTick: three large smoke above the fire, or
// two at each burnable neighbour's face when nothing burnable or
// sturdy supports it (SoulFireBlock.canBurn is always true).
let can_burn = |p: [i32; 3]| {
soul || tables
.burnable
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
};
let below = [pos[0], pos[1] - 1, pos[2]];
let sturdy = ctx.facts.get(ctx.state(below)).sturdy_full & (1 << 1) != 0;
if !can_burn(below) && !sturdy {
// (neighbour, coordinate axis, face at the axis' high end): the
// west, east, north, south and above faces, in vanilla order.
for (at, axis, high) in [
([pos[0] - 1, pos[1], pos[2]], 0usize, false),
([pos[0] + 1, pos[1], pos[2]], 0, true),
([pos[0], pos[1], pos[2] - 1], 2, false),
([pos[0], pos[1], pos[2] + 1], 2, true),
([pos[0], pos[1] + 1, pos[2]], 1, true),
] {
if !can_burn(at) {
continue;
}
for _ in 0..2 {
let r = engine.random();
let d = [r.next_double(), r.next_double(), r.next_double()];
let mut p = [fx + d[0], fy + d[1], fz + d[2]];
// The face coordinate hugs the edge: `* 0.1F` (float).
p[axis] = if high {
f64::from(pos[axis]) + 1.0 - d[axis] * f64::from(0.1f32)
} else {
f64::from(pos[axis]) + d[axis] * f64::from(0.1f32)
};
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
} else {
for _ in 0..3 {
let r = engine.random();
let p = [
fx + r.next_double(),
fy + r.next_double() * 0.5 + 0.5,
fz + r.next_double(),
];
add(engine, T::LargeSmoke, p, [0.0; 3]);
}
}
}
}
}
+31
View File
@@ -323,10 +323,15 @@ const QUIET_MESHES_PER_S: f64 = 100.0;
/// happened (`tools/perf_bench.py`'s `wait_loaded`). With `status.streaming`, the sections are
/// drawn once no stage has held work for half a second: blocks changing after the load (fluids
/// settling, random ticks) only remesh in bursts. Without it, meshing has to go quiet.
///
/// Right after a teleport (and at spawn) the game reports nothing queued and steady counts for over
/// a second before it starts loading: the counts must change once, or stay full for 5 s, and the
/// resident chunks reach [`view_chunks`], before "steady" means loaded.
fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Instant, String> {
let deadline = Instant::now() + LOAD_LIMIT;
let (mut last, mut changed, mut meshing) = (None, Instant::now(), Instant::now());
let mut previous: Option<(Instant, f64)> = None;
let (mut first, mut moved, began) = (None, false, Instant::now());
while Instant::now() < deadline {
let s = control.status();
let now = Instant::now();
@@ -339,6 +344,19 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
(last, changed, previous, meshing) = (None, now, None, now);
} else {
let counts = (s["chunks"].as_u64(), s["generated_chunks"].as_u64());
let resident = counts.0.unwrap_or(0);
let min_chunks = view_chunks(s["render_distance"].as_u64().unwrap_or(0));
let first = *first.get_or_insert(counts);
moved = moved
|| counts != first
|| (min_chunks > 0
&& resident >= min_chunks
&& now - began > Duration::from_secs(5));
if min_chunks > 0 && (!moved || resident < min_chunks) {
(last, changed, previous, meshing) = (None, now, None, now);
thread::sleep(Duration::from_millis(50));
continue;
}
if last != Some(counts) {
(last, changed) = (Some(counts), now);
}
@@ -370,6 +388,12 @@ fn wait_loaded(control: &Control, near: impl Fn(&Value) -> bool) -> Result<Insta
Err(format!("not loaded after {} s", LOAD_LIMIT.as_secs()))
}
/// 80% of the view circle (pi x RD^2): the game keeps 637 columns at RD 12 and 3725 at RD 32
/// (`tools/perf_bench.py`'s `view_chunks`).
fn view_chunks(distance: u64) -> u64 {
(0.8 * std::f64::consts::PI * (distance * distance) as f64) as u64
}
/// Vanilla's yaw facing from `start` to `end`.
fn yaw(start: [f64; 3], end: [f64; 3]) -> f64 {
(-(end[0] - start[0])).atan2(end[2] - start[2]).to_degrees()
@@ -420,6 +444,13 @@ mod tests {
assert!(scenes("nether").is_none());
}
#[test]
fn view_chunks_matches_perf_bench() {
assert_eq!(view_chunks(12), 361);
assert_eq!(view_chunks(32), 2573);
assert_eq!(view_chunks(0), 0);
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
+18 -7
View File
@@ -664,7 +664,9 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
"minecraft:trial_spawner" => {
// `TrialSpawnerStateData.Packed` and `FullConfig`: every key is optional
// and defaulted by vanilla; they are kept as loaded.
// and defaulted by vanilla; they are kept as loaded. No `spawn_data` is
// added: an absent one loads as Optional.empty and is not stored, and
// an empty entity would keep the spawner from ever picking a mob.
if let Tag::Compound(c) = tag {
for (k, v) in c {
let key = k.to_string_lossy();
@@ -676,12 +678,6 @@ pub fn canonical(tag: &Tag) -> Option<Tag> {
}
}
}
if !f.out.contains_key(&"spawn_data".into()) {
f.put(
"spawn_data",
Tag::compound([("entity", Tag::Compound(Compound::new()))]),
);
}
}
"minecraft:vault" => {
// `VaultConfig.DEFAULT` (when the config is absent): the trial key and the
@@ -827,6 +823,21 @@ mod tests {
canonical(&snbt(s)).expect("known type")
}
#[test]
fn a_trial_spawner_without_spawn_data_saves_none() {
let fresh = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,normal_config:"minecraft:trial_chamber/ranged/skeleton/normal"}"#,
);
assert_eq!(fresh.get("spawn_data"), None);
let picked = canon(
r#"{id:"minecraft:trial_spawner",x:1,y:2,z:3,spawn_data:{entity:{id:"minecraft:skeleton"}}}"#,
);
assert_eq!(
picked.get("spawn_data"),
Some(&snbt(r#"{entity:{id:"minecraft:skeleton"}}"#))
);
}
#[test]
fn every_type_has_defaults() {
for id in TYPES.iter().chain(NEWEST.iter()) {
@@ -558,12 +558,11 @@ fn frac(x: f32) -> f32 {
/// `StringSplitter.splitLines(...).get(0)` for plain text: the first line,
/// wrapped at the last space that fits or mid-word when none does.
fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> String {
let chars: Vec<char> = line.chars().collect();
let mut width = 0.0;
let mut last_space = None;
for (i, &c) in chars.iter().enumerate() {
for (i, c) in line.char_indices() {
if c == '\n' {
return chars[..i].iter().collect();
return line[..i].to_owned();
}
if c == ' ' {
last_space = Some(i);
@@ -572,9 +571,11 @@ fn first_line(line: &str, max_width: f32, advance: &dyn Fn(char) -> f32) -> Stri
if width > max_width {
let end = match last_space {
Some(s) if s > 0 => s,
_ => i.max(1),
_ if i > 0 => i,
// The first char alone is too wide: take that one char.
_ => c.len_utf8(),
};
return chars[..end].iter().collect();
return line[..end].to_owned();
}
}
line.to_owned()
@@ -2098,4 +2099,13 @@ mod tests {
assert_eq!(out[1].pos, [2.0, 0.0, 0.0]);
assert_eq!(out[2].uv, [0.5, 0.5]);
}
#[test]
fn first_line_wraps_on_bytes_without_splitting_a_char() {
let advance = |c: char| if c == ' ' { 4.0 } else { 6.0 };
assert_eq!(first_line("ab cd ef", 13.0, &advance), "ab");
assert_eq!(first_line("abcdef", 13.0, &advance), "ab");
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
}
+203
View File
@@ -166,6 +166,38 @@ impl Contents {
Some(stack)
}
/// The stacks inside, in display order (the first is the next out).
pub fn items(&self) -> &[ItemStack] {
&self.items
}
/// `getNumberOfItemsToShow`: a full grid shows 11 and a `+N`, so the last
/// row keeps its fourth cell for the count.
pub fn shown_count(&self) -> usize {
let count = self.items.len();
let available: usize = if count > 12 { 11 } else { 12 };
let on_last_row = count % 4;
let empty = if on_last_row == 0 { 0 } else { 4 - on_last_row };
count.min(available.saturating_sub(empty))
}
/// The items the tooltip shows, and how many sit beyond them.
pub fn shown(&self) -> (&[ItemStack], u32) {
let n = self.shown_count();
let hidden = self.items[n..].iter().map(|s| s.count).sum();
(&self.items[..n], hidden)
}
/// The `Fraction` weight as the float the progress bar reads.
pub fn weight_fraction(&self) -> f32 {
self.weight.n as f32 / self.weight.d as f32
}
/// `Fraction.ONE`: a full bundle draws the full bar and says so.
pub fn is_full(&self) -> bool {
self.weight.n >= self.weight.d
}
/// `toImmutable`, written back into the bundle's component.
pub fn store(self, bundle: &mut ItemStack) {
bundle.data_mut().bundle_contents = self.items;
@@ -190,3 +222,174 @@ mod tests {
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
}
}
// ------------------------------------------------------------- the tooltip
/// `ClientBundleTooltip`'s geometry, relative to the tooltip box's top-left:
/// the 96 pixel column of a 4 wide slot grid and the progress bar under it.
#[derive(Clone, Debug, PartialEq)]
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.
pub slots: Vec<([f32; 2], ItemStack)>,
/// 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
/// the weight fills it (the full sprite).
pub bar: [f32; 2],
pub fill: f32,
pub full: bool,
/// `item.minecraft.bundle.empty` / `.full`, centred over the bar.
pub label: Option<&'static str>,
/// An empty bundle's description, wrapped at `width` from here.
pub empty_description: Option<[f32; 2]>,
}
/// The tooltip of a bundle's contents (`ClientBundleTooltip`), or None when
/// the stack is not a bundle. `x`/`y` are the box's top-left, `box_width` the
/// tooltip's width, which the component is centred in.
pub fn tooltip_layout(
items: &Items,
stack: &ItemStack,
x: f32,
y: f32,
box_width: f32,
) -> Option<TooltipLayout> {
let contents = Contents::of(items, stack)?;
let offset = ((box_width - 96.0) / 2.0).floor();
if contents.items().is_empty() {
// The description takes the top; the bar follows it.
let description_height = 9.0 * 2.0;
return Some(TooltipLayout {
width: 96.0,
height: description_height + 13.0 + 8.0,
slots: Vec::new(),
overflow: None,
bar: [x + offset, y + description_height + 4.0],
fill: 0.0,
full: false,
label: Some("item.minecraft.bundle.empty"),
empty_description: Some([x + offset, y]),
});
}
let (shown, hidden) = contents.shown();
// `gridSizeY` counts `slotCount()` (at most twelve), not the slots the
// overflow count leaves room for.
let rows = contents.items().len().min(12).div_ceil(4).max(1);
let grid_height = rows as f32 * 24.0;
let x_start = x + offset + 96.0;
let y_start = y + grid_height;
let mut slots = Vec::with_capacity(shown.len());
let mut overflow = None;
// Rows fill bottom-up and columns right-to-left, as vanilla draws them.
let mut slot = 0;
for row in 1..=rows {
for column in 1..=4 {
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()));
slot += 1;
}
}
}
let weight = contents.weight_fraction();
Some(TooltipLayout {
width: 96.0,
height: grid_height + 13.0 + 8.0,
slots,
overflow,
bar: [x + offset, y + grid_height + 4.0],
// `Mth.mulAndTruncate(weight, 94)`, kept within the bar.
fill: (weight * 94.0).trunc().clamp(0.0, 94.0),
full: contents.is_full(),
label: if contents.is_full() {
Some("item.minecraft.bundle.full")
} else {
None
},
empty_description: None,
})
}
#[cfg(test)]
mod tooltip_tests {
use super::*;
/// The prepared resources, when they are on this machine: the tooltip
/// asks the item registry whether a stack is a bundle.
fn items() -> Option<Items> {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).ok()?;
Items::load(&root, assets.registry, &facts).ok()
}
fn bundle_with(counts: &[(&str, u32)]) -> ItemStack {
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = counts
.iter()
.map(|(item, n)| ItemStack::new(item, *n))
.collect();
bundle
}
#[test]
fn a_grid_shows_twelve_stacks_and_counts_the_rest() {
let Some(items) = items() else { return };
let twelve: Vec<(&str, u32)> = (0..12).map(|_| ("minecraft:stone", 1)).collect();
let layout = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 96.0).unwrap();
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.
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]);
// A wider tooltip centres the 96 pixel column in it.
let centred = tooltip_layout(&items, &bundle_with(&twelve), 0.0, 0.0, 120.0).unwrap();
assert_eq!(centred.slots[0].0[0], 12.0 + 96.0 - 24.0);
// Thirteen stacks: `getNumberOfItemsToShow` leaves the last row room
// for the count, so 8 slots show and `+6` (four stones and two dirt)
// takes the first cell.
let mut counts = twelve;
counts.push(("minecraft:dirt", 2));
let layout = tooltip_layout(&items, &bundle_with(&counts), 0.0, 0.0, 96.0).unwrap();
assert_eq!(layout.slots.len(), 8);
assert_eq!(layout.overflow, Some(([96.0 - 24.0, 48.0], 6)));
}
#[test]
fn the_bar_fills_with_the_weight_and_an_empty_bundle_explains_itself() {
let Some(items) = items() else { return };
let layout = tooltip_layout(&items, &bundle_with(&[]), 4.0, 8.0, 96.0).unwrap();
assert!(layout.slots.is_empty());
assert_eq!(layout.fill, 0.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.empty"));
assert_eq!(layout.empty_description, Some([4.0, 8.0]));
assert_eq!(layout.bar, [4.0, 8.0 + 18.0 + 4.0]);
// A bundle holding one 64 stack is full: the whole bar, the full label.
let layout = tooltip_layout(
&items,
&bundle_with(&[("minecraft:stone", 64)]),
0.0,
0.0,
96.0,
)
.unwrap();
assert!(layout.full);
assert_eq!(layout.fill, 94.0);
assert_eq!(layout.label, Some("item.minecraft.bundle.full"));
// Something that is not a bundle has no component.
let dirt = ItemStack::new("minecraft:dirt", 1);
assert!(tooltip_layout(&items, &dirt, 0.0, 0.0, 96.0).is_none());
}
}
+31
View File
@@ -436,3 +436,34 @@ fn the_chat_option_filters_the_lines_both_ways() {
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
/// The in-frame LAN host's chat: a guest's line is a player message, so the
/// Chat option's player-message permission hides it at Commands Only and
/// Hidden; command feedback (`/list`, `/tp`) is a system message and stays
/// at Commands Only.
#[test]
fn a_guests_line_follows_the_player_message_permission() {
let font = font();
let shown = |visibility, source| {
let mut chat = Chat::default();
chat.set_abilities(&font, ChatAbilities::new(visibility));
chat.add_message(&font, Text::literal("line"), None, source, 0, false);
chat.messages.len()
};
for (visibility, player, system) in [
(ChatVisibility::Full, 1, 1),
(ChatVisibility::System, 0, 1),
(ChatVisibility::Hidden, 0, 0),
] {
assert_eq!(
shown(visibility, ChatSource::Player),
player,
"{visibility:?}"
);
assert_eq!(
shown(visibility, ChatSource::Server),
system,
"{visibility:?}"
);
}
}
+14
View File
@@ -24,6 +24,20 @@ pub fn block_strategy() -> Strategy {
Strategy::blocks(BLOCK_BITS.load(std::sync::atomic::Ordering::Relaxed))
}
/// Frees `columns` (`count` of them) on a short-lived thread of its own
/// when there are many: a teleport or a dimension change drops a few
/// thousand columns at once, and freeing their sections took the frame
/// 15-20 ms. A few are freed here.
pub fn free_columns<T: Send + 'static>(columns: T, count: usize) {
if count < 64 {
return;
}
// Should the thread not start, the closure drops `columns` here.
let _ = std::thread::Builder::new()
.name("Chunk freeing".into())
.spawn(move || drop(columns));
}
/// Sizes the block states' global palette for a registry of `states`.
pub fn set_block_states(states: usize) {
let bits = horizon_core::paletted::ceil_log2(states).max(16);
+5
View File
@@ -346,6 +346,11 @@ mod tests {
assert_eq!(filter_text(s, true), "abc\nde\u{80}");
assert_eq!(strip_formatting("§aRed§r §zx§"), "Red §zx§");
assert_eq!(helper_clipboard_contents("§lone\r\ntwo"), "one\ntwo");
// TextFieldHelper.getClipboardContents runs before insertText's filter.
assert_eq!(
filter_text(&helper_clipboard_contents("§aHello"), false),
"Hello"
);
}
struct Field {
@@ -275,4 +275,30 @@ mod tests {
Release::Click(Input::QuickMove)
);
}
/// A press whose release never arrives (the pointer left the window, or
/// the screen closed first) leaves `isQuickCrafting` set; the next screen
/// starts with its own state, as `AbstractContainerScreen` does, so the
/// reset the game runs on open/close makes clicks work again.
#[test]
fn a_lost_release_does_not_wedge_the_next_screen() {
let mut clicks = Clicks::default();
let slot = Target::<usize>::Slot(3);
// Pressed with a stack carried: a quick craft drag starts, and its
// release is lost.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert!(clicks.quick_crafting().is_some());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
None,
"the stuck drag swallows the next press"
);
// A new screen: fresh state, so the press is a normal click again.
clicks = Clicks::default();
assert!(clicks.quick_crafting().is_none());
assert_eq!(
clicks.press(&slot, Button::Left, true, false, false),
Some(Input::Pickup)
);
}
}
+117 -3
View File
@@ -49,6 +49,17 @@ pub struct Furnace {
/// rather than per item, and a save keeps it as the `RecipesUsed` compound.
pub recipes_used: HashMap<String, u32>,
}
/// The key `recipes_used` keeps a recipe under: its full id, as vanilla's `ResourceKey<Recipe>`
/// writes it in `RecipesUsed`. Horizon's own recipe ids are file names without the namespace, so
/// keeping both forms would write a key twice, and vanilla refuses such a furnace
/// ("Duplicate entry for key").
pub fn recipe_used_key(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// A hopper's slots: the only plain container with five.
pub const HOPPER_SLOTS: usize = 5;
@@ -83,9 +94,66 @@ pub enum BlockEntity {
Beehive(Box<crate::beehive::Beehive>),
/// `CommandBlockEntity` of the three command blocks (`crate::command_block`).
CommandBlock(Box<crate::command_block::CommandBlock>),
/// `CampfireBlockEntity` of campfires and soul campfires.
Campfire(Box<Campfire>),
/// `SkullBlockEntity` of every skull and head block.
Skull(Skull),
}
/// `CampfireBlockEntity`: four cooking slots, each with its
/// `cookingProgress` and `cookingTime` (the recipe's).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Campfire {
pub items: [Option<ItemStack>; 4],
pub progress: [i32; 4],
pub total: [i32; 4],
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
pub fn place_food(&mut self, held: &mut ItemStack, cooking_time: i32) -> bool {
let Some(slot) = self.items.iter().position(Option::is_none) else {
return false;
};
self.total[slot] = cooking_time;
self.progress[slot] = 0;
self.items[slot] = Some(ItemStack {
count: 1,
..held.clone()
});
held.count = held.count.saturating_sub(1);
true
}
/// `cookTick` (lit): each item's progress rises by one; the slots whose
/// progress reached their cooking time give up their item (the caller
/// drops its result).
pub fn cook_tick(&mut self) -> Vec<(usize, ItemStack)> {
let mut done = Vec::new();
for i in 0..4 {
if self.items[i].is_none() {
continue;
}
self.progress[i] += 1;
if self.progress[i] >= self.total[i]
&& let Some(item) = self.items[i].take()
{
done.push((i, item));
}
}
done
}
/// `cooldownTick` (unlit): the progress falls by two, no lower than 0.
/// Whether anything changed (`setChanged`).
pub fn cooldown_tick(&mut self) -> bool {
let mut changed = false;
for i in 0..4 {
if self.progress[i] > 0 {
changed = true;
self.progress[i] = (self.progress[i] - 2).clamp(0, self.total[i]);
}
}
changed
}
}
/// `SkullBlockEntity`: the player profile the head shows (kept as loaded:
/// a compound, or a string when it names a player only), the note block
/// sound and `custom_name`.
@@ -304,6 +372,9 @@ impl BlockEntity {
if name == "minecraft:brewing_stand" {
return Some(Self::Brewing(Box::new(crate::brewing::BrewingStand::new())));
}
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
let size = match name {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => 27,
n if n.ends_with("copper_chest") => 27,
@@ -341,6 +412,7 @@ impl BlockEntity {
Self::Beehive(_) => "minecraft:beehive",
Self::CommandBlock(_) => "minecraft:command_block",
Self::Skull(_) => "minecraft:skull",
Self::Campfire(_) => "minecraft:campfire",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -362,6 +434,7 @@ impl BlockEntity {
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
Self::Crafter(c) => c.items.iter().flatten().cloned().collect(),
Self::Campfire(c) => c.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -382,7 +455,9 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &[],
| Self::CommandBlock(_)
// Not a Container (only Clearable): hoppers don't reach in.
| Self::Campfire(_) => &[],
}
}
pub fn slots_mut(&mut self) -> &mut [Option<ItemStack>] {
@@ -403,7 +478,8 @@ impl BlockEntity {
| Self::TrialSpawner(_)
| Self::Vault(_)
| Self::Beehive(_)
| Self::CommandBlock(_) => &mut [],
| Self::CommandBlock(_)
| Self::Campfire(_) => &mut [],
}
}
}
@@ -638,7 +714,10 @@ impl Furnace {
Some(out) => out.count += recipe.result.count,
None => self.items[2] = Some(recipe.result.clone()),
}
*self.recipes_used.entry(recipe.id.clone()).or_insert(0) += 1;
*self
.recipes_used
.entry(recipe_used_key(&recipe.id))
.or_insert(0) += 1;
if let Some(i) = self.items[0].as_mut() {
i.count -= 1;
if i.count == 0 {
@@ -692,6 +771,41 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
let mut beef = ItemStack::new("minecraft:beef", 2);
// placeFood: the first free slot, one item, its recipe's time.
assert!(c.place_food(&mut beef, 600));
assert_eq!(beef.count, 1);
assert_eq!(c.items[0].as_ref().map(|s| s.count), Some(1));
assert_eq!((c.progress[0], c.total[0]), (0, 600));
let mut fish = ItemStack::new("minecraft:cod", 1);
assert!(c.place_food(&mut fish, 3));
assert_eq!(c.items[1].as_ref().map(|s| &*s.item), Some("minecraft:cod"));
// cookTick: the cod is done on its third tick, the beef keeps cooking.
assert!(c.cook_tick().is_empty());
assert!(c.cook_tick().is_empty());
let done = c.cook_tick();
assert_eq!(done.len(), 1);
assert_eq!((done[0].0, &*done[0].1.item), (1, "minecraft:cod"));
assert!(c.items[1].is_none());
assert_eq!(c.progress[0], 3);
// cooldownTick: two a tick, never below zero.
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 1);
assert!(c.cooldown_tick());
assert_eq!(c.progress[0], 0);
// Full: four slots, no fifth.
for _ in 0..3 {
let mut more = ItemStack::new("minecraft:potato", 1);
assert!(c.place_food(&mut more, 600));
}
let mut fifth = ItemStack::new("minecraft:potato", 1);
assert!(!c.place_food(&mut fifth, 600));
assert_eq!(fifth.count, 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+2
View File
@@ -1612,7 +1612,9 @@ impl Recipes {
}
/// The `experience` of the cooking recipe with this file name, and 0 for one this data
/// pack does not have (a save from another pack can name it).
/// `id` with or without the `minecraft:` namespace (a furnace keeps the full id).
pub fn cooking_experience(&self, id: &str) -> f32 {
let id = id.strip_prefix("minecraft:").unwrap_or(id);
self.cooking
.iter()
.find(|c| c.id == id)
+40 -15
View File
@@ -1,7 +1,7 @@
//! Creative inventory (CreativeModeInventoryScreen): vanilla's tabs and their
//! contents as built by the game's own display generators
//! (`creative-inventory.json`), a 9x5 scrolling grid and the search tab.
use crate::{Result, items::ItemStack};
use crate::{Result, items::ItemStack, ui::EditBox};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -35,10 +35,8 @@ pub struct Creative {
pub scroll: f32,
/// The scrollbar is held (scrolling): dragging moves it.
pub scrolling: bool,
pub search: String,
/// When the search box took the focus (`EditBox.setFocused`): its
/// cursor blinks from then.
pub search_focused: std::time::Instant,
/// CreativeModeInventoryScreen's search box: an unbordered `EditBox` of 50.
pub search: EditBox,
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
@@ -284,8 +282,13 @@ impl Creative {
selected: 0,
scroll: 0.0,
scrolling: false,
search: String::new(),
search_focused: std::time::Instant::now(),
search: {
let mut search = EditBox::new(0.0, 0.0, 80.0, 9.0);
search.bordered = false;
search.max_length = 50;
search.text_color = u32::MAX;
search
},
search_items,
search_index,
}
@@ -306,12 +309,13 @@ impl Creative {
/// selectTab: a different tab empties the search box; the scrollbar goes to the top.
pub fn select(&mut self, tab: usize) {
if tab != self.selected {
self.search.clear();
self.search.set_value("");
}
self.selected = tab;
self.scroll = 0.0;
if self.tab().search {
self.search_focused = std::time::Instant::now();
self.search.focused = true;
self.search.reset_blink();
}
}
/// The search tab's stacks.
@@ -385,7 +389,7 @@ impl Creative {
let tab = self.tab();
if tab.search {
return self
.search_results(&self.search, names)
.search_results(&self.search.value, names)
.into_iter()
.map(|i| self.search_items[i].clone())
.collect();
@@ -754,13 +758,13 @@ mod tests {
(1, false, true),
(2, true, false),
] {
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(selected);
assert_eq!(c.selected, selected);
assert_eq!(c.tab().inventory, inventory);
assert_eq!(c.tab().search, search);
assert!(c.search.is_empty());
assert!(c.search.value.is_empty());
assert_eq!(c.scroll, 0.0);
}
}
@@ -780,12 +784,12 @@ mod tests {
assert_eq!(c.tabs[0].items[0].item.as_ref(), "minecraft:anvil");
let mut c = c;
c.select(1);
c.search = "anvil".into();
c.search.set_value("anvil");
c.scroll = 0.5;
c.select(1);
assert_eq!((c.search.as_str(), c.scroll), ("anvil", 0.0));
assert_eq!((c.search.value.as_str(), c.scroll), ("anvil", 0.0));
c.select(0);
assert_eq!(c.search, "");
assert_eq!(c.search.value, "");
}
#[test]
@@ -899,4 +903,25 @@ mod tests {
c.scroll = 1.0;
assert_eq!(c.scroller_offset(), 95.0);
}
#[test]
fn search_edits_at_its_cursor() {
use winit::keyboard::KeyCode;
let mut c = creative(0);
c.select(1);
c.search.insert_text("hello world");
for _ in 0..5 {
assert!(c.search.key(KeyCode::ArrowLeft, false, false));
}
c.search.insert_text("X");
assert_eq!(c.search.value, "hello Xworld");
c.search.key(KeyCode::Home, false, true);
c.search.insert_text("Y");
assert_eq!(c.search.value, "Yworld");
// Ctrl+Left jumps a word; one step right and Backspace deletes before the cursor.
c.search.key(KeyCode::ArrowLeft, true, false);
c.search.key(KeyCode::ArrowRight, false, false);
c.search.key(KeyCode::Backspace, false, false);
assert_eq!(c.search.value, "world");
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Cushion placement and support checks (`Cushion.canBePlacedAt`/`wouldSurviveAt`).
use horizon_core::geometry::{Aabb, ShapeOp, Vec3, VoxelShape};
pub trait World {
fn outline_bounds(&self, pos: [i32; 3]) -> Option<Aabb>;
fn collision_shapes(&self, bounds: Aabb) -> Vec<VoxelShape>;
fn suffocating(&self, pos: [i32; 3]) -> bool;
}
pub fn blocks_in(a: Aabb) -> impl Iterator<Item = [i32; 3]> {
let (lo, hi) = (a.min_position(), a.max_position());
let (x0, y0, z0) = (
lo.x.floor() as i32,
lo.y.floor() as i32,
lo.z.floor() as i32,
);
let (x1, y1, z1) = (
hi.x.floor() as i32,
hi.y.floor() as i32,
hi.z.floor() as i32,
);
(y0..=y1).flat_map(move |y| (z0..=z1).flat_map(move |z| (x0..=x1).map(move |x| [x, y, z])))
}
pub fn survives(world: &impl World, bounds: Aabb) -> bool {
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let anchor = Aabb::new(
Vec3::new(lo.x, lo.y - 0.015625, lo.z),
Vec3::new(hi.x.next_down(), lo.y, hi.z.next_down()),
);
// The extra eighth block expands the scan, not the intersection test.
let supported = blocks_in(anchor.expand_towards(Vec3::new(0.0, -0.125, 0.0))).any(|p| {
world
.outline_bounds(p)
.is_some_and(|shape| shape.intersects(anchor))
});
supported && !blocks_in(bounds.next_deflated()).all(|p| world.suffocating(p))
}
pub fn can_place(world: &impl World, bounds: Aabb) -> bool {
if !survives(world, bounds) {
return false;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let slab = Aabb::new(lo, Vec3::new(hi.x, lo.y + 0.015625, hi.z)).next_deflated();
let Ok(mut remaining) = VoxelShape::from_box(slab) else {
return false;
};
for shape in world.collision_shapes(slab) {
let Ok(next) = remaining.join(&shape, ShapeOp::ONLY_FIRST) else {
return false;
};
remaining = next;
if remaining.is_empty() {
return false;
}
}
true
}
+4 -5
View File
@@ -221,18 +221,17 @@ pub fn scale(
pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec<GuiDraw> {
let mut out = vec![GuiDraw::Push(vec![PoseOp::Scale(scale, scale)])];
for (lines, align_left) in [(columns[0], true), (columns[1], false)] {
let placed: Vec<(&String, i32, i32)> = lines
let placed: Vec<(&String, i32, i32, i32)> = lines
.iter()
.enumerate()
.filter(|(_, l)| !l.is_empty())
.map(|(i, l)| {
let w = font.width(l) as i32;
let left = if align_left { 2 } else { width - 2 - w };
(l, left, 2 + LINE_HEIGHT * i as i32)
(l, left, 2 + LINE_HEIGHT * i as i32, w)
})
.collect();
for (line, left, top) in &placed {
let w = font.width(line) as i32;
for (_, left, top, w) in &placed {
out.push(fill(
left - 1,
top - 1,
@@ -241,7 +240,7 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
BACKGROUND,
));
}
for (line, left, top) in placed {
for (line, left, top, _) in placed {
out.push(GuiDraw::Text {
text: Text::literal(line.clone()),
x: left,
+51
View File
@@ -591,6 +591,57 @@ impl Painting {
)
}
}
/// The `DyeColor` ordinal of a colour name (`DyeColor.byName`), white when
/// the name is unknown.
pub fn dye_index(name: &str) -> u8 {
crate::mobs::render::DYE_NAMES
.iter()
.position(|n| *n == name)
.unwrap_or(0) as u8
}
/// A cushion (`Cushion`, a `BlockAttachedEntity`): a 1 x 0.25 block pillow
/// that seats a player. It sits on the top face of a block, at the clicked
/// height, and is coloured by its `DyeColor`.
#[derive(Clone, Debug)]
pub struct Cushion {
pub id: u32,
/// `Entity.position`: the bottom centre of the cushion.
pub position: Vec3,
/// `BlockAttachedEntity.pos` (the saved `block_pos`).
pub pos: [i32; 3],
pub yaw: f32,
/// `DyeColor` ordinal: white 0, orange 1, ..., black 15.
pub color: u8,
/// BlockAttachedEntity.ticksSinceLastCheck.
pub check_interval: u32,
}
impl Cushion {
/// `Entity.makeBoundingBox`: x and z half a block out, y to y + 0.25.
pub fn bounds(&self) -> ([f64; 3], [f64; 3]) {
let p = self.position;
(
[p.x - 0.5, p.y, p.z - 0.5],
[p.x + 0.5, p.y + 0.25, p.z + 0.5],
)
}
/// The `DyeColor` name of the cushion.
pub fn color_name(&self) -> &'static str {
crate::mobs::render::DYE_NAMES[usize::from(self.color.min(15))]
}
/// `Cushion.getCushionItemStackWithData`: the matching coloured item.
pub fn item(&self) -> String {
format!("minecraft:{}_cushion", self.color_name())
}
/// `Cushion.interact`'s seat: the vehicle's PASSENGER attachment
/// `(0, 0.25, 0)` less the player's `(0, 0.6, 0)`.
pub fn seat(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y - 0.35, self.position.z)
}
/// `Entity.getDismountLocationForPassenger`: on top of the cushion.
pub fn dismount(&self) -> Vec3 {
Vec3::new(self.position.x, self.position.y + 0.25, self.position.z)
}
}
/// An item frame or glow item frame (`ItemFrame`).
#[derive(Clone, Debug)]
pub struct ItemFrame {
+74 -1
View File
@@ -6,7 +6,7 @@
//! survive a Horizon save.
use crate::{
arrows::Pickup,
entities::{ArmorStand, ExperienceOrb, ItemEntity, ItemFrame, Painting},
entities::{ArmorStand, Cushion, ExperienceOrb, ItemEntity, ItemFrame, Painting},
mobs::{Mob, MobType, Who},
projectiles::{Projectile, ProjectileKind},
save::{
@@ -1313,6 +1313,79 @@ pub fn painting_from_nbt(tag: &Tag) -> Option<([i32; 3], u8, String)> {
let facing = (number(tag.get("facing")).unwrap_or(0) & 3) as u8;
Some((pos, facing, text(tag.get("variant"))?))
}
/// Cushion.addAdditionalSaveData: the block it sits on and its colour.
pub fn cushion_to_nbt(c: &Cushion, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: "minecraft:cushion",
uuid,
position: c.position,
motion: Vec3::ZERO,
rotation: [c.yaw, 0.0],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put(&mut tag, "block_pos", Tag::IntArray(c.pos.to_vec()));
put(&mut tag, "color", string(c.color_name()));
tag
}
/// The position, block position, yaw and colour of a saved cushion.
pub fn cushion_from_nbt(tag: &Tag, id: u32) -> Option<Cushion> {
let position = position(tag)?;
let pos = match tag.get("block_pos") {
Some(Tag::IntArray(v)) if v.len() == 3 => [v[0], v[1], v[2]],
_ => [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
],
};
let yaw = match tag.get("Rotation") {
Some(Tag::List(l)) => l
.first()
.and_then(|v| crate::save::float(Some(v)))
.unwrap_or(0.0) as f32,
_ => 0.0,
};
let color =
crate::entities::dye_index(&text(tag.get("color")).unwrap_or_else(|| "white".into()));
Some(Cushion {
id,
position,
pos,
yaw,
color,
check_interval: 0,
})
}
#[cfg(test)]
mod cushion_tests {
use super::*;
#[test]
fn cushion_save_preserves_color_anchor_rotation_and_unknown_data() {
let cushion = Cushion {
id: 7,
position: Vec3::new(-1.5, 64.5, 3.5),
pos: [-2, 64, 3],
yaw: 270.0,
color: 14,
check_interval: 55,
};
let raw = Tag::compound([("CustomName", string("Seat"))]);
let tag = cushion_to_nbt(&cushion, [1, 2, 3, 4], Some(&raw));
assert_eq!(text(tag.get("CustomName")).as_deref(), Some("Seat"));
let loaded = cushion_from_nbt(&tag, 8).unwrap();
assert_eq!(loaded.id, 8);
assert_eq!(loaded.position, cushion.position);
assert_eq!(loaded.pos, cushion.pos);
assert_eq!(loaded.yaw, cushion.yaw);
assert_eq!(loaded.color_name(), "red");
assert_eq!(loaded.check_interval, 0);
assert_eq!(loaded.bounds(), cushion.bounds());
}
}
/// ItemFrame.addAdditionalSaveData.
pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = f.bounds();
+25
View File
@@ -1201,6 +1201,31 @@ mod tests {
assert!(!font.take_patches().is_empty());
}
/// A renderer rebuilt over the same font (the loading screen's handoff)
/// makes its atlas texture from the textures alone, so the glyphs it had
/// rasterized into the previous texture must be placed into the new one.
#[test]
fn a_rebuilt_atlas_gets_its_glyphs_again() {
let sheet = png(16, 8, |x, _| x < 2 || (8..14).contains(&x));
let default = br#"{"providers":[{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["ab"]}]}"#;
let root = root(
"rebuild",
&[
("assets/minecraft/font/default.json", default),
("assets/minecraft/textures/font/a.png", &sheet),
],
);
let font = load(&root);
let _ = std::fs::remove_dir_all(&root);
let first = font.glyph('a');
assert_eq!(font.take_patches().len(), 1);
font.reset_cache();
// Nothing is left over: drawing the character again places its pixels.
assert!(font.take_patches().is_empty());
assert_eq!(font.glyph('a').advance, first.advance);
assert_eq!(font.take_patches().len(), 1);
}
#[test]
fn provider_filters_follow_the_font_options() {
let sheet = png(8, 8, |x, _| x < 4);
+160 -42
View File
@@ -18,6 +18,7 @@ mod boats;
mod books;
mod bossbars;
mod breezes;
mod campfire;
mod cartography;
mod chat;
mod client_ambient;
@@ -33,6 +34,7 @@ mod copper_golems;
mod creaking;
mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod debug_keys;
mod decorated_pot;
mod dedicated;
@@ -80,6 +82,7 @@ mod lan_mobs;
mod lan_mount_menu;
mod lan_particles;
mod lan_pvp;
mod lan_quiet;
mod lan_screen;
mod lan_stations;
mod lan_trading;
@@ -147,6 +150,7 @@ mod scoreboard;
mod screens;
mod sculk;
mod sculk_fixture;
mod server_mods;
mod server_packs;
mod server_travel;
mod shader_packs;
@@ -1429,6 +1433,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: Vec<horizon_client::entities::ArmorStand>,
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
others: Vec<commands::OtherEntity>,
armor_stand_assets: armor_stands::ArmorStandAssets,
@@ -1444,6 +1450,8 @@ struct Game<'a> {
boat_models: boats::BoatModels,
/// EndCrystalModel's baked layer.
crystal_model: Option<horizon_client::mobs::Part>,
/// CushionModel's baked layer.
cushion_model: Option<horizon_client::mobs::Part>,
carts: Vec<minecarts::Minecart>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
@@ -1580,6 +1588,8 @@ struct Game<'a> {
/// position) for the guests.
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)>,
/// 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)
@@ -2785,6 +2795,9 @@ impl Game<'_> {
pos,
data,
} => self.consume_teleport_particles([pos.x, pos.y, pos.z], data),
LevelEvent::LevelEvent { id: 2003, pos, .. } => {
self.eye_break_particles([pos.x, pos.y, pos.z]);
}
LevelEvent::LevelEvent {
id: 2013,
pos,
@@ -3336,6 +3349,7 @@ impl Game<'_> {
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
// ServerPlayerGameMode.destroyBlock.
let state = self.world.block(pos[0], pos[1], pos[2]);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
// Block.playerWillDestroy: mining their gold angers all nearby piglins.
@@ -3367,6 +3381,9 @@ impl Game<'_> {
creative,
)
});
if removed {
self.mod_block_event(pos, broken, false);
}
if removed && !creative {
let held = self.held().cloned();
self.mine_block_with_held(&facts.name, facts.hardness);
@@ -3475,6 +3492,7 @@ impl Game<'_> {
fn interact_entity(&mut self) -> Result<bool> {
Ok(self.interact_frame()
|| self.interact_armor_stand()
|| self.interact_cushion()
|| self.interact_boat()
|| self.interact_cart()
|| self.interact_mob()?
@@ -3661,6 +3679,7 @@ impl Game<'_> {
|| self.place_painting(hit, &stack)
|| self.place_item_frame(hit, &stack)
|| self.place_armor_stand(hit, &stack)
|| self.place_cushion(hit, &stack)
|| self.place_end_crystal(hit, &stack))
{
return Ok(true);
@@ -3709,6 +3728,7 @@ impl Game<'_> {
return Ok(true);
};
let target = [pos.x, pos.y, pos.z];
self.mod_block_event(target, state, true);
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
let key = (target[0], target[1], target[2]);
self.world.block_entities.insert(key, entity);
@@ -4242,6 +4262,7 @@ impl Game<'_> {
self.update_totem_ready();
self.tick_command_extras();
self.tick_mods();
self.tick_server_mods();
let camera_eye = self.camera_eye_height();
let stick = self.pad_move();
if self.riding.is_some() && self.player.is_some() {
@@ -4544,6 +4565,7 @@ impl Game<'_> {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
horizon_client::spans::time("tick_item_frames", || self.tick_item_frames());
horizon_client::spans::time("tick_armor_stands", || self.tick_armor_stands());
horizon_client::spans::time("tick_cushions", || self.tick_cushions());
horizon_client::spans::time("tick_boats", || self.tick_boats());
horizon_client::spans::time("tick_carts", || self.tick_carts());
horizon_client::spans::time("tick_thunder", || self.tick_thunder());
@@ -4752,7 +4774,10 @@ impl Game<'_> {
if capture && !self.focused {
return;
}
if !capture {
if capture {
// The inventory's screens are behind us: no drag survives them.
self.reset_inventory_clicks();
} else {
self.stop_mining();
}
// The real pointer takes over from an agent's.
@@ -5278,7 +5303,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -5548,7 +5574,8 @@ impl Game<'_> {
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::Skull(_),
| BlockEntity::Skull(_)
| BlockEntity::Campfire(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -5591,7 +5618,18 @@ impl Game<'_> {
self.draw_inventory_effects(batch, (ox, oy), 176);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen; Horizon keeps them on the game, so they are
/// dropped when a screen closes. A press whose release never reaches the
/// screen (the pointer left the window, or the screen closed first) would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks = Default::default();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
self.reset_inventory_clicks();
self.close_station();
self.mount_closed();
self.recipe_ui.reset_screen();
@@ -5687,8 +5725,9 @@ impl Game<'_> {
if self.creative_screen.tab().search {
// CreativeModeInventoryScreen.slotClicked: moveCursorToEnd, then
// setHighlightPos(0), so typing replaces the whole query.
let search = &self.creative_screen.search;
self.text_selection.select(("creative.search", 0), search);
let search = &mut self.creative_screen.search;
search.move_cursor_to_end(false);
search.highlight = 0;
}
let items = self.creative_screen.items(&self.pack.names);
let first = self.creative_screen.first_row(&self.pack.names);
@@ -6330,6 +6369,13 @@ impl Game<'_> {
false,
);
if outside {
// AbstractRecipeBookScreen.mouseClicked: the book takes the click
// before the outside test, and its tabs sit left of the strip
// hasClickedOutside counts as the book.
if self.recipe_book_click(right) {
self.inv_clicks.skip_release();
return;
}
// slotId -999: an empty hand throws nothing; a carried stack
// starts a drag whose release drops it.
match input {
@@ -6895,6 +6941,9 @@ impl Game<'_> {
self.prepare_entity_culling(camera);
self.prepare_trims();
self.mark_entity(out.len(), "minecraft:entity_flame");
// One scan for both passes that draw them (block entities below and the
// overlay near the end of this function).
let cracks = self.cracks(camera);
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
self.yaw + 180.0
} else {
@@ -6991,6 +7040,9 @@ impl Game<'_> {
// `VaultRenderer`.
self.mark_entity(out.len(), "");
self.vault_geometry(camera, alpha, &mut out);
// `CampfireRenderer`.
self.mark_entity(out.len(), "");
self.campfire_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
// Mobs and their projectiles (a span each).
@@ -7120,7 +7172,7 @@ impl Game<'_> {
.render(&scene, visible, &mut out);
}
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
for &(pos, stage) in &cracks {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.res
.block_entity_render()
@@ -7233,6 +7285,7 @@ impl Game<'_> {
self.frame_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:armor_stand");
self.armor_stand_geometry(camera, alpha, &mut out);
self.cushion_geometry(camera, &mut out);
self.mark_entity(out.len(), "minecraft:oak_boat");
self.boat_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:minecart");
@@ -7263,7 +7316,7 @@ impl Game<'_> {
);
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
self.mark_stage(out.len(), "DESTROY");
for (pos, stage) in self.cracks(camera) {
for &(pos, stage) in &cracks {
let o = (glam::DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- camera)
.as_vec3();
@@ -7311,6 +7364,20 @@ impl Game<'_> {
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> {
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -7336,11 +7403,8 @@ impl Game<'_> {
d.length_squared() <= 1024.0
})
.filter_map(|(pos, stage)| {
let r = self
.gui
.atlas
.sprites
.get(&format!("block/destroy_stage_{stage}"))?;
let name = DESTROY_STAGES.get(stage as usize)?;
let r = self.gui.atlas.sprites.get(*name)?;
Some((pos, [r.u0, r.v0]))
})
.collect()
@@ -7518,6 +7582,7 @@ impl Game<'_> {
.map_or(0.0, |i| i.attack_damage);
if self.attack_frame()
|| self.attack_armor_stand()
|| self.attack_cushion()
|| self.attack_boat(hand_damage)
|| self.attack_cart(hand_damage)
|| self.attack_dragon()
@@ -7605,10 +7670,9 @@ impl Game<'_> {
self.stats
.custom("damage_dealt", (actual * 10.0).round() as i32);
if crit {
self.particles.tracking_emitter(
horizon_client::particles::ParticleOptions::simple(
horizon_client::particles::ParticleType::Crit,
),
self.critical_hit(
self.lan_acting,
self.mobs[index].id as i32,
feet,
width,
height,
@@ -8263,6 +8327,7 @@ impl Game<'_> {
self.boats.clear();
self.carts.clear();
self.armor_stands.clear();
self.cushions.clear();
self.item_frames.clear();
self.paintings.clear();
self.others.clear();
@@ -8681,6 +8746,13 @@ impl Game<'_> {
{
return Ok(UseResult::Success { swing: true });
}
if allow_block_actions
&& let Some(hit) = self.pick()
&& let Some(placed) = self.use_on_campfire(hit.pos)?
{
// SUCCESS_SERVER swings; a full campfire's CONSUME does not.
return Ok(UseResult::Success { swing: placed });
}
// BucketItem has no useOn: a guest's UseItemOn leaves it to UseItem.
let bucket = horizon_client::items::bucket_contents(&stack.item);
if matches!(self.guest_use, Some(GuestUse::OnBlock(_)))
@@ -9798,20 +9870,16 @@ impl Game<'_> {
);
}
if tab.search {
// The search box: an unbordered EditBox of 80 at (82, 6), white.
let search = &self.creative_screen.search;
horizon_client::ui::append_field(
batch,
atlas,
search,
(ox + 82.0, oy + 6.0, 80.0),
(u32::MAX, true),
clipboard::CREATIVE_SEARCH_CHARS,
self.text_selection
.covers(clipboard::CREATIVE_SEARCH, search),
true,
self.creative_screen.search_focused.elapsed().as_millis(),
);
// CreativeModeInventoryScreen: an unbordered EditBox of 50 at (82, 6).
let search = &mut self.creative_screen.search;
search.x = ox + 82.0;
search.y = oy + 6.0;
search.width = 80.0;
search.bordered = false;
search.focused = true;
search.text_color = u32::MAX;
search.max_length = clipboard::CREATIVE_SEARCH_CHARS;
search.draw(batch, atlas, None);
}
let mut hovered: Option<ItemStack> = None;
if tab.inventory {
@@ -9919,8 +9987,7 @@ impl Game<'_> {
} else if self.carried.is_none()
&& let Some(item) = hovered
{
let lines = self.stack_tooltip(&item);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &item, self.cursor.0, self.cursor.1);
}
self.draw_inventory_effects(batch, (ox, oy), Creative::WIDTH as i32);
}
@@ -9973,6 +10040,9 @@ impl Game<'_> {
}
return;
}
if !right && self.creative_search_click(ox, oy) {
return;
}
// Holding the scrollbar scrolls as the mouse moves (mouseDragged).
if !right
&& self
@@ -9985,6 +10055,24 @@ impl Game<'_> {
self.inventory_press(right);
}
}
/// A click on the search tab's box (`EditBox.onClick`).
fn creative_search_click(&mut self, ox: f32, oy: f32) -> bool {
if !self.creative_screen.tab().search {
return false;
}
let (x, y, w, h) = (ox + 82.0, oy + 6.0, 80.0, 9.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let search = &mut self.creative_screen.search;
search.x = x;
search.y = y;
search.width = w;
search.click(&self.gui.atlas, mx, shift, false);
true
}
/// Copy the live player and world state into the `Level` that gets saved.
pub(super) fn sync_level(&mut self) {
// Level.write saves the game rules (game_rules.dat) with the rest.
@@ -10303,8 +10391,6 @@ impl Game<'_> {
Vec::new()
};
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
// Map textures changed since the last drawn frame (game/maps.rs).
hud.patches.append(&mut self.map_patches);
// LevelLoadingScreen (Reason.OTHER) draws the title's panorama.
let title = self.title.is_some()
|| self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
@@ -10406,6 +10492,10 @@ impl Game<'_> {
} else {
self.shader_pack_inputs(camera, alpha as f32)
};
// Map textures and font glyphs changed while building this frame's
// geometry (game/maps.rs, world text) upload with this batch; draining
// map_patches any earlier leaves them a frame behind.
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let frame = Frame {
weather: &weather,
@@ -11021,11 +11111,7 @@ impl Game<'_> {
bytes += size;
}
}
for c in unloaded {
for sy in 0..(HEIGHT / 16) {
renderer.remove((c.x, sy, c.z));
}
}
renderer.remove_columns(unloaded.iter().map(|c| (c.x, c.z)));
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
lod.set_near_loading(self.streaming.busy());
@@ -11632,8 +11718,17 @@ impl ApplicationHandler for Game<'_> {
// The hovered slot took the key (`ignoreTextInput`).
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if event.physical_key == PhysicalKey::Code(KeyCode::Backspace) {
self.creative_search_backspace(self.has_control_down());
} else if let PhysicalKey::Code(key) = event.physical_key {
// EditBox.keyPressed: arrows, Home, End, Delete and Backspace.
let before = self.creative_screen.search.value.clone();
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
if self.creative_screen.search.key(key, ctrl, shift) {
if self.creative_screen.search.value != before {
self.creative_screen.scroll = 0.0;
}
} else {
self.creative_search_edit(event.text.as_deref());
}
} else {
self.creative_search_edit(event.text.as_deref());
}
@@ -11654,6 +11749,9 @@ impl ApplicationHandler for Game<'_> {
// The anvil rename box takes every key but Escape (AnvilScreen.keyPressed).
if let Some(shortcut) = self.edit_shortcut(&event) {
self.station_shortcut(shortcut);
} else if let PhysicalKey::Code(key) = event.physical_key
&& self.station_key(key)
{
} else {
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
self.station_text(event.text.as_deref(), backspace);
@@ -12270,6 +12368,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Vec::new(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
waypoint_styles: prepared.waypoint_styles,
@@ -12279,6 +12378,7 @@ impl<'a> Game<'a> {
boats: Vec::new(),
boat_models: prepared.boat_models,
crystal_model: prepared.crystal_model,
cushion_model: prepared.cushion_model,
carts: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
@@ -12365,6 +12465,7 @@ impl<'a> Game<'a> {
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
@@ -12416,6 +12517,7 @@ struct GameCpuResources {
boat_models: boats::BoatModels,
cart_models: minecarts::CartModels,
crystal_model: Option<horizon_client::mobs::Part>,
cushion_model: Option<horizon_client::mobs::Part>,
dragon_model: Option<horizon_client::mobs::Part>,
arrow_model: Option<horizon_client::mobs::Part>,
trident_model: Option<horizon_client::mobs::Part>,
@@ -12436,6 +12538,7 @@ impl GameCpuResources {
boat_models: boats::BoatModels::load(document.as_ref()),
cart_models: minecarts::CartModels::load(document.as_ref()),
crystal_model: model("minecraft:end_crystal#main"),
cushion_model: model("minecraft:cushion#main"),
dragon_model: model("minecraft:ender_dragon#main"),
arrow_model: model("minecraft:arrow#main"),
trident_model: model("minecraft:trident#main"),
@@ -12995,6 +13098,14 @@ fn build_renderer(
) -> Result<Renderer> {
let assets = &pack.render;
let atlas = &gui.atlas;
// The new renderer's atlas texture starts from `HudAtlas.pixels`, which
// holds the textures but never a glyph rasterized at run time. The font's
// cache still answers with the rects it drew into the previous renderer's
// texture, so forget them: the glyphs that are drawn again are placed into
// this texture (as `ui_theme` does when its atlas changes). Without this,
// Chinese and Japanese menu labels go blank when the loading screen hands
// over to the game.
atlas.font.reset_cache();
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -13041,6 +13152,12 @@ fn loading_renderer(
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
None => (EMPTY_HUD.as_slice(), 1, 1),
};
// A glyph rasterized into an earlier texture is not in these pixels; the
// new texture gets it again when the character is drawn (see
// `build_renderer`).
if let Some(atlas) = atlas {
atlas.font.reset_cache();
}
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -13777,7 +13894,7 @@ mod startup_cpu_tests {
r#"{
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
"minecraft:armor_stand#main": {}
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
}"#,
)
.unwrap();
@@ -13797,6 +13914,7 @@ mod startup_cpu_tests {
.unwrap();
std::fs::remove_dir_all(&root).unwrap();
assert!(prepared.crystal_model.is_some());
assert!(prepared.cushion_model.is_some());
assert!(prepared.dragon_model.is_some());
assert!(prepared.arrow_model.is_some());
assert!(prepared.trident_model.is_some());
+1 -2
View File
@@ -515,8 +515,7 @@ impl Game<'_> {
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = stack_tip {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(key) = tooltip {
let text = self.translate(&key, &key);
self.draw_tooltip_lines(
@@ -56,6 +56,7 @@ impl Game<'_> {
Some(BlockEntity::Beehive(_)) => self.tick_beehive(pos),
Some(BlockEntity::Furnace(_)) => self.tick_furnace(pos)?,
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
_ => {}
}
}
@@ -92,7 +93,10 @@ impl Game<'_> {
| "minecraft:vault"
| "minecraft:beehive"
| "minecraft:bee_nest"
| "minecraft:brewing_stand" => Ticking::Yes,
| "minecraft:brewing_stand"
// Lit, cookTick; unlit, cooldownTick.
| "minecraft:campfire"
| "minecraft:soul_campfire" => Ticking::Yes,
"minecraft:jukebox" => {
let record = registry
.state(state)
+4 -1
View File
@@ -55,7 +55,7 @@ pub(super) struct Boat {
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
pub(super) const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
pub fn new(id: u32, wood: Arc<str>, position: Vec3, yaw: f32) -> Self {
Self {
@@ -587,6 +587,9 @@ impl Game<'_> {
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
if self.tick_cushion_riding(keys) {
return Ok(());
}
let Some(i) = self.boats.iter().position(|b| Some(b.id) == self.riding) else {
if self.tick_cart_riding(keys) || self.tick_mount_riding(keys) {
return Ok(());
+346 -151
View File
@@ -17,7 +17,6 @@ use horizon_client::{
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -27,8 +26,24 @@ const BLACK: u32 = 0xFF00_0000;
const PAGE_CHARS: usize = 1024;
const MAX_PAGES: usize = 100;
const LINES: usize = 14;
/// `BookSignScreen`'s title box.
const BOOK_TITLE: horizon_client::clipboard::FieldId = ("book.title", 0);
fn book_title_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
edit.focused = true;
edit
}
fn book_page_box() -> ui::EditBox {
let mut edit = ui::EditBox::new(0.0, 0.0, 114.0, 9.0);
edit.bordered = false;
edit.multiline = true;
edit.max_length = PAGE_CHARS;
edit.text_color = BLACK;
edit.focused = true;
edit
}
pub(super) enum BookScreen {
Edit {
@@ -38,7 +53,10 @@ pub(super) enum BookScreen {
page: usize,
signing: bool,
title: String,
opened: Instant,
/// BookSignScreen's title box (`EditBox` of 15).
title_edit: ui::EditBox,
/// BookEditScreen's page (`MultiLineEditBox`).
page_edit: ui::EditBox,
},
View {
pages: Vec<String>,
@@ -84,13 +102,16 @@ impl Game<'_> {
if pages.is_empty() {
pages.push(String::new());
}
let mut page_edit = book_page_box();
page_edit.set_value(&pages[0]);
BookScreen::Edit {
slot: self.held_slot(),
pages,
page: 0,
signing: false,
title: String::new(),
opened: Instant::now(),
title_edit: book_title_box(),
page_edit,
}
}
"minecraft:written_book" => BookScreen::View {
@@ -239,34 +260,24 @@ impl Game<'_> {
Some(KeyCode::PageDown) => self.turn_page(true),
_ => {}
},
Some(BookScreen::Edit {
signing: true,
title,
..
}) => match key {
Some(BookScreen::Edit { signing: true, .. }) => match key {
Some(KeyCode::Escape) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(KeyCode::Backspace) => {
if !self.text_selection.take(BOOK_TITLE, title) {
title.pop();
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
if !title.trim().is_empty() {
let blank = self.book.as_ref().is_some_and(
|b| matches!(b, BookScreen::Edit { title, .. } if title.trim().is_empty()),
);
if !blank {
self.save_book(true);
}
}
Some(key) if self.book_title_key(key) => {}
_ => {
if let Some(typed) = text {
self.text_selection.take(BOOK_TITLE, title);
for c in typed.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
self.book_title_insert(typed);
}
}
},
@@ -274,24 +285,13 @@ impl Game<'_> {
Some(KeyCode::Escape) => self.save_book(false),
Some(KeyCode::PageUp) => self.turn_page(false),
Some(KeyCode::PageDown) => self.turn_page(true),
Some(KeyCode::Backspace) => {
if !self.take_page_selection() {
self.edit_page(|p| {
p.pop();
})
}
}
Some(KeyCode::Enter | KeyCode::NumpadEnter) => {
self.take_page_selection();
self.type_into_page("\n")
}
Some(KeyCode::ArrowUp) => self.book_move_line(-1),
Some(KeyCode::ArrowDown) => self.book_move_line(1),
Some(KeyCode::Enter | KeyCode::NumpadEnter) => self.page_insert("\n"),
Some(key) if self.book_page_key(key) => {}
_ => {
if let Some(typed) = text {
let typed: String = typed.chars().filter(|c| !c.is_control()).collect();
if !typed.is_empty() {
self.take_page_selection();
}
self.type_into_page(&typed);
self.page_insert(typed);
}
}
},
@@ -302,99 +302,258 @@ impl Game<'_> {
pub(super) fn book_type(&mut self, text: &str) {
let text = text.replace("\\n", "\n");
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
for c in text.chars().filter(|c| !c.is_control()) {
if title.chars().count() < 15 {
title.push(c);
}
}
}
Some(BookScreen::Edit { .. }) => self.type_into_page(&text),
Some(BookScreen::Edit { signing: true, .. }) => self.book_title_insert(&text),
Some(BookScreen::Edit { .. }) => self.page_insert(&text),
_ => {}
}
}
/// Before typing on a page: Select All's selection is cleared (true).
fn take_page_selection(&mut self) -> bool {
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => self
.text_selection
.take(("book.page", *page), &mut pages[*page]),
_ => false,
/// `BookSignScreen.keyPressed` on the title box.
fn book_title_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return false;
};
if !title_edit.key(key, ctrl, shift) {
return false;
}
title.clone_from(&title_edit.value);
true
}
/// Ctrl+A/C/V/X: the title box (an `EditBox` of 15) while signing, else
/// the page (`MultilineTextField.insertText`: line breaks kept, cut to
/// the 1024 characters left, rejected when it overflows the 14 lines).
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let font = &self.gui.atlas.font;
match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) => {
Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
BOOK_TITLE,
title,
shortcut,
15,
);
}
Some(BookScreen::Edit { pages, page, .. }) => {
// truncateInsertionText counts the whole value, selection included.
let room = PAGE_CHARS.saturating_sub(pages[*page].chars().count());
self.text_selection.apply(
("book.page", *page),
&mut pages[*page],
shortcut,
&mut self.clipboard,
|value, pasted| {
let text: String = horizon_client::clipboard::filter_text(pasted, true)
.chars()
.take(room)
.collect();
let next = format!("{value}{text}");
font.split(&Text::literal(&next), 114.0).len() <= LINES && {
*value = next;
true
}
},
);
}
_ => {}
}
}
fn edit_page(&mut self, f: impl FnOnce(&mut String)) {
if let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_mut() {
f(&mut pages[*page]);
}
}
/// MultiLineEditBox: characters that keep the page within 14 lines and 1024 characters.
fn type_into_page(&mut self, typed: &str) {
let Some(BookScreen::Edit { pages, page, .. }) = self.book.as_ref() else {
fn book_title_insert(&mut self, text: &str) {
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let mut text = pages[*page].clone();
for c in typed.chars() {
let mut next = text.clone();
next.push(c);
if next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES {
text = next;
title_edit.insert_text(text);
title.clone_from(&title_edit.value);
}
/// `BookEditScreen.keyPressed` on the page, apart from the line moves.
fn book_page_key(&mut self, key: KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let handled = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.key(key, ctrl, shift)
} else {
false
};
if handled {
self.page_keep(before);
}
handled
}
/// One character at a time, so a page stops at the last character that fits.
fn page_insert(&mut self, text: &str) {
for c in text.chars() {
self.page_change(|edit| edit.insert_text(&c.to_string()));
}
}
/// Ctrl+A/C/V/X: the title box while signing, else the page.
pub(super) fn book_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
let signing = matches!(self.book, Some(BookScreen::Edit { signing: true, .. }));
if signing {
if let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
{
title_edit.shortcut(shortcut, &mut self.clipboard);
title.clone_from(&title_edit.value);
}
return;
}
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => None,
};
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.shortcut(shortcut, &mut self.clipboard);
}
self.page_keep(before);
}
/// Edit the page box, then keep it only within 14 lines and 1024 characters.
fn page_change(&mut self, f: impl FnOnce(&mut ui::EditBox)) -> bool {
let before = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => Some((
page_edit.value.clone(),
page_edit.cursor,
page_edit.highlight,
)),
_ => return false,
};
let ran = if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
f(page_edit);
true
} else {
false
};
ran && self.page_keep(before)
}
/// Write the page, or put the previous text back when it no longer fits.
fn page_keep(&mut self, before: Option<(String, usize, usize)>) -> bool {
let next = match self.book.as_ref() {
Some(BookScreen::Edit { page_edit, .. }) => page_edit.value.clone(),
_ => return false,
};
let ok = next.chars().count() <= PAGE_CHARS && self.book_lines(&next).len() <= LINES;
let Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) = self.book.as_mut()
else {
return false;
};
if ok {
pages[*page].clone_from(&page_edit.value);
} else if let Some((value, cursor, highlight)) = before {
page_edit.value = value;
page_edit.cursor = cursor;
page_edit.highlight = highlight;
}
ok
}
/// ArrowUp and ArrowDown move a visual line, keeping the column.
fn book_move_line(&mut self, dir: isize) {
let shift = self.has_shift_down();
let (text, cursor) = match self.book.as_ref() {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => (pages[*page].clone(), page_edit.cursor),
_ => return,
};
let rows = self.page_rows(&text);
let (x, line) = self.caret_on(&text, &rows, cursor);
let next = (line as isize + dir).clamp(0, rows.len() as isize - 1) as usize;
let (start, end) = rows[next];
let chars: Vec<char> = text.chars().collect();
let slice: String = chars[start..end].iter().collect();
let pos = start + self.gui.atlas.font.fit(&slice, x);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// Wrapped rows of a page as character ranges, the same breaks as `font.split`.
fn page_rows(&self, page: &str) -> Vec<(usize, usize)> {
let font = &self.gui.atlas.font;
let chars: Vec<char> = page.chars().collect();
let mut rows = Vec::new();
let mut start = 0;
while start < chars.len() {
let mut end = chars.len();
let mut next = chars.len();
let mut last_space = None;
for i in start..chars.len() {
if chars[i] == '\n' {
end = i;
next = i + 1;
break;
}
if chars[i] == ' ' {
last_space = Some(i);
}
let slice: String = chars[start..=i].iter().collect();
if font.width(&slice) > 114.0 {
match last_space {
Some(s) if s > start => {
end = s;
next = s + 1;
}
_ => {
end = i.max(start + 1);
next = end;
}
}
break;
}
}
rows.push((start, end));
start = next;
if start == chars.len() && chars.last() == Some(&'\n') {
rows.push((chars.len(), chars.len()));
}
}
self.edit_page(|p| *p = text);
if rows.is_empty() {
rows.push((0, 0));
}
rows
}
/// GUI x of the caret and the row it sits on.
fn caret_on(&self, page: &str, rows: &[(usize, usize)], cursor: usize) -> (f32, usize) {
let chars: Vec<char> = page.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate() {
if cursor <= end || i + 1 == rows.len() {
let take = cursor.saturating_sub(start).min(end.saturating_sub(start));
let prefix: String = chars.iter().skip(start).take(take).collect();
return (self.gui.atlas.font.width(&prefix), i);
}
}
(0.0, 0)
}
fn page_index_at(&self, page: &str, local_x: f32, local_y: f32) -> usize {
let rows = self.page_rows(page);
let line = (local_y / 9.0).floor().max(0.0) as usize;
let line = line.min(rows.len().saturating_sub(1));
let (start, end) = rows[line];
let chars: Vec<char> = page.chars().collect();
let slice: String = chars[start..end].iter().collect();
start + self.gui.atlas.font.fit(&slice, local_x.max(0.0))
}
/// A click on the centred title (`EditBox.onClick`).
fn book_title_click(&mut self, field_x: f32, mx: f32) {
let shift = self.has_shift_down();
let Some(BookScreen::Edit {
title, title_edit, ..
}) = self.book.as_mut()
else {
return;
};
let tx = field_x + ((114.0 - self.gui.atlas.font.width(title)) / 2.0).floor();
let rel = (mx - tx).max(0.0);
let pos = self.gui.atlas.font.fit(title, rel);
title_edit.set_cursor(pos, shift);
}
/// A click on the open page.
fn book_page_click(&mut self, origin_x: f32, origin_y: f32, mx: f32, my: f32) {
let shift = self.has_shift_down();
let text = match self.book.as_ref() {
Some(BookScreen::Edit { pages, page, .. }) => pages[*page].clone(),
_ => return,
};
let pos = self.page_index_at(&text, mx - origin_x, my - origin_y);
if let Some(BookScreen::Edit { page_edit, .. }) = self.book.as_mut() {
page_edit.set_cursor(pos, shift);
}
}
/// The page buttons (`pageForward` adds a page while editing).
fn turn_page(&mut self, forward: bool) {
let mut lectern_page = None;
match self.book.as_mut() {
Some(BookScreen::Edit { pages, page, .. }) => {
Some(BookScreen::Edit {
pages,
page,
page_edit,
..
}) => {
let before = *page;
if forward {
if *page + 1 >= pages.len() && pages.len() < MAX_PAGES {
pages.push(String::new());
@@ -405,6 +564,10 @@ impl Game<'_> {
} else if *page > 0 {
*page -= 1;
}
if *page != before {
let text = pages[*page].clone();
page_edit.set_value(&text);
}
}
Some(BookScreen::View {
lectern: Some(key), ..
@@ -764,6 +927,8 @@ impl Game<'_> {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
} else if inside(left + 36.0, 50.0, 114.0, 9.0) {
self.book_title_click(left + 36.0, mx);
}
}
Some(BookScreen::Edit { page, .. }) => {
@@ -780,6 +945,8 @@ impl Game<'_> {
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
self.save_book(false);
} else if inside(left + 36.0, 32.0, 114.0, 14.0 * 9.0) {
self.book_page_click(left + 36.0, 32.0, mx, my);
}
}
Some(BookScreen::View {
@@ -865,7 +1032,7 @@ impl Game<'_> {
BookScreen::Edit {
signing: true,
title,
opened,
title_edit,
..
} => {
let edit = tr("book.editTitle", "Enter Book Title:");
@@ -881,19 +1048,35 @@ impl Game<'_> {
// The title box: an unbordered, centred EditBox of 114 with
// black text and no shadow; the value alone is centred, the
// cursor follows it.
let value = &title_edit.value;
let x = left + 36.0;
let tx = x + ((114.0 - HudBatch::text_width(atlas, title)) / 2.0).floor();
horizon_client::ui::append_field(
batch,
atlas,
title,
(tx, 50.0, x + 114.0 - tx),
(BLACK, false),
15,
self.text_selection.covers(BOOK_TITLE, title),
true,
opened.elapsed().as_millis(),
);
let tx = x + ((114.0 - HudBatch::text_width(atlas, value)) / 2.0).floor();
batch.text(atlas, value, tx, 50.0, BLACK, false);
if title_edit.cursor != title_edit.highlight {
let (a, b) = (
title_edit.cursor.min(title_edit.highlight),
title_edit.cursor.max(title_edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
let sx = tx + HudBatch::text_width(atlas, &pre);
batch.text_highlight(
atlas,
(sx, 49.0, HudBatch::text_width(atlas, &sel), 11.0),
true,
);
}
if title_edit.cursor_visible() {
let pre: String = value.chars().take(title_edit.cursor).collect();
batch.fill(
atlas,
tx + HudBatch::text_width(atlas, &pre),
49.0,
1.0,
9.0,
BLACK,
);
}
let by = tr("book.byAuthor", "by %1$s").replace("%1$s", super::chat::PLAYER_NAME);
let tw = HudBatch::text_width(atlas, &by);
batch.text(
@@ -941,12 +1124,12 @@ impl Game<'_> {
BookScreen::Edit {
pages,
page,
opened,
page_edit,
..
} => {
indicator(batch, *page, pages.len());
let blink = (opened.elapsed().as_millis() / 300) % 2 == 0;
let lines = self.book_lines(&pages[*page]);
let text = &pages[*page];
let lines = self.book_lines(text);
for (i, line) in lines.iter().take(LINES).enumerate() {
batch.text_styled(
atlas,
@@ -957,27 +1140,39 @@ impl Game<'_> {
false,
);
}
if blink {
// The cursor after the last character.
let last = lines
.last()
.map(|l| atlas.font.width_text(l))
.unwrap_or(0.0);
let row = lines.len().saturating_sub(1) as f32
+ if pages[*page].ends_with('\n') {
1.0
} else {
0.0
};
let x = if pages[*page].ends_with('\n') {
0.0
} else {
last
};
let rows = self.page_rows(text);
let (sel_a, sel_b) = (
page_edit.cursor.min(page_edit.highlight),
page_edit.cursor.max(page_edit.highlight),
);
if sel_a != sel_b {
let chars: Vec<char> = text.chars().collect();
for (i, &(start, end)) in rows.iter().enumerate().take(LINES) {
let from = sel_a.max(start);
let to = sel_b.min(end);
if from >= to {
continue;
}
let pre: String = chars[start..from].iter().collect();
let sel: String = chars[from..to].iter().collect();
batch.text_highlight(
atlas,
(
left + 36.0 + atlas.font.width(&pre),
top + 29.0 + i as f32 * 9.0,
atlas.font.width(&sel),
9.0,
),
true,
);
}
}
if page_edit.cursor_visible() {
let (x, row) = self.caret_on(text, &rows, page_edit.cursor);
batch.fill(
atlas,
left + 36.0 + x,
top + 29.0 + row * 9.0,
top + 29.0 + row as f32 * 9.0,
1.0,
9.0,
BLACK,
+190
View File
@@ -0,0 +1,190 @@
//! Campfires' cooking: `CampfireBlock.useItemOn` (`CampfireBlockEntity.placeFood`)
//! and the block entity's `cookTick` (lit) and `cooldownTick` (unlit). The
//! items drawn on the fire are `campfire_geometry`'s; a server's clients get
//! them through the block entity's update tag (`lan::chunks::entity_tag`).
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
items::ItemStack, mesher::Vertex, mobs::Who,
};
use horizon_core::position::BlockPos;
/// The `campfire_cooking` recipe kind (`RecipeType.CAMPFIRE_COOKING`).
const KIND: &str = "minecraft:campfire_cooking";
fn is_campfire(name: &str) -> bool {
name == "minecraft:campfire" || name == "minecraft:soul_campfire"
}
impl Game<'_> {
/// `CampfireBlock.useItemOn` with a held item that is a campfire recipe's
/// input (`RecipePropertySet.CAMPFIRE_INPUT`): on a server level the food
/// goes into the first free slot (`placeFood`), and the use is consumed
/// whether or not there was room. A client of a server leaves the placing
/// to the server. Whether the food went in; None when this isn't a
/// campfire use, so the item's own use runs.
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
let state = self.world.block(pos[0], pos[1], pos[2]);
if !is_campfire(&self.res.facts().get(state).name) {
return Ok(None);
}
let Some(held) = self.held().cloned() else {
return Ok(None);
};
let Some(time) = self.res.recipes().cook(KIND, &held).map(|r| r.time as i32) else {
return Ok(None);
};
// A client of a server predicts the placing; the server decides.
if self.remote.is_some() {
return Ok(Some(true));
}
let key = (pos[0], pos[1], pos[2]);
let placed = match self.world.block_entities.get_mut(&key) {
Some(BlockEntity::Campfire(c)) => {
let mut one = held.clone();
c.place_food(&mut one, time)
}
_ => false,
};
if placed {
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Player),
Some(state),
);
self.mark_block_entity_updated(key);
}
Ok(Some(placed))
}
/// `CampfireBlockEntity.cookTick` of a lit campfire, `cooldownTick` of an
/// unlit one. A finished item's result (the recipe's, or the item itself
/// when no recipe takes it any more) drops at the block's corner
/// (`Containers.dropItemStack` at its integer position), then the clients
/// see the slot empty and a `block_change` goes out.
pub(super) fn tick_campfire(&mut self, pos: Pos) -> Result<()> {
let state = self.world.block(pos.0, pos.1, pos.2);
let lit = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
// BlockEntity.setChanged: a timer that moved is saved with the
// chunk (no update to the clients, who don't see the timers).
let changed = if lit {
c.items.iter().any(Option::is_some)
} else {
c.cooldown_tick()
};
if changed {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let done = c.cook_tick();
for (_, item) in done {
let result = self
.res
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
"minecraft:block_change",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
Ok(())
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
/// FIXED item seeded by the block's position plus the slot.
pub(super) fn campfire_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Campfire(c) = entity else {
continue;
};
if c.items.iter().all(Option::is_none) {
continue;
}
let at = glam::DVec3::new(f64::from(key.0), f64::from(key.1), f64::from(key.2));
if at.distance_squared(camera) >= 64.0 * 64.0 {
continue;
}
// Direction.get2DDataValue: south 0, west 1, north 2, east 3.
let state = self.world.block(key.0, key.1, key.2);
let facing = match self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
{
Some("west") => 1,
Some("north") => 2,
Some("east") => 3,
_ => 0,
};
let light = vertex_light(self.world.light(key.0, key.1, key.2));
let packed = BlockPos::new(key.0, key.1, key.2).pack();
for (i, stack) in c.items.iter().enumerate() {
let Some(stack) = stack else {
continue;
};
let seed = (packed as i32).wrapping_add(i as i32);
let item = self.resolve_item(stack, DisplayContext::Fixed, None, seed);
if item.is_empty() {
continue;
}
// Direction.toYRot: 90° per 2D step from south.
let yaw = -(((i + facing) % 4) as f32 * 90.0);
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.5, 0.449_218_75, 0.5),
) * glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_rotation_x(90f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(-0.3125, -0.3125, 0.0))
* glam::Mat4::from_scale(glam::Vec3::splat(0.375));
self.submit_item_world(&item, pose, light, out);
}
}
}
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
/// integer position.
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
let at = BlockPos::new(pos.0, pos.1, pos.2);
for piece in
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
{
self.spawn_item(
ItemStack {
count: piece.count,
..stack.clone()
},
piece.pos,
piece.motion,
0,
);
}
}
}
+29 -4
View File
@@ -464,7 +464,28 @@ impl Game<'_> {
return;
}
// The Chat option's Hidden level takes the server's messages too.
if !self.ui.chat.shows(ChatSource::Server) {
self.local_chat_line(text, Some(SYSTEM_INDICATOR), ChatSource::Server);
}
/// A player's chat line on the window's own chat: a LAN guest's message
/// while the host runs in-frame (`HostEvent::Chat`). `ChatListener`
/// checks `canReceivePlayerMessages` for these, so Commands Only and
/// Hidden drop them; command feedback (`/list`, `/tp`) is a system
/// message and takes the system-message permission instead.
pub(super) fn player_message(&mut self, text: Text) {
if let Some(capture) = self.chat_capture.as_mut() {
capture.push(text);
return;
}
// A server's client receives the line as a packet (PlayerChat or
// DisguisedChat) and filters it there.
if self.remote.is_none() {
self.local_chat_line(text, None, ChatSource::Player);
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
@@ -472,8 +493,8 @@ impl Game<'_> {
self.ui.chat.add(
&self.gui.atlas,
text,
Some(SYSTEM_INDICATOR),
ChatSource::Server,
indicator,
source,
self.ticks,
focused,
);
@@ -602,7 +623,10 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
self.run_command(command);
// A client mod's command first, then a server mod's, as on a server.
if !self.mod_command(command) && !self.local_server_mod_command(command) {
self.run_command(command);
}
} else {
let line = Text::translate(
"chat.type.text",
@@ -622,6 +646,7 @@ impl Game<'_> {
self.mods_chat(&line);
}
self.lan_host_chat(message);
self.mod_player_chat(super::server_mods::Who::Local, message);
}
}
pub(super) fn chat_scroll(&mut self, amount: f32) {
+13 -24
View File
@@ -63,8 +63,8 @@ impl Game<'_> {
}
}
}
/// Typing or a backspace in a field of the title screens: in the server
/// screens' append-only fields Select All's selection is replaced first.
/// Typing or a backspace in a title-screen field. A field that still only
/// appends replaces a Select All selection before the edit.
pub(super) fn title_edit(&mut self, typed: Option<&str>, backspace: bool) {
let Some(title) = self.title.as_mut() else {
return;
@@ -86,42 +86,31 @@ impl Game<'_> {
}
/// Shortcuts in the creative inventory's search box (an `EditBox` of 50).
pub(super) fn creative_search_shortcut(&mut self, shortcut: EditShortcut) {
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
CREATIVE_SEARCH,
&mut self.creative_screen.search,
shortcut,
CREATIVE_SEARCH_CHARS,
) {
if self
.creative_screen
.search
.shortcut(shortcut, &mut self.clipboard)
{
self.creative_screen.scroll = 0.0;
}
}
/// A backspace in the creative search box: with Ctrl, the whole word
/// before the caret (`EditBox.deleteWords(-1)`), otherwise one character.
/// A Select All selection is what it deletes, as in `EditBox.keyPressed`.
/// A selection is what it deletes, as in `EditBox.keyPressed`.
pub(super) fn creative_search_backspace(&mut self, ctrl: bool) {
let search = &mut self.creative_screen.search;
if !self.text_selection.take(CREATIVE_SEARCH, search) {
if ctrl {
horizon_client::ui::delete_words(search, -1);
} else {
search.pop();
}
}
self.creative_screen
.search
.key(KeyCode::Backspace, ctrl, false);
self.creative_screen.scroll = 0.0;
}
/// Typing in the creative search box.
pub(super) fn creative_search_edit(&mut self, typed: Option<&str>) {
let search = &mut self.creative_screen.search;
self.text_selection.take(CREATIVE_SEARCH, search);
if let Some(text) = typed {
clipboard::append_limited(search, text, CREATIVE_SEARCH_CHARS);
self.creative_screen.search.insert_text(text);
self.creative_screen.scroll = 0.0;
}
self.creative_screen.scroll = 0.0;
}
}
/// `CreativeModeInventoryScreen`'s search box: `setMaxLength(50)`.
pub(super) const CREATIVE_SEARCH_CHARS: usize = 50;
pub(super) const CREATIVE_SEARCH: horizon_client::clipboard::FieldId = ("creative.search", 0);
+8 -3
View File
@@ -52,11 +52,16 @@ impl Game<'_> {
/// Remember who deals the next `damage_type` damage to the player.
pub(super) fn note_attacker(&mut self, damage_type: &str, mob: u32) {
if let Some(name) = self.mob_display_name(mob) {
self.combat
.pending
.push((damage_type.to_owned(), (mob, name)));
self.note_named_attacker(damage_type, mob, name);
}
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
self.combat
.pending
.push((damage_type.to_owned(), (id, 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]) {
+18 -3
View File
@@ -85,16 +85,16 @@ fn format_double(v: f64) -> String {
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) {
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.mod_command(command) {
return;
return true;
}
let d = vanilla::game_dispatcher();
let parse = d.parse(command, &self.ui.registries);
let result = match parse.executable() {
Err(e) => {
self.syntax_failure(&e, command);
return;
return false;
}
Ok((ctx, _)) => {
let ctx = ctx.clone();
@@ -103,6 +103,7 @@ impl Game<'_> {
self.run_chain(&root, &ctx)
}
};
let ok = result.is_ok();
if let Err(e) = result {
if e.input.is_some() {
self.syntax_failure(&e, command);
@@ -114,6 +115,20 @@ impl Game<'_> {
if self.player.is_some() {
self.check_inventory_trigger();
}
ok
}
/// `run_command` with its output suppressed
/// (`CommandSourceStack.withSuppressedOutput`): the player's own source,
/// rights and checks, no feedback and no failure in the chat.
pub(super) fn run_command_quietly(&mut self, command: &str) -> bool {
let quiet = execute::Source {
silent: true,
..self.player_source()
};
let outer = self.command_source.replace(quiet);
let ok = self.run_command(command);
self.command_source = outer;
ok
}
fn syntax_failure(&mut self, e: &CommandError, command: &str) {
self.command_failure(e.message.clone());
+16 -6
View File
@@ -50,9 +50,9 @@ pub(super) struct Creatures {
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
}
struct ProjWorld<'a, 'w> {
w: &'a MobWorld<'w>,
targets: Vec<(Who, Aabb)>,
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
@@ -373,6 +373,7 @@ impl Game<'_> {
// Drawn seated (Entity.isPassenger); set apart because the
// json! above is at the macro recursion limit.
state["passenger"] = m.extra.passenger.into();
state["guardian_beam"] = serde_json::json!(horizon_client::mobs::guardian::beam(m, 0.0));
state
})
.collect();
@@ -391,6 +392,9 @@ impl Game<'_> {
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
// Level.canHaveWeather: only the overworld's rain and thunder levels
// ever rise, so nothing rains or thunders in the Nether or the End.
let weather = self.dim == Dim::Overworld;
let player = self.player_view();
MobWorld {
world: &self.world,
@@ -407,8 +411,8 @@ impl Game<'_> {
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: rain > 0.2,
thundering: thunder > 0.9,
raining: weather && rain > 0.2,
thundering: weather && thunder > 0.9,
nether: self.dim == Dim::Nether,
block_tags: &self.res.items().block_tags,
biomes: self.res.biomes(),
@@ -1062,7 +1066,10 @@ impl Game<'_> {
}
MobEvent::BreakBlock { pos, drop } => {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
// Block.spawnDestroyParticles: fire breaks leave nothing.
if !self.res.facts().get(old).has_class("BaseFireBlock") {
self.destroy_particles(pos, old);
}
if drop {
let drops = self.res.items().block_drops_in(
self.pack.assets.registry,
@@ -1084,6 +1091,9 @@ impl Game<'_> {
self.ensure_item_particle_sprites();
}
let options = horizon_client::particles::ParticleOptions::simple(ty);
if ty == horizon_client::particles::ParticleType::SonicBoom {
self.lan_sonic_boom(&options, at);
}
// Count 0: one particle exactly there, at rest.
if count == 0 {
self.particles.add(&options, at, [0.0; 3]);
+494
View File
@@ -0,0 +1,494 @@
//! Cushions: `CushionItem.useOn` placement on the top face of a block,
//! `Cushion.canBePlacedAt`/`survives`, `Cushion.interact` (sit),
//! `BlockAttachedEntity.hurtServer` (break, dropping the matching colour),
//! `BlockAttachedEntity.tick` and `CushionRenderer`.
//!
//! A cushion is a `BlockAttachedEntity`, not a block: it has no block state,
//! only an entity whose `Pos` is the click point's height. Placement uses
//! outline bounds, collision shapes and suffocation predicates.
use super::{Game, Hit, view_vector};
use horizon_client::entities::{Cushion, dye_index};
use horizon_client::items::ItemStack;
use horizon_client::mobs::anim::Poses;
use horizon_core::geometry::{Aabb, Vec3};
use winit::keyboard::KeyCode;
/// `Direction.fromYRot(yaw).toYRot()`: the nearest 90 degree step.
pub(crate) fn snap_yaw(yaw: f32) -> f32 {
(((f64::from(yaw) / 90.0 + 0.5).floor() as i64) & 3) as f32 * 90.0
}
fn aabb(bounds: ([f64; 3], [f64; 3])) -> Aabb {
Aabb::new(
Vec3::new(bounds.0[0], bounds.0[1], bounds.0[2]),
Vec3::new(bounds.1[0], bounds.1[1], bounds.1[2]),
)
}
fn overlaps(a: Aabb, b: Aabb) -> bool {
a.intersects(b)
}
use horizon_client::cushion::blocks_in;
struct PlacementWorld<'a, 'r>(&'a Game<'r>);
impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
fn outline_bounds(&self, p: [i32; 3]) -> Option<Aabb> {
let boxes = self.0.outline_boxes(p);
let mut shape = *boxes.first()?;
for b in &boxes[1..] {
for k in 0..3 {
shape[k] = shape[k].min(b[k]);
shape[k + 3] = shape[k + 3].max(b[k + 3]);
}
}
Some(Aabb::new(
Vec3::new(shape[0], shape[1], shape[2]),
Vec3::new(shape[3], shape[4], shape[5]),
))
}
fn collision_shapes(&self, bounds: Aabb) -> Vec<horizon_core::geometry::VoxelShape> {
let binding = self.0.res.collision();
let mut world = horizon_client::collisions::SceneCollisions {
world: &self.0.world,
binding,
subject: None,
};
// Include neighbour blocks whose collision shape can reach outside their cell.
blocks_in(bounds.inflate(Vec3::new(1.0, 1.0, 1.0)))
.filter_map(|p| {
let state = self.0.world.block(p[0], p[1], p[2]);
binding
.collision_shape(&mut world, state, p)
.ok()
.map(|s| s.moved(Vec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))))
})
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
let state = self.0.world.block(p[0], p[1], p[2]);
let facts = self.0.res.facts().get(state);
let name = facts.name.as_ref();
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
match name {
"minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:mud"
| "minecraft:soul_sand" => true,
"minecraft:piston" | "minecraft:sticky_piston" => self
.0
.pack
.assets
.registry
.state(state)
.is_some_and(|s| s.properties.get("extended").is_some_and(|p| p == "false")),
"minecraft:mangrove_roots" | "minecraft:moving_piston" => false,
_ if name.ends_with("_leaves")
|| name.ends_with("glass")
|| name.ends_with("copper_grate") =>
{
false
}
// ShulkerBoxBlock: suffocating while closed; a box seen open
// here is rare enough to count as closed.
_ if name.ends_with("shulker_box") => true,
_ => {
facts.full_collision
&& self
.0
.res
.items()
.block_in_tag("minecraft:causes_suffocation", name)
}
}
}
}
impl Game<'_> {
/// The cushion under the crosshair, its distance and the hit point.
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let eye = self.eye_position()?;
let dir = view_vector(self.yaw, self.pitch);
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, c) in self.cushions.iter().enumerate() {
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
continue;
};
if best.is_none_or(|b| d < b.1) {
best = Some((i, d, std::array::from_fn(|k| eye[k] + dir[k] * d)));
}
}
best
}
pub(super) fn cushion_survives(&self, c: &Cushion) -> bool {
horizon_client::cushion::survives(&PlacementWorld(self), aabb(c.bounds()))
}
/// `Cushion.destroyIfInFire`: a fire-tagged block inside the deflated box.
fn cushion_in_fire(&self, c: &Cushion) -> bool {
let bounds = aabb(c.bounds()).next_deflated();
blocks_in(bounds).any(|p| {
let facts = self.res.facts().get(self.world.block(p[0], p[1], p[2]));
self.res.items().block_in_tag("minecraft:fire", &facts.name)
})
}
/// `CushionItem.useOn`: on the top face only, spawning at the click's
/// height above the clicked block (or the replaceable block itself).
pub(super) fn place_cushion(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(info) = self.res.items().get(&stack.item).cloned() else {
return false;
};
let Some(default_color) = info.cushion_color.as_deref() else {
return false;
};
let color_override = stack
.data()
.and_then(|d| d.other("minecraft:cushion/color"))
.and_then(|tag| match tag {
horizon_world::nbt::Tag::String(s) => Some(s.to_string_lossy()),
_ => None,
});
let color = dye_index(color_override.as_deref().unwrap_or(default_color));
// CushionItem.recalculateContextForSpecialCollisionShapes: open containers
// (cauldrons, hoppers and composters) use the collision ray's actual height.
let mut adjusted = Hit {
pos: hit.pos,
face: hit.face,
point: hit.point,
};
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
if self.res.items().block_in_tag(
"minecraft:cushion_uses_collision_shape",
&self.res.facts().get(state).name,
) && let Some(eye) = self.eye_position()
{
let start = Vec3::new(eye[0], eye[1], eye[2]);
let point = Vec3::new(hit.point[0], hit.point[1], hit.point[2]);
let end = point.add_vector(point.subtract(start).normalize().scale(0.001));
let binding = self.res.collision();
let mut world = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding,
subject: None,
};
if let Ok(shape) = binding.collision_shape(&mut world, state, hit.pos)
&& let Some(result) = shape.clip(
start,
end,
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
)
{
adjusted.face = result.direction.normal();
adjusted.point = [result.location.x, result.location.y, result.location.z];
}
}
let hit = &adjusted;
if hit.face != [0, 1, 0] {
return false;
}
// BlockPlaceContext: a replaceable clicked block is replaced.
let clicked = self
.res
.facts()
.get(self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]));
let pos = if clicked.replaceable {
hit.pos
} else {
std::array::from_fn(|i| hit.pos[i] + hit.face[i])
};
// `Vec3.atCenterOfWithY(clickedPos, clickLocation.y)`.
let y = hit.point[1];
let position = Vec3::new(f64::from(pos[0]) + 0.5, y, f64::from(pos[2]) + 0.5);
let bounds = aabb((
[position.x - 0.5, position.y, position.z - 0.5],
[position.x + 0.5, position.y + 0.25, position.z + 0.5],
));
if !horizon_client::cushion::can_place(&PlacementWorld(self), bounds) {
return false;
}
// `getEntitiesOfClass(Cushion.class, spawnAABB).isEmpty()`.
if self
.cushions
.iter()
.any(|c| overlaps(aabb(c.bounds()), bounds))
{
return false;
}
self.next_entity += 1;
let id = self.next_entity;
// `BlockAttachedEntity.setPos`: `BlockPos.containing(position)`.
let attached = [
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
];
self.cushions.push(Cushion {
id,
position,
pos: attached,
yaw: snap_yaw(self.yaw),
color,
check_interval: 0,
});
self.world
.modified
.insert((attached[0] >> 4, attached[2] >> 4));
// `destroyIfInFire`: placed into fire, it breaks at once.
let placed = self.cushions.len() - 1;
if self.cushion_in_fire(&self.cushions[placed]) {
self.break_cushion(placed, false);
}
self.audio.play(
"minecraft:entity.cushion.place",
Some([position.x, position.y, position.z]),
0.75,
0.8,
);
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event(
"minecraft:entity_place",
[position.x, position.y, position.z],
ctx,
);
if !self.creative {
self.consume_held();
}
true
}
/// `Cushion.interact`: sit down unless sneaking or already occupied.
pub(super) fn interact_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
return false;
};
if !self.before_block(d) {
return false;
}
// isSecondaryUseActive.
if self.keys.contains(&KeyCode::ShiftLeft) {
return false;
}
let id = self.cushions[i].id;
let occupied = self.riding == Some(id)
|| self
.lan
.as_ref()
.is_some_and(|h| h.hub.guests.iter().any(|g| g.vehicle == Some(id)));
// `Cushion.interact`: an occupied cushion (`isVehicle`) passes.
if occupied {
return false;
}
// `Player.startRiding` gives up the vehicle it already has; the
// client never sends that click, so pass instead.
if self.riding.is_some() {
return false;
}
self.riding = Some(id);
let p = self.cushions[i].position;
self.audio.play(
"minecraft:entity.cushion.sit",
Some([p.x, p.y, p.z]),
1.0,
1.0,
);
self.swing_arm();
true
}
/// `Cushion.hurtServer` from a player: killed, then the item dropped.
pub(super) fn attack_cushion(&mut self) -> bool {
let Some((i, d, _)) = self.cushion_hit() else {
return false;
};
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// `skipAttackInteraction` when `mayInteract` is false.
if !self.may_build() {
return true;
}
self.break_cushion(i, true);
true
}
/// `Cushion.dropItem`: the break sound and particles, then the coloured
/// item unless drops are off or the breaker has infinite materials.
pub(super) fn break_cushion(&mut self, index: usize, by_player: bool) {
let c = self.cushions.remove(index);
let p = c.position;
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
if self.riding == Some(c.id) {
self.riding = None;
}
self.audio.play(
"minecraft:entity.cushion.break",
Some([p.x, p.y, p.z]),
1.0,
1.0,
);
self.cushion_break_particles(&c);
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
return;
}
let stack = ItemStack {
item: std::sync::Arc::from(c.item()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stack, [p.x, p.y, p.z], [0.0; 3], 10);
}
/// `Cushion.showBreakingParticles`: ten of the matching wool block.
fn cushion_break_particles(&mut self, c: &Cushion) {
let name = format!("minecraft:{}_wool", c.color_name());
let Some(state) = self.pack.assets.registry.default_state(&name) else {
return;
};
let at = [
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
];
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
return;
};
let options = horizon_client::particles::ParticleOptions::block(
horizon_client::particles::ParticleType::Block,
sprite,
tint,
);
let p = c.position;
self.particles.send_particles(
&options,
[p.x, p.y + 0.666_666_7, p.z],
[0.25, 0.0625, 0.25],
0.05,
10,
);
}
/// `BlockAttachedEntity.tick`: survive and burn checks after the post-increment reaches 100.
pub(super) fn tick_cushions(&mut self) {
let ticking = self.entity_ticking_area();
let mut i = 0;
while i < self.cushions.len() {
let p = self.cushions[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) {
i += 1;
continue;
}
let previous = self.cushions[i].check_interval;
self.cushions[i].check_interval += 1;
if previous < 100 {
i += 1;
continue;
}
self.cushions[i].check_interval = 0;
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
continue;
}
i += 1;
}
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
let Some(c) = self
.cushions
.iter()
.find(|c| Some(c.id) == self.riding)
.cloned()
else {
return false;
};
if keys.shift {
let at = c.dismount();
self.riding = None;
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
// `Cushion.removePassenger`: the get-up sound for a rider that
// left rather than one whose cushion was killed.
self.audio.play(
"minecraft:entity.cushion.get_up",
Some([c.position.x, c.position.y, c.position.z]),
1.0,
1.0,
);
return true;
}
let seat = c.seat();
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = seat;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
true
}
/// `CushionRenderer`: the coloured model, rotated to the nearest
/// direction, with its bottom on the cushion's position.
pub(super) fn cushion_geometry(
&self,
camera: glam::DVec3,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let Some(model) = self.cushion_model.as_ref() else {
return;
};
let sprites = &self.gui.atlas.sprites;
for c in &self.cushions {
let entity_start = out.len();
let a = aabb(c.bounds());
let (lo, hi) = (a.min_position(), a.max_position());
let (lo, hi) = (
glam::DVec3::new(lo.x, lo.y, lo.z),
glam::DVec3::new(hi.x, hi.y, hi.z),
);
let range =
super::entity_culling::default_range(hi - lo, self.settings.entity_distance);
if !self.entity_in_view(camera, (lo + hi) / 2.0, range, lo, hi) {
continue;
}
let Some(uv) = sprites
.get(&format!("entity/cushion/{}_cushion", c.color_name()))
.map(|r| [r.u0, r.v0, r.u1, r.v1])
else {
continue;
};
let p = glam::DVec3::new(c.position.x, c.position.y, c.position.z) - camera;
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - snap_yaw(c.yaw)).to_radians())
* glam::Mat4::from_rotation_x(std::f32::consts::PI)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0));
// `EntityRenderer.getPackedLightCoords` probes at the eye height.
let l = self.world.light(
c.position.x.floor() as i32,
(c.position.y + 0.2125).floor() as i32,
c.position.z.floor() as i32,
);
let packed = (u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8);
horizon_client::mobs::render::draw(
model,
base,
&Poses::new(model),
uv,
0xFFFF_FFFF,
packed,
0,
out,
);
self.cull_entity_vertices(out, entity_start);
}
}
}
+215 -22
View File
@@ -12,11 +12,13 @@ use horizon_client::{
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
};
use horizon_protocol::entity_data::Value;
use horizon_world::nbt::Tag;
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
@@ -1691,25 +1693,8 @@ impl Game<'_> {
}
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
d.nearest_crystal = self
.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
(c.position.x - p.x).abs() <= 32.0
&& (c.position.y - p.y).abs() <= 32.0
&& (c.position.z - p.z).abs() <= 32.0
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
+ (a.1.position.y - p.y).powi(2)
+ (a.1.position.z - p.z).powi(2);
let db = (b.1.position.x - p.x).powi(2)
+ (b.1.position.y - p.y).powi(2)
+ (b.1.position.z - p.z).powi(2);
da.total_cmp(&db)
})
.map(|(i, _)| i);
let nearest = self.nearest_crystal_to(p);
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
}
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
@@ -1870,7 +1855,7 @@ impl Game<'_> {
pl.velocity.z += zd / dd * 4.0;
}
if !sitting {
g.hurt_player_by("minecraft:mob_attack", 5.0);
g.dragon_hurts_player("minecraft:mob_attack", 5.0);
}
});
}
@@ -1879,7 +1864,7 @@ impl Game<'_> {
for h in heads {
if h.inflate(Vec3::new(1.0, 1.0, 1.0)).intersects(p.bounds) {
self.as_fight_player(p.who, |g| {
g.hurt_player_by("minecraft:mob_attack", 10.0);
g.dragon_hurts_player("minecraft:mob_attack", 10.0);
});
}
}
@@ -2161,10 +2146,20 @@ impl Game<'_> {
self.breath_clouds.append(&mut clouds);
for (who, amount) in harmed {
self.as_fight_player(who, |g| {
g.hurt_player_by("minecraft:indirect_magic", amount);
g.dragon_hurts_player("minecraft:indirect_magic", amount);
});
}
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
/// death message names it.
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);
}
self.hurt_player_by(damage_type, amount);
}
/// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars`
/// (or a server's packets) put on this client's overlay.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
@@ -3132,6 +3127,204 @@ impl Game<'_> {
let _ = self.activate_exit_portal();
self.fight.dirty = true;
}
/// `EnderDragon.checkCrystals`' search: the nearest crystal within 32
/// blocks on each axis.
fn nearest_crystal_to(&self, p: Vec3) -> Option<usize> {
self.crystals
.iter()
.enumerate()
.filter(|(_, c)| {
(c.position.x - p.x).abs() <= 32.0
&& (c.position.y - p.y).abs() <= 32.0
&& (c.position.z - p.z).abs() <= 32.0
})
.min_by(|a, b| {
let da = (a.1.position.x - p.x).powi(2)
+ (a.1.position.y - p.y).powi(2)
+ (a.1.position.z - p.z).powi(2);
let db = (b.1.position.x - p.x).powi(2)
+ (b.1.position.y - p.y).powi(2)
+ (b.1.position.z - p.z).powi(2);
da.total_cmp(&db)
})
.map(|(i, _)| i)
}
}
/// The dragon on a server's client (`ClientPacketListener.handleAddEntity`
/// and the client side of `EnderDragon.aiStep`): the server flies it and
/// sends its moves, health and phase; the client flaps its wings, keeps
/// its flight history and places its parts, so it can be drawn and hit.
impl Game<'_> {
/// `handleAddEntity` for `minecraft:ender_dragon`. Its constructor
/// sets the full health and the hovering phase until the server's
/// synched data says otherwise.
pub(super) fn spawn_remote_dragon(&mut self, id: i32, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
let mut d = Dragon::new(at, yaw, i64::from(id));
d.id = id as u32;
d.phase = Phase::from_id(10);
self.dragon = Some(d);
self.place_dragon_parts();
if let Some(d) = self.dragon.as_mut() {
for part in &mut d.parts {
part.1 = part.0;
}
}
}
/// The remote dragon, if `id` is it.
fn remote_dragon_mut(&mut self, id: i32) -> Option<&mut Dragon> {
self.dragon.as_mut().filter(|d| d.id as i32 == id)
}
/// `handleSetEntityData`: LivingEntity.DATA_HEALTH_ID and
/// EnderDragon.DATA_PHASE (`onSyncedDataUpdated` sets the phase).
pub(super) fn remote_dragon_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let Some(d) = self.remote_dragon_mut(id) else {
return;
};
for (index, _, value) in values {
match (index, value) {
(9, Value::Float(health)) => d.health = *health,
(16, Value::Int(phase)) => d.set_phase(Phase::from_id(*phase)),
_ => {}
}
}
}
/// `handleSetEntityMotion`: the velocity its wings flap by.
pub(super) fn remote_dragon_motion(&mut self, id: i32, velocity: [f64; 3]) {
if let Some(d) = self.remote_dragon_mut(id) {
d.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
}
}
/// `handleHurtAnimation` and `handleDamageEvent`: the red flash.
pub(super) fn remote_dragon_hurt(&mut self, id: i32) {
if let Some(d) = self.remote_dragon_mut(id) {
d.hurt_time = 10;
}
}
/// `handleRemoveEntities`, or a crystal removed: the healing beam
/// keeps pointing at the same crystal.
pub(super) fn remove_remote_dragon(&mut self, id: i32) {
if self.remote_dragon_mut(id).is_some() {
self.dragon = None;
return;
}
let Some(index) = self.crystals.iter().position(|c| c.id as i32 == id) else {
return;
};
if let Some(d) = self.dragon.as_mut() {
match d.nearest_crystal {
Some(n) if n == index => d.nearest_crystal = None,
Some(n) if n > index => d.nearest_crystal = Some(n - 1),
_ => {}
}
}
}
/// One client tick of the dragon. `lerp`: the step toward its last
/// sent position and yaw (LivingEntity's interpolation), and the
/// steps left including this one.
pub(super) fn tick_remote_dragon(&mut self, lerp: Option<([f64; 3], Option<f32>, u32)>) {
let Some(d) = self.dragon.as_mut() else {
return;
};
d.previous = d.position;
d.previous_yaw = d.yaw;
d.old_flap = d.flap;
d.ticks += 1;
d.hurt_time = (d.hurt_time - 1).max(0);
for part in &mut d.parts {
part.1 = part.0;
}
if let Some((target, yaw, steps)) = lerp {
let n = f64::from(steps.max(1));
d.position = Vec3::new(
d.position.x + (target[0] - d.position.x) / n,
d.position.y + (target[1] - d.position.y) / n,
d.position.z + (target[2] - d.position.z) / n,
);
if let Some(yaw) = yaw {
d.yaw += wrap(yaw - d.yaw) / steps.max(1) as f32;
}
}
if !d.alive() {
// EnderDragon.tickDeath on a client: it rises, and bursts
// from tick 180 to 200; the server removes it.
d.death_time += 1;
let (t, p) = (d.death_time, d.position);
if (180..=200).contains(&t) {
let r = &mut d.random;
let o = [
f64::from((r.next_float() - 0.5) * 8.0),
f64::from((r.next_float() - 0.5) * 4.0),
f64::from((r.next_float() - 0.5) * 8.0),
];
self.particles.add(
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
[p.x + o[0], p.y + 2.0 + o[1], p.z + o[2]],
[0.0; 3],
);
}
if let Some(d) = self.dragon.as_mut() {
d.position.y += 0.1;
for part in &mut d.parts {
part.0.y += 0.1;
}
}
return;
}
// processFlappingMovement and onFlap: its wing beats, and their sound.
let flap_was = (d.old_flap * std::f32::consts::TAU).cos();
let v = d.velocity;
let mut flap_speed = 0.2 / ((v.x.hypot(v.z) as f32) * 10.0 + 1.0);
flap_speed *= 2f32.powf(v.y as f32);
d.flap += if d.phase.sitting() { 0.1 } else { flap_speed };
let flap_now = (d.flap * std::f32::consts::TAU).cos();
let at = [d.position.x, d.position.y, d.position.z];
if flap_was <= -0.3 && flap_now >= -0.3 {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.flap", Some(at), 5.0, pitch);
}
let d = self.dragon.as_mut().expect("dragon");
if !d.phase.sitting() {
d.growl -= 1;
if d.growl < 0 {
d.growl = 200 + d.random.next_int_bound(200).unwrap_or(0);
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
// checkCrystals' search runs on a client too (the healing beam).
let d = self.dragon.as_mut().expect("dragon");
if d.random.next_int_bound(10).unwrap_or(1) == 0 {
let p = d.position;
let nearest = self.nearest_crystal_to(p);
self.dragon.as_mut().expect("dragon").nearest_crystal = nearest;
}
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
d.record();
self.place_dragon_parts();
}
/// The dragon's parts as a server's client attacks them: the server's
/// ids for `EnderDragonPart`s follow the dragon's.
pub(super) fn remote_dragon_part_hit(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<(i32, f64, [f64; 3])> {
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
(0..8)
.filter_map(|i| {
let b = d.part_box(i);
let p = d.parts[i].0;
super::ray_box(eye, dir, reach, b)
.map(|t| ((d.id + 1 + i as u32) as i32, t, [p.x, p.y, p.z]))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
}
}
#[cfg(test)]
+56 -4
View File
@@ -129,15 +129,32 @@ impl EndState {
survive: false,
});
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_eyes(&mut self, mut step: impl FnMut(u32, [f64; 3]) -> [f64; 3]) {
/// `EyeOfEnder.tick` on a server's client: it moves by its last known
/// motion; only the server steers it.
pub(super) fn step_remote_eyes(&mut self) {
for e in &mut self.eyes {
e.previous = e.position;
e.life += 1;
e.position = glam::DVec3::from_array(step(e.id, e.position.to_array()));
e.position += e.velocity;
}
}
/// A server's move: an eye has no interpolation handler, so
/// `moveOrInterpolateTo` puts it there at once. False when `id` isn't one.
pub(super) fn move_remote_eye(&mut self, id: u32, at: [f64; 3]) -> bool {
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
return false;
};
e.position = glam::DVec3::from_array(at);
true
}
/// `handleSetEntityMotion` for an eye. False when `id` isn't one.
pub(super) fn remote_eye_motion(&mut self, id: u32, velocity: [f64; 3]) -> bool {
let Some(e) = self.eyes.iter_mut().find(|e| e.id == id) else {
return false;
};
e.velocity = glam::DVec3::from_array(velocity);
true
}
pub(super) fn remove_remote_eye(&mut self, id: u32) {
self.eyes.retain(|e| e.id != id);
}
@@ -1099,6 +1116,7 @@ impl Game<'_> {
/// EyeOfEnder.tick: fly, then drop (4 in 5) or shatter after 80 ticks.
fn tick_eyes(&mut self) {
let mut eyes = std::mem::take(&mut self.end.eyes);
let mut shattered = Vec::new();
eyes.retain_mut(|eye| {
eye.previous = eye.position;
let next = eye.position + eye.velocity;
@@ -1122,10 +1140,44 @@ impl Game<'_> {
extra: None,
};
self.spawn_item(stack, at, [vx, 0.2, vz], 0);
} else {
shattered.push(at.map(|v| v.floor() as i32));
}
false
});
self.end.eyes = eyes;
for pos in shattered {
self.level_event_at(2003, pos, 0);
}
}
/// `LevelEventHandler` 2003: a shattered eye's item crumbs and two rings
/// of portal particles drawn in towards it.
pub(super) fn eye_break_particles(&mut self, pos: [i32; 3]) {
let (x, y, z) = (
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
);
let crumbs = self.item_particle(&ItemStack::new("minecraft:ender_eye", 1));
for _ in 0..8 {
let r = self.particles.random();
let v = [
r.next_gaussian() * 0.15,
r.next_double() * 0.2,
r.next_gaussian() * 0.15,
];
self.particles.add(&crumbs, [x, y, z], v);
}
let portal = ParticleOptions::simple(ParticleType::Portal);
let mut angle = 0.0f64;
while angle < std::f64::consts::PI * 2.0 {
let (c, s) = (angle.cos(), angle.sin());
let at = [x + c * 5.0, y - 0.4, z + s * 5.0];
self.particles.add(&portal, at, [c * -5.0, 0.0, s * -5.0]);
self.particles.add(&portal, at, [c * -7.0, 0.0, s * -7.0]);
angle += std::f64::consts::PI / 20.0;
}
}
/// `EyeOfEnder.tick` on a client: behind each eye (a quarter of its last
@@ -17,6 +17,8 @@ use horizon_core::{
random::RandomSource,
};
pub(super) const FIREWORK_EXPLODE_EVENT: i8 = 17;
pub(super) struct Rocket {
/// The entity id a server tracks it by (0 until it is tracked).
pub id: u32,
@@ -261,6 +263,14 @@ impl Game<'_> {
/// `FireworkRocketEntity.explode` and `dealExplosionDamage`.
fn explode_rocket(&mut self, r: &Rocket) {
let at = [r.position.x, r.position.y, r.position.z];
// FireworkRocketEntity.explode broadcasts before discard. Send now:
// tick_lan removes missing tracked entities before flushing effects.
if r.id != 0
&& let Some(host) = &self.lan
{
host.hub
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
}
if !r.explosions.is_empty() {
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
let from = r.position;
@@ -322,6 +332,16 @@ impl Game<'_> {
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
}
/// FireworkRocketEntity.handleEntityEvent: use the synchronized item data.
pub(super) fn remote_firework_explosion(&mut self, id: i32) {
let camera = self.eye_position();
if let Some(r) = self.rockets.iter().find(|r| r.id == id as u32) {
let at = [r.position.x, r.position.y, r.position.z];
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
}
}
/// `dealExplosionDamage` on a player that is not the rocket's
/// `attachedToEntity`: within 5 blocks, when the rocket sees its feet or
/// its middle (`ClipContext.Block.COLLIDER`, no fluids).
+15 -4
View File
@@ -355,12 +355,23 @@ impl Game<'_> {
* glam::DMat4::from_rotation_y(y_rot.to_radians());
let light = self.world.light(f.pos[0], f.pos[1], f.pos[2]);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// ItemFrameRenderer.getBlockLightLevel: a glow frame's model is at least 5 bright.
let frame_light = if f.glow {
(light & 0xF0) | (light & 15).max(5)
} else {
light
};
let frame_packed =
(u32::from(frame_light >> 4) * 16) | ((u32::from(frame_light & 15) * 16) << 8);
let mut quad =
|m: &glam::DMat4, corners: [[f64; 3]; 4], uv: [[f32; 2]; 4], light: u32| {
for (c, uv) in corners.iter().zip(uv) {
let p = m.transform_point3(glam::DVec3::from_array(*c));
// The frame model is drawn with the entity shader, lit by its face normal.
let at = corners.map(|c| m.transform_point3(glam::DVec3::from_array(c)));
let n = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = [0, 1, 2].map(|k| (n[k].clamp(-1.0, 1.0) * 127.0) as i8);
for (p, uv) in at.into_iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
normal: [normal[0], normal[1], normal[2], -127],
pos: p.as_vec3().to_array(),
uv,
layer: 0,
@@ -393,7 +404,7 @@ impl Game<'_> {
]
}),
map_uv(r, *uv),
packed,
frame_packed,
);
}
}
+1
View File
@@ -151,6 +151,7 @@ impl Game<'_> {
before(self.painting_hit().map(|h| h.1))
|| before(self.frame_hit().map(|h| h.1))
|| before(self.armor_stand_hit().map(|h| h.1))
|| before(self.cushion_hit().map(|h| h.1))
|| before(self.boat_hit().map(|h| h.1))
|| before(self.cart_hit().map(|h| h.1))
|| before(self.dragon_hit().map(|h| h.1))
+3 -1
View File
@@ -222,7 +222,6 @@ impl Game<'_> {
options.report = None;
options.server = None;
options.world = None;
options.dev_mods.clear();
self.join_world_rest()?;
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
let flags = Arc::new(Flags::default());
@@ -292,6 +291,9 @@ impl Game<'_> {
launch.create.as_ref(),
)?)?;
let saved = Box::new(self.level.clone());
// IntegratedServer runs the server mods; the window the client mods.
self.mods.dev = self.options.dev_mods.clone();
self.start_mods();
// Math.max(2, options.renderDistance): past vanilla's 32 when the
// window's render distance is (Horizon's options go to 64).
let view_distance = self.settings.render_distance.max(2);
+85 -8
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::{Mob, leash::Holder},
mobs::{Mob, Who, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -71,6 +71,8 @@ pub(super) struct LanHost {
/// Guests' commands waiting for the hub to be back in `self.lan`, where
/// a command can reach every player (game/lan_actions.rs).
pub commands: Vec<super::lan_actions::GuestCommand>,
/// Mods' quiet commands waiting to run a few a tick (game/lan_quiet.rs).
pub quiet: std::collections::VecDeque<super::lan_quiet::QuietBatch>,
/// The entities the guests track, described again every tick
/// (`lan_entities`) over last tick's states.
states: lan::entity::States,
@@ -145,6 +147,7 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
fn mob_meta(
m: &Mob,
hub: &Hub,
player_entity: Option<i32>,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
@@ -194,6 +197,32 @@ fn mob_meta(
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
match kind {
"minecraft:guardian" | "minecraft:elder_guardian" => {
// Guardian.DATA_ID_MOVING and DATA_ID_ATTACK_TARGET.
if e.flag2 {
meta.push((16, serializer::BOOLEAN, Value::Bool(true)));
}
let target = if e.flag {
m.ai.target.and_then(|who| match who {
Who::Player => player_entity,
Who::Mob(id) => Some(id as i32),
})
} else {
None
};
if let Some(target) = target {
meta.push((17, serializer::INT, Value::Int(target)));
}
defaults.push((16, serializer::BOOLEAN, Value::Bool(false)));
defaults.push((17, serializer::INT, Value::Int(0)));
}
// Bat.DATA_ID_FLAGS: resting in bit 0, flying by default.
"minecraft:bat" => {
if e.flag {
meta.push((16, serializer::BYTE, Value::Byte(1)));
}
defaults.push((16, serializer::BYTE, Value::Byte(0)));
}
// 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 };
@@ -596,6 +625,7 @@ impl Game<'_> {
waypoint_dead: Default::default(),
border_sent: None,
commands: Vec::new(),
quiet: std::collections::VecDeque::new(),
states: Default::default(),
});
self.audio.capture = Some(Vec::new());
@@ -634,6 +664,7 @@ impl Game<'_> {
self.world.light_log = None;
self.audio.capture = None;
self.lan_events.clear();
self.lan_critical_hits.clear();
self.streaming.extra.clear();
if announce {
self.system_message(Text::translate(
@@ -820,12 +851,13 @@ impl Game<'_> {
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
let player_entity = mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity);
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity),
Holder::Player => player_entity,
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
});
@@ -835,7 +867,7 @@ impl Game<'_> {
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
e.needs_sync = m.needs_sync;
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
mob_meta(m, hub, player_entity, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
@@ -960,6 +992,9 @@ impl Game<'_> {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
self.mod_player_chat(super::server_mods::Who::Uuid(sender), &text);
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
@@ -1029,6 +1064,14 @@ impl Game<'_> {
}
host.states = states;
}
if !host.quiet.is_empty() {
self.lan = Some(host);
self.run_quiet_commands();
host = self
.lan
.take()
.expect("the hub is back after the quiet commands");
}
self.lan_flush_effects(&mut host);
if !paused {
self.tick_guests(&mut host);
@@ -1162,8 +1205,30 @@ 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.
pub(super) fn critical_hit(
&mut self,
attacker: Option<u32>,
id: i32,
feet: [f64; 3],
width: f64,
height: f64,
) {
use horizon_client::particles::{ParticleOptions, ParticleType};
self.particles.tracking_emitter(
ParticleOptions::simple(ParticleType::Crit),
feet,
width,
height,
);
if self.audio.capture.is_some() {
self.lan_critical_hits.push((attacker, id));
}
}
/// Send the tick's level events, sounds and mob animations.
fn lan_flush_effects(&mut self, host: &mut LanHost) {
let critical_hits = std::mem::take(&mut self.lan_critical_hits);
for (kind, pos, data, acting) in std::mem::take(&mut self.lan_events) {
if kind == 2001 {
host.broken
@@ -1236,6 +1301,14 @@ impl Game<'_> {
seen.insert(m.id, (m.hurt_time, dead, swing));
}
host.mob_state.insert(self.dim, seen);
for (attacker, target) 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);
}
}
/// Keep the chunks around every guest loaded.
@@ -1403,7 +1476,11 @@ impl Game<'_> {
let tag = if secure { "" } else { "[Not Secure] " };
super::dedicated::log("INFO", &format!("{tag}{line}"));
}
HostEvent::Chat { line, .. } => self.system_message(Text::literal(line)),
// Player chat (and `/say`'s or `/me`'s, which are chat messages
// too): the host's Chat option decides, as `ChatListener` does
// for the guests' packets. Command feedback is a system message
// and takes the system-message permission instead.
HostEvent::Chat { line, .. } => self.player_message(Text::literal(line)),
HostEvent::Warn(line) if dedicated => super::dedicated::log("WARN", &line),
HostEvent::Warn(line) => log::warn!("{line}"),
HostEvent::Error(line) if dedicated => super::dedicated::log("ERROR", &line),
+105 -37
View File
@@ -77,6 +77,10 @@ pub(super) struct GuestPlay {
last_inventory: u64,
/// `Entity.portalProcess` and `portalCooldown` (game/server_travel.rs).
pub(super) portal: horizon_client::nether_portal::PortalState,
/// `ServerPlayer.wonGame`: the End poem is showing, the player waits for
/// its client's PERFORM_RESPAWN (`respawn_requested`).
pub(super) won_game: bool,
pub(super) respawn_requested: bool,
/// `Player.takeXpDelay`: ticks until the next orb can be taken.
xp_delay: u32,
/// What last killed the guest outright (`/kill`): the death message's key.
@@ -94,11 +98,14 @@ pub(super) struct GuestPlay {
/// A guest's command waiting for the hub (`LanHost::commands`).
pub(super) struct GuestCommand {
conn: u32,
command: String,
permission: u8,
pub(super) conn: u32,
pub(super) command: String,
pub(super) permission: u8,
/// An integrated server's: no level teleports (yet).
integrated: bool,
pub(super) integrated: bool,
/// A mod's quiet command (game/lan_quiet.rs): its output suppressed, as
/// `CommandSourceStack.withSuppressedOutput` does.
pub(super) quiet: bool,
}
/// The game's own state while a guest's is swapped in (`enter_guest`), and
@@ -872,7 +879,11 @@ impl Game<'_> {
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos)
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.map(|g| g.conn)
@@ -886,6 +897,35 @@ impl Game<'_> {
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
fn chest_vehicle_still_valid(&self, id: i32, g: &horizon_client::lan::guest::Guest) -> bool {
let bounds = self
.carts
.iter()
.find(|c| c.id as i32 == id)
.map(|c| c.bounds())
.or_else(|| {
self.boats
.iter()
.find(|b| b.id as i32 == id)
.map(|b| b.bounds())
});
let Some(b) = bounds else {
return false;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let (lo, hi) = (b.min_position(), b.max_position());
let d = [
eye[0] - eye[0].clamp(lo.x, hi.x),
eye[1] - eye[1].clamp(lo.y, hi.y),
eye[2] - eye[2].clamp(lo.z, hi.z),
];
let range = if g.creative() { 5.0 } else { 3.0 } + 4.0;
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] < range * range
}
/// A packet from a guest that the hub does not handle itself.
pub(super) fn lan_guest_packet(&mut self, host: &mut LanHost, conn: u32, packet: S) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
@@ -1210,6 +1250,9 @@ impl Game<'_> {
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
S::CustomPayload(p) if p.channel == horizon_protocol::quiet::CHANNEL => {
self.guest_quiet_commands(host, conn, &p.data)
}
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
S::ChangeDifficulty(p) if host.hub.guests[gi].permission >= 2 => {
if !self.level.difficulty_locked && (0..=3).contains(&p.difficulty) {
@@ -1308,6 +1351,13 @@ impl Game<'_> {
self.guest_suggestions(host, conn, &payload);
}
S::PlaceRecipe(p) => self.lan_place_recipe(host, conn, &p),
// handleClientCommand: PERFORM_RESPAWN after the poem leaves the End
// (the next server tick sends the player on).
S::ClientCommand(c)
if c.action == 0 && host.play.get(&conn).is_some_and(|p| p.won_game) =>
{
host.play.entry(conn).or_default().respawn_requested = true;
}
S::ClientCommand(c) if c.action == 0 => self.guest_respawn(host, conn),
// ServerStatsCounter.sendStats.
S::ClientCommand(c) if c.action == 1 => {
@@ -1533,6 +1583,10 @@ impl Game<'_> {
);
return;
}
// A server mod's command, for every player whatever their level.
if self.guest_server_mod_command(host, conn, command.trim_start_matches('/')) {
return;
}
// An integrated server runs every player's commands at their own
// permission level (`CommandSourceStack.permissions`); a LAN world
// follows its Allow Commands, and a dedicated server leaves it to
@@ -1559,6 +1613,7 @@ impl Game<'_> {
command: command.trim_start_matches('/').to_owned(),
permission,
integrated,
quiet: false,
});
}
@@ -1573,41 +1628,54 @@ impl Game<'_> {
return;
}
let c = host.commands.remove(0);
let mut host = self.lan.take().expect("checked above");
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
self.lan = Some(host);
continue;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
self.run_command(&c.command);
self.ui.registries.permission = level;
let lines = self.chat_capture.take().unwrap_or_default();
let mut host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
if !c.integrated {
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
self.run_guest_command(c);
}
}
/// One guest's command as that guest, its feedback sent to them; false
/// when it failed (or the guest is gone).
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
let Some(mut host) = self.lan.take() else {
return false;
};
let Some(swap) = self.enter_guest(&mut host, c.conn) else {
self.lan = Some(host);
return false;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, c.permission);
let ok = if c.quiet {
self.run_command_quietly(&c.command)
} else {
self.run_command(&c.command)
};
self.ui.registries.permission = level;
let lines = self.chat_capture.take().unwrap_or_default();
let mut host = self.lan.take().expect("a command puts the hub back");
self.leave_guest(&mut host, swap);
host.hub.sync_all_effects();
if !c.integrated {
// A teleport into another level happens before its feedback is sent.
self.run_level_teleports(&mut host);
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
ok
}
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
/// whose it was; none for the host's or the console's): every other
/// operator gets them, and the console logs them as the server's own
+5 -1
View File
@@ -28,7 +28,11 @@ impl Game<'_> {
/// SignableCommand.of: the command's message arguments, by name, with
/// their text, not past a redirect to the root (`execute ... run`).
fn signable_arguments(&mut self, permission: u8, command: &str) -> Vec<(String, String)> {
pub(super) fn signable_arguments(
&mut self,
permission: u8,
command: &str,
) -> Vec<(String, String)> {
let parse = self.parse_as(permission, command);
let nodes = &vanilla::game_dispatcher().nodes;
let root = parse.context.root;
@@ -245,6 +245,16 @@ impl Game<'_> {
}
out.push(e);
}
for c in &self.cushions {
let p = c.position;
let mut e = state(c.id, "minecraft:cushion", [p.x, p.y, p.z], c.yaw, false);
// Cushion.DATA_COLOR, set unless it is the default white.
if c.color != 0 {
e.meta
.push((8, serializer::DYE_COLOR, Value::Id(i32::from(c.color))));
}
out.push(e);
}
self.lan_projectile_states(host_entity, hub, out);
for o in &self.others {
let p = o.position;
@@ -25,6 +25,33 @@ fn rgb(c: [f32; 4]) -> [f32; 3] {
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
pub(super) fn lan_sonic_boom(&self, options: &ParticleOptions, at: [f64; 3]) {
let (Some(host), Some(particle)) = (&self.lan, self.network_particle(options, None)) else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: [0.0; 3],
count: 0,
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.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
+22
View File
@@ -32,6 +32,8 @@ pub(super) enum Victim {
/// The attacker's side of the hit.
pub(super) struct Strike {
/// The attacking guest, kept after their turn through `as_guest` ends.
attacker: Option<u32>,
amount: f32,
scale: f32,
crit: bool,
@@ -119,6 +121,7 @@ impl Game<'_> {
}
self.swing_arm();
Some(Strike {
attacker: self.lan_acting,
amount,
scale,
crit,
@@ -250,6 +253,25 @@ impl Game<'_> {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
};
if hurt && s.crit {
let target = match victim {
Victim::Host => self.player.as_ref().map(|p| (host.host_entity, p)),
Victim::Guest(conn) => host
.hub
.guest(conn)
.and_then(|g| g.motion.as_deref().map(|p| (g.entity_id, p))),
};
if let Some((id, p)) = target {
let d = p.dimensions;
self.critical_hit(
s.attacker,
id,
[p.position.x, p.position.y, p.position.z],
f64::from(d.width),
f64::from(d.height),
);
}
}
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
(true, true, _) => "minecraft:entity.player.attack.crit",
+165
View File
@@ -0,0 +1,165 @@
//! Quiet commands (`horizon_protocol::quiet`): a Horizon client's mod sends a
//! batch of command lines in one payload, and the server runs each exactly as
//! that player typing it would (`guest_command`'s checks, the player's own
//! permission level, at the time it runs) with its output suppressed, as
//! vanilla's functions run (`CommandSourceStack.withSuppressedOutput`): no
//! feedback and no failure in anyone's chat. A batch waits in a queue and runs
//! [`PER_TICK`] lines a player a tick, so a mod can't flood a server; each
//! finished batch is one line in the server's log.
use super::{Game, lan::LanHost, lan_actions::GuestCommand};
use horizon_protocol::quiet;
use std::collections::{HashMap, VecDeque};
/// Lines a player may have waiting at most; a batch past it loses the rest.
pub(super) const PENDING: usize = 4096;
/// Lines run a player a tick at most.
pub(super) const PER_TICK: usize = 64;
/// A batch waiting to run.
pub(super) struct QuietBatch {
conn: u32,
mod_id: String,
lines: VecDeque<String>,
ran: usize,
failed: usize,
/// Lines past [`PENDING`], never run.
dropped: usize,
}
impl Game<'_> {
/// A `horizon:quiet_commands` payload from `conn`: queued (or logged as
/// refused when it breaks the payload's caps).
pub(super) fn guest_quiet_commands(&mut self, host: &mut LanHost, conn: u32, data: &[u8]) {
let Some(name) = host.hub.guest(conn).map(|g| g.name.clone()) else {
return;
};
let batch = match quiet::decode(data) {
Ok(b) => b,
Err(e) => {
super::dedicated::log(
"WARN",
&format!("{name} sent quiet commands that were refused: {e}"),
);
return;
}
};
let waiting: usize = host
.quiet
.iter()
.filter(|b| b.conn == conn)
.map(|b| b.lines.len())
.sum();
let room = PENDING.saturating_sub(waiting);
let dropped = batch.lines.len().saturating_sub(room);
host.quiet.push_back(QuietBatch {
conn,
mod_id: batch.mod_id,
lines: batch.lines.into_iter().take(room).collect(),
ran: 0,
failed: 0,
dropped,
});
}
/// The waiting batches, [`PER_TICK`] lines a player this tick, in order.
pub(super) fn run_quiet_commands(&mut self) {
let mut left: HashMap<u32, usize> = HashMap::new();
let mut i = 0;
loop {
let Some(host) = self.lan.as_mut() else {
return;
};
let Some(batch) = host.quiet.get_mut(i) else {
return;
};
let conn = batch.conn;
let budget = left.entry(conn).or_insert(PER_TICK);
let gone = host.hub.guest(conn).is_none();
if gone || batch.lines.is_empty() {
let batch = host.quiet.remove(i).expect("got it above");
if !gone {
let name = host
.hub
.guest(conn)
.map_or(String::new(), |g| g.name.clone());
super::dedicated::log("INFO", &summary(&name, &batch));
}
continue;
}
if *budget == 0 {
i += 1;
continue;
}
*budget -= 1;
let line = batch.lines.pop_front().expect("not empty");
let ok = self.run_quiet_line(conn, &line);
if let Some(batch) = self.lan.as_mut().and_then(|h| h.quiet.get_mut(i)) {
batch.ran += 1;
batch.failed += usize::from(!ok);
}
}
}
/// One line as `guest_command` would run it for `conn`, quietly. False
/// when it is refused or fails. A command that carries a chat message
/// (`say`, `msg`...) is refused: it would need the player's signature.
fn run_quiet_line(&mut self, conn: u32, line: &str) -> bool {
let allowed = self.options.dedicated.is_some() || self.allow_commands();
let Some(host) = self.lan.as_ref() else {
return false;
};
let integrated = host.hub.config.integrated;
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
permission,
integrated,
quiet: true,
})
}
}
/// The batch's line in the server's log.
fn summary(name: &str, b: &QuietBatch) -> String {
let mut line = format!(
"{name} ran {} quiet command(s) from mod {}: {} failed",
b.ran, b.mod_id, b.failed
);
if b.dropped > 0 {
line += &format!(", {} dropped (more than {PENDING} waiting)", b.dropped);
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_log_line_counts_the_batch() {
let b = QuietBatch {
conn: 1,
mod_id: "orbital".into(),
lines: VecDeque::new(),
ran: 12,
failed: 3,
dropped: 0,
};
assert_eq!(
summary("Alex", &b),
"Alex ran 12 quiet command(s) from mod orbital: 3 failed"
);
let b = QuietBatch { dropped: 5, ..b };
assert!(summary("Alex", &b).ends_with(", 5 dropped (more than 4096 waiting)"));
}
}
@@ -144,15 +144,16 @@ impl Game<'_> {
.offers
.iter()
.filter_map(|o| {
let cost =
|st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| play::NetCost {
item: hub.item_id(&st.item),
count: st.count as i32,
};
let cost = |st: &ItemStack, hub: &mut horizon_client::lan::hub::Hub| {
hub.stack(&Some(st.clone())).map(play::NetCost::from)
};
Some(play::NetOffer {
cost_a: cost(&o.base_cost_a.stack, &mut host.hub),
cost_a: cost(&o.base_cost_a.stack, &mut host.hub)?,
result: host.hub.stack(&Some(o.result.clone()))?,
cost_b: o.cost_b.as_ref().map(|b| cost(&b.stack, &mut host.hub)),
cost_b: match &o.cost_b {
Some(b) => Some(cost(&b.stack, &mut host.hub)?),
None => None,
},
out_of_stock: o.out_of_stock(),
uses: o.uses,
max_uses: o.max_uses,
+26 -1
View File
@@ -56,6 +56,13 @@ impl Game<'_> {
let (lo, hi) = corners(a.bounds());
return Some((lo, hi, at(a.position)));
}
if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
let (lo, hi) = corners(Aabb::new(
Vec3::new(c.position.x - 0.5, c.position.y, c.position.z - 0.5),
Vec3::new(c.position.x + 0.5, c.position.y + 0.25, c.position.z + 0.5),
));
return Some((lo, hi, at(c.position)));
}
if let Some(f) = self.item_frames.iter().find(|f| f.id == id) {
let (lo, hi) = f.bounds();
return Some((lo, hi, centre(f.pos)));
@@ -98,9 +105,10 @@ impl Game<'_> {
let boat = self.boats.iter().position(|b| b.id == vehicle);
let cart = self.carts.iter().any(|c| c.id == vehicle);
let mount = self.mobs.iter().any(|m| m.id == vehicle);
let cushion = self.cushions.iter().any(|c| c.id == vehicle);
// The vehicle is gone (broken, in another level) or the rider died:
// its client drops it with the entity or the death screen.
if dead || (boat.is_none() && !cart && !mount) {
if dead || (boat.is_none() && !cart && !mount && !cushion) {
let g = &mut host.hub.guests[gi];
(g.vehicle, g.vehicle_good) = (None, None);
return;
@@ -115,6 +123,17 @@ impl Game<'_> {
// Getting out goes through leave_guest, which tells the client and
// teleports it (ServerPlayer.dismountTo).
let seat = self.as_guest(host, conn, |game| {
// A cushion does not move: the rider sits on it until it sneaks
// (`Cushion.removePassenger` plays the get-up sound).
if game.cushions.iter().any(|c| Some(c.id) == game.riding) {
if keys.shift {
game.tick_cushion_riding(keys);
return None;
}
let c = game.cushions.iter().find(|c| Some(c.id) == game.riding)?;
let p = c.seat();
return Some([p.x, p.y, p.z]);
}
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
if keys.shift {
game.dismount(i);
@@ -168,6 +187,12 @@ impl Game<'_> {
.iter()
.find(|c| c.id == vehicle)
.map(|c| (c.position, c.position.add_vector(Vec3::new(0.0, SEAT, 0.0))))
})
.or_else(|| {
self.cushions
.iter()
.find(|c| c.id == vehicle)
.map(|c| (c.position, c.seat()))
});
if let Some((p, seat)) = at {
let g = &mut host.hub.guests[gi];
+6
View File
@@ -50,6 +50,7 @@ pub(super) struct Parked {
paintings: Vec<horizon_client::entities::Painting>,
item_frames: Vec<horizon_client::entities::ItemFrame>,
armor_stands: Vec<horizon_client::entities::ArmorStand>,
cushions: Vec<horizon_client::entities::Cushion>,
others: Vec<commands::OtherEntity>,
boats: Vec<boats::Boat>,
carts: Vec<minecarts::Minecart>,
@@ -74,6 +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)>,
/// Only the per-level parts of these are used (`swap_level`).
anims: BlockEntityAnims,
raiding: Raiding,
@@ -135,6 +137,7 @@ impl Game<'_> {
paintings: Vec::new(),
item_frames: Vec::new(),
armor_stands: Vec::new(),
cushions: Vec::new(),
others: Vec::new(),
boats: Vec::new(),
carts: Vec::new(),
@@ -159,6 +162,7 @@ impl Game<'_> {
lan_events: Vec::new(),
lan_block_events: Vec::new(),
lan_entity_events: Vec::new(),
lan_critical_hits: Vec::new(),
anims: Default::default(),
raiding: Default::default(),
sculk: Default::default(),
@@ -185,6 +189,7 @@ impl Game<'_> {
swap(&mut self.paintings, &mut p.paintings);
swap(&mut self.item_frames, &mut p.item_frames);
swap(&mut self.armor_stands, &mut p.armor_stands);
swap(&mut self.cushions, &mut p.cushions);
swap(&mut self.others, &mut p.others);
swap(&mut self.boats, &mut p.boats);
swap(&mut self.carts, &mut p.carts);
@@ -209,6 +214,7 @@ impl Game<'_> {
swap(&mut self.lan_events, &mut p.lan_events);
swap(&mut self.lan_block_events, &mut p.lan_block_events);
swap(&mut self.lan_entity_events, &mut p.lan_entity_events);
swap(&mut self.lan_critical_hits, &mut p.lan_critical_hits);
let (a, b) = (&mut self.be_anims, &mut p.anims);
swap(&mut a.chests, &mut b.chests);
swap(&mut a.books, &mut b.books);
+3 -3
View File
@@ -88,8 +88,8 @@ pub(super) struct Minecart {
/// Hopper minecarts: an unpowered activator rail enables them.
pub enabled: bool,
}
const WIDTH: f64 = 0.98;
const HEIGHT: f64 = 0.7;
pub(super) const WIDTH: f64 = 0.98;
pub(super) const HEIGHT: f64 = 0.7;
/// AbstractMinecart.getDefaultDisplayOffset, in sixteenths of a block.
fn display_offset(kind: CartKind) -> f64 {
if kind == CartKind::Hopper { 1.0 } else { 6.0 }
@@ -97,7 +97,7 @@ fn display_offset(kind: CartKind) -> f64 {
/// The minecart's passenger attachment minus Player.DEFAULT_VEHICLE_ATTACHMENT.
pub(super) const SEAT: f64 = 0.1875 - 0.6;
/// The placeholder container key under which an open cart's items live.
pub(super) const CART_KEY_Y: i32 = -100_000;
pub(super) const CART_KEY_Y: i32 = horizon_client::lan::menu::VEHICLE_KEY_Y;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Shape {
+144 -31
View File
@@ -17,6 +17,7 @@ use std::{path::PathBuf, time::Duration};
#[cfg(feature = "mods")]
use {
super::Screen,
super::{execution_role::ExecutionRole, lan::LanHost, server_mods::ModEvent},
horizon_client::{
extras::Corner,
extras_hud,
@@ -28,7 +29,8 @@ use {
ui,
},
horizon_mods::{
Clock, ModKey, Mods, Output, Player, Runs, Snapshot, Status, UiScreen, UiWidget,
Clock, ModKey, Mods, Output, Player, Runs, ServerPlayer, Snapshot, Status, UiScreen,
UiWidget,
},
std::time::Instant,
winit::{event::MouseButton, keyboard::KeyCode},
@@ -52,6 +54,15 @@ pub(super) struct ModsState {
/// The chat's command tree while a running mod owns commands.
#[cfg(feature = "mods")]
tree: Option<Dispatcher>,
/// A server mod runs here: what players do is queued for it.
#[cfg(feature = "mods")]
pub(super) server: bool,
/// The players server mods saw last tick, for joins and leaves.
#[cfg(feature = "mods")]
pub(super) players: Vec<ServerPlayer>,
/// What players did this tick, for server mods at its end.
#[cfg(feature = "mods")]
pub(super) events: Vec<ModEvent>,
/// `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.
@@ -272,7 +283,7 @@ impl Game<'_> {
.collect();
let enabled = &self.settings.mods.enabled;
let on = |id: &str| enabled.contains(id) || dev_ids.iter().any(|d| d == id);
let mods = Mods::load_for(Runs::Client, &paths, on, vanilla_command);
let mods = Mods::load_for(self.mod_runs(), &paths, on, vanilla_command);
self.mods.mods = mods;
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
@@ -282,8 +293,18 @@ impl Game<'_> {
self.sync_mod_commands();
self.show_mod_output();
}
/// Which mods this game runs: a server the server mods, the window
/// client mods, and both when it simulates the world itself (in-frame).
fn mod_runs(&self) -> Runs {
match self.options.execution_role {
ExecutionRole::IntegratedServer | ExecutionRole::DedicatedServer => Runs::Server,
ExecutionRole::Client if self.options.server_thread => Runs::Client,
ExecutionRole::Client => Runs::Both,
}
}
/// The Mods page's rows, from what loaded.
fn list_mods(&mut self) {
self.mods.server = self.mods.mods.runs_server();
let dev = &self.mods.dev;
self.settings.mods.installed = self
.mods
@@ -369,8 +390,18 @@ impl Game<'_> {
self.start_mods();
}
}
/// What mods read this tick (or frame, for mods with a `frame` hook).
/// What mods read this tick (or frame, for mods with a `frame` hook): the
/// server's players as they were at the last tick's end.
fn mod_snapshot(&mut self) -> Snapshot {
let players = if self.mods.server {
self.mods.players.clone()
} else {
Vec::new()
};
self.mod_snapshot_with(players)
}
/// What mods read, with these players on the server.
pub(super) fn mod_snapshot_with(&mut self, players: Vec<ServerPlayer>) -> Snapshot {
let player = self.player.as_ref().map(|p| Player {
x: p.position.x,
y: p.position.y,
@@ -414,9 +445,66 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
players,
view_hit: self.mod_view_hit(),
}
}
/// The first block along the player's view within
/// [`horizon_mods::RAYCAST_MAX`] and its distance from the eye
/// (`game.raycast`): through the loaded chunks as the crosshair's ray
/// goes (fluids pass), then across the distant terrain's ground heights,
/// a block at a time.
fn mod_view_hit(&self) -> Option<(f32, horizon_mods::Hit)> {
if !self.mods.mods.wants_raycast() {
return None;
}
let eye = self.eye_position()?;
let dir = super::view_vector(self.yaw, self.pitch);
let reach = f64::from(horizon_mods::RAYCAST_MAX);
let (pos, point, state) = match self.clip_ray(eye, dir, reach) {
Some(hit) => (
hit.pos,
hit.point,
self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]),
),
None => self.lod_ray(eye, dir, reach)?,
};
let distance = (0..3).map(|i| (point[i] - eye[i]).powi(2)).sum::<f64>();
let hit = horizon_mods::Hit {
block_x: pos[0],
block_y: pos[1],
block_z: pos[2],
x: point[0],
y: point[1],
z: point[2],
block: self.res.facts().get(state).name.to_string(),
};
Some((distance.sqrt() as f32, hit))
}
/// Past the resident chunks: the view walked across the distant
/// terrain's ground heights (`Lod::ground`), a block at a time. Columns in
/// loaded chunks are left to `clip_ray`, which has already missed them.
fn lod_ray(
&self,
eye: [f64; 3],
dir: [f64; 3],
reach: f64,
) -> Option<([i32; 3], [f64; 3], horizon_world::registry::BlockStateId)> {
let lod = self.lod.as_ref()?;
let mut t = 0.0;
while t <= reach {
let p = [0, 1, 2].map(|i| eye[i] + dir[i] * t);
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
if !self.world.loaded(x, z)
&& let Some((height, state)) = lod.ground(x, z)
&& p[1] <= f64::from(height)
{
return Some(([x, height - 1, z], [p[0], f64::from(height), p[2]], state));
}
t += 1.0;
}
None
}
/// A game tick.
pub(super) fn tick_mods(&mut self) {
// Here, at the player's level: shown inside a console call (a control
@@ -433,23 +521,32 @@ impl Game<'_> {
self.mods.mods.tick(self.ticks);
self.show_mod_output();
}
/// Once per frame: the frame hook, and dev mods' rebuilds.
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
if !self.mods.dev.is_empty()
&& self
/// Restarts the dev mods rebuilt since the last check, once a second.
pub(super) fn reload_dev_mods(&mut self) {
if self.mods.dev.is_empty()
|| self
.mods
.checked
.is_none_or(|t| t.elapsed() >= RELOAD_CHECK)
.is_some_and(|t| t.elapsed() < RELOAD_CHECK)
{
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
return;
}
self.mods.checked = Some(Instant::now());
if self.mods.mods.reload_changed(vanilla_command) {
let chosen = &self.settings.mods;
self.mods.mods.set_bindings(|id| chosen.keys_of(id));
self.list_mods();
self.sync_mod_commands();
if self.options.execution_role.is_client() {
self.system_message(Text::literal("Mods reloaded").with_color(text::GRAY));
} else {
log::info!("Horizon: mods reloaded");
}
}
}
/// Once per frame: the frame hook, and dev mods' rebuilds.
pub(super) fn mods_frame(&mut self, elapsed: Duration) {
self.reload_dev_mods();
if !self.mods.mods.is_running() {
return;
}
@@ -480,8 +577,13 @@ impl Game<'_> {
self.mods.mods.chat(line);
self.show_mod_output();
}
/// Chat lines, command feedback and stopped mods, into the chat.
fn show_mod_output(&mut self) {
/// What mods showed: client mods' lines into the chat, server mods'
/// to their players.
pub(super) fn show_mod_output(&mut self) {
self.route_mod_output(None);
}
/// [`Game::show_mod_output`], with the hub when it is out of `self.lan`.
pub(super) fn route_mod_output(&mut self, mut host: Option<&mut LanHost>) {
for output in self.mods.mods.drain() {
let line = match output {
Output::Chat { name, text } => {
@@ -509,7 +611,16 @@ impl Game<'_> {
// `mod_ui_view` finds no widgets and the screen closes.
self.list_mods();
self.sync_mod_commands();
Text::literal(format!("Mod {name} stopped: {reason}")).with_color(text::RED)
let line = format!("Mod {name} stopped: {reason}");
if self.options.dedicated.is_some() {
super::dedicated::log("WARN", &line);
}
Text::literal(line).with_color(text::RED)
}
server
@ (Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. }) => {
self.route_server_output(host.as_deref_mut(), server);
continue;
}
Output::OpenScreen { owner } => {
self.open_mod_screen(owner, false);
@@ -525,20 +636,8 @@ impl Game<'_> {
}
continue;
}
// Server mods' lines: this game runs no server mod yet.
Output::Tell { .. } | Output::Broadcast { .. } | Output::Reply { .. } => continue,
};
// Not through system_message: a mod must not hear its own lines.
// Written by this client, so the Chat option doesn't hide it.
let focused = self.screen == Screen::Chat;
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Client,
self.ticks,
focused,
);
self.add_mod_chat_line(line);
}
// A mod may set or clear its settings page in any call, not only
// while loading: its row's Settings button follows.
@@ -547,6 +646,20 @@ impl Game<'_> {
row.has_settings = e.has_ui_settings();
}
}
/// A mod's line in the window's chat; not through system_message: a mod
/// must not hear its own lines.
/// Written by this client, so the Chat option doesn't hide it.
pub(super) fn add_mod_chat_line(&mut self, line: Text) {
let focused = self.screen == Screen::Chat;
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Client,
self.ticks,
focused,
);
}
/// The mods' HUD panels, stacked with the extras in their corners.
pub(super) fn draw_mod_panels(&self, batch: &mut HudBatch, corners: &mut Corners) {
for panel in self.mods.mods.panels() {
+7 -3
View File
@@ -86,6 +86,10 @@ impl Game<'_> {
.iter()
.filter_map(|m| Some((m, m.custom_name.clone()?, false)));
let players = players.into_iter().map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
let ridden: std::collections::HashSet<u32> =
self.mobs.iter().filter_map(|o| o.extra.mount).collect();
for (m, name, player) in named.chain(players) {
if !m.alive() {
continue;
@@ -134,7 +138,7 @@ impl Game<'_> {
invisible_to_viewer: invisible,
hud_hidden: self.ui.hide_gui,
is_camera: false,
is_vehicle: self.mobs.iter().any(|o| o.extra.mount == Some(m.id)),
is_vehicle: ridden.contains(&m.id),
};
// MobRenderer.shouldShowName: a visible name, or the mob under the
// crosshair; players (LivingEntityRenderer) need neither.
@@ -310,7 +314,7 @@ impl Game<'_> {
}
/// The lines of one name tag, `o` from the camera: the text at
/// `text_alpha` in its layer, on the translucent black background
/// (unlit, a hair behind) when `background`.
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
&self,
@@ -382,7 +386,7 @@ impl Game<'_> {
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
2,
text_layer,
);
}
let mut x = x0;
+23 -1
View File
@@ -3,7 +3,10 @@
//! window, waits until it stands in the world with the chunks around it,
//! sends a chat message and `/list`, collects the chat it is shown, leaves
//! and writes a JSON report (position, chunks, chat lines, whether the
//! server kept it connected throughout).
//! server kept it connected throughout). With `HORIZON_NET_QUIET` (lines
//! split by `;`) it then sends them as one batch of a mod's quiet commands
//! (`horizon_protocol::quiet`, `tools/quiet_commands_e2e.py`) and waits two
//! seconds for any chat they bring.
use super::Game;
use horizon_client::Result;
use serde_json::json;
@@ -23,6 +26,9 @@ impl Game<'_> {
let mut step_at = Instant::now();
let mut chat_at = 0;
let mut position = None;
let quiet: Vec<String> = std::env::var("HORIZON_NET_QUIET")
.map(|v| v.split(';').map(str::to_owned).collect())
.unwrap_or_default();
let mut chunks = 0;
let mut error = None;
while Instant::now() < deadline {
@@ -71,6 +77,22 @@ impl Game<'_> {
step_at = now;
chat_at = shown;
}
2 if answered && !quiet.is_empty() => {
for data in horizon_protocol::quiet::encode("net_harness", &quiet) {
self.send(horizon_protocol::packets::play::Serverbound::CustomPayload(
horizon_protocol::packets::configuration::CustomPayload {
channel: horizon_protocol::quiet::CHANNEL.into(),
data,
},
));
}
step = 4;
step_at = now;
}
4 if now - step_at > Duration::from_secs(2) => {
step = 3;
break;
}
2 if answered => {
step = 3;
break;
@@ -454,11 +454,24 @@ impl Game<'_> {
for m in &self.mobs {
self.portals.before.insert(m.id, m.position);
}
for b in &self.boats {
self.portals.before.insert(b.id, b.position);
}
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
let ids: Vec<(u32, bool)> = self
.dropped
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.boats
.iter()
.map(|b| b.id)
.chain(self.carts.iter().map(|c| c.id))
.map(|id| (id, !self.vehicle_occupied(id))),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -501,8 +514,15 @@ impl Game<'_> {
&& self.riding != Some(m.id)
&& m.species() != Species::Wither
}
/// `NetherPortalBlock.entityInside` for the items and mobs that moved this
/// tick: the portal blocks their box swept through.
/// Whether the player, a server player or a mob rides vehicle `id`.
fn vehicle_occupied(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` for the items, mobs and empty boats
/// and minecarts that moved this tick: the portal blocks their box swept
/// through.
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
return;
@@ -519,8 +539,23 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let vehicles: Vec<(u32, Vec3, f32, f32)> = self
.boats
.iter()
.map(|b| (b.id, b.position, super::boats::WIDTH, super::boats::HEIGHT))
.chain(self.carts.iter().map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH,
super::minecarts::HEIGHT,
)
}))
.filter(|(id, ..)| !self.vehicle_occupied(*id))
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs) {
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
@@ -678,6 +713,44 @@ impl Game<'_> {
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = &self.boats[i];
let (width, height) = (super::boats::WIDTH as f32, super::boats::HEIGHT as f32);
let plan = self.portal_plan(entry, b.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist_raw(id),
b.chest.then_some(&b.items[..]),
);
self.boats.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let c = &self.carts[i];
let (width, height) = (
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
);
let plan = self.portal_plan(entry, c.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
}
/// Entities waiting for the loaded dimension whose exit area is loaded:
@@ -268,6 +268,11 @@ impl Game<'_> {
r.animated_textures = s.horizon.animated_textures;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
r.set_world_post(s.horizon.resolution_scale as f32 / 100.0, s.horizon.fxaa);
r.set_hdr(
s.horizon.hdr,
s.horizon.hdr_paper_white as f32,
s.horizon.hdr_peak as f32,
);
}
// Window.updateFullscreenIfChanged: only a changed option asks the
// window to change, so applying settings never undoes a fullscreen
+27
View File
@@ -314,6 +314,13 @@ impl Game<'_> {
self.item_frames.push(f);
}
}
"minecraft:cushion" => {
self.next_entity += 1;
if let Some(c) = entity_nbt::cushion_from_nbt(&tag, self.next_entity) {
self.remember(c.id, uuid, tag);
self.cushions.push(c);
}
}
"minecraft:painting" => {
if let Some((pos, facing, variant)) = entity_nbt::painting_from_nbt(&tag)
&& let Some(v) = self.painting_variants.get(&variant).cloned()
@@ -776,6 +783,18 @@ impl Game<'_> {
out.entry(chunk_of(at)).or_default().push(tag);
}
}
for c in &self.cushions {
if keys.contains(&chunk_of(c.position)) {
let uuid = self
.persist
.uuids
.get(&c.id)
.copied()
.unwrap_or_else(random_uuid);
let tag = entity_nbt::cushion_to_nbt(c, uuid, self.persist.raw.get(&c.id));
out.entry(chunk_of(c.position)).or_default().push(tag);
}
}
for p in &self.paintings {
let at = painting_center(p);
if keys.contains(&chunk_of(at)) {
@@ -868,6 +887,13 @@ impl Game<'_> {
}
keep
});
self.cushions.retain(|c| {
let keep = !gone(c.position);
if !keep {
removed.push(c.id);
}
keep
});
// Riders leave with their root vehicle, a boat's with the boat.
let leaving: HashSet<u32> = (0..self.mobs.len())
.filter(|&i| {
@@ -934,6 +960,7 @@ impl Game<'_> {
.chain(self.boats.iter().map(|e| e.id))
.chain(self.carts.iter().map(|e| e.id))
.chain(self.armor_stands.iter().map(|e| e.id))
.chain(self.cushions.iter().map(|e| e.id))
.chain(self.item_frames.iter().map(|e| e.id))
.chain(self.paintings.iter().map(|e| e.id))
.chain(self.others.iter().map(|e| e.id))
+71 -20
View File
@@ -141,14 +141,22 @@ impl PotionState {
t.stack = stack;
}
}
/// One tick of a server's client: `step` moves each toward where the
/// server put it.
pub(super) fn step_remote_thrown(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
for t in &mut self.thrown {
t.previous = t.position;
t.age += 1;
t.position = step(t.id, t.position);
}
/// A server's move: thrown potions have no interpolation handler, so
/// `moveOrInterpolateTo` puts them there at once. False when `id` isn't one.
pub(super) fn move_remote_thrown(&mut self, id: u32, at: Vec3) -> bool {
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
return false;
};
t.position = at;
true
}
/// `handleSetEntityMotion` for a thrown potion. False when `id` isn't one.
pub(super) fn remote_thrown_motion(&mut self, id: u32, velocity: Vec3) -> bool {
let Some(t) = self.thrown.iter_mut().find(|t| t.id == id) else {
return false;
};
t.velocity = velocity;
true
}
pub(super) fn remove_remote_thrown(&mut self, id: u32) {
self.thrown.retain(|t| t.id != id);
@@ -484,6 +492,7 @@ impl Game<'_> {
thrown.retain_mut(|t| {
t.age += 1;
t.previous = t.position;
self.thrown_potion_motion(t);
let next = Vec3::new(
t.position.x + t.velocity.x,
t.position.y + t.velocity.y,
@@ -520,18 +529,6 @@ impl Game<'_> {
return false;
}
t.position = next;
// ThrowableItemProjectile: drag 0.99 then gravity 0.05.
// ThrownExperienceBottle.getDefaultGravity is 0.07.
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
0.07
} else {
0.05
};
t.velocity = Vec3::new(
t.velocity.x * 0.99,
t.velocity.y * 0.99 - gravity,
t.velocity.z * 0.99,
);
t.age < 1200 && t.position.y > -128.0
});
self.potions.thrown = thrown;
@@ -539,6 +536,60 @@ impl Game<'_> {
self.splash(&stack, at);
}
}
/// `ThrowableProjectile.tick`'s `applyGravity` and `applyInertia`, which
/// come before the move: gravity 0.05 (0.07 for an experience bottle),
/// then drag 0.99 (0.8 in water).
fn thrown_potion_motion(&self, t: &mut ThrownPotion) {
let gravity = if &*t.stack.item == "minecraft:experience_bottle" {
0.07
} else {
0.05
};
let at = t.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
let inertia = if water { 0.8 } else { 0.99 };
t.velocity = Vec3::new(
t.velocity.x * inertia,
(t.velocity.y - gravity) * inertia,
t.velocity.z * inertia,
);
}
/// `ThrowableProjectile.tick` on a server's client: it falls and moves
/// by itself between the server's updates and stops at a block; only
/// the server breaks it.
pub(super) fn step_remote_thrown(&mut self) {
let mut thrown = std::mem::take(&mut self.potions.thrown);
for t in &mut thrown {
t.previous = t.position;
t.age += 1;
self.thrown_potion_motion(t);
let next = Vec3::new(
t.position.x + t.velocity.x,
t.position.y + t.velocity.y,
t.position.z + t.velocity.z,
);
let state = self.world.block(
next.x.floor() as i32,
next.y.floor() as i32,
next.z.floor() as i32,
);
if self.res.facts().get(state).full_collision {
t.velocity = Vec3::ZERO;
} else {
t.position = next;
}
}
self.potions.thrown = thrown;
}
/// `ThrownItemRenderer` for thrown splash and lingering potions and
/// experience bottles: the item (GROUND) facing the camera at scale 1,
/// lit at the light probe (the eye, 0.85 of the 0.25 height).
+110 -35
View File
@@ -11,7 +11,7 @@ use super::{Dim, Game, Mining, Screen};
use horizon_client::first_person::{Hand, SwingAnimation};
use horizon_client::{
Result,
chunk::{Chunk, SECTIONS},
chunk::Chunk,
containers::{BlockEntity, Furnace, FurnaceKind},
entities::{Experience, ExperienceOrb, ItemEntity},
hud::{HudBatch, rgba},
@@ -839,7 +839,10 @@ impl Game<'_> {
remote.brand = brand;
}
}
Event::Reconfigured(registries) => self.set_registries(registries),
Event::Reconfigured(registries) => {
self.boss_overlay.reset();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
@@ -998,6 +1001,9 @@ impl Game<'_> {
self.world.day_time,
self.world.weather,
);
let old = std::mem::take(&mut self.world.chunks);
let count = old.len();
horizon_client::chunk::free_columns(old, count);
self.world = World::new(self.res.facts().air, spawn.seed);
self.world.game_time = time;
self.world.day_time = day;
@@ -1059,14 +1065,14 @@ impl Game<'_> {
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in &gone {
self.streaming.forget(&mut self.world, x, z);
if let Some(renderer) = self.renderer.as_mut() {
for sy in 0..SECTIONS as i32 {
renderer.remove((x, sy, z));
}
}
freed.extend(self.streaming.forget(&mut self.world, x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
}
horizon_client::chunk::free_columns(freed, gone.len());
// One pass for all of them.
self.world
.block_entities
@@ -1255,6 +1261,9 @@ impl Game<'_> {
} else {
self.res.mob_types().get(kind).cloned()
};
if kind == "minecraft:ender_dragon" {
self.spawn_remote_dragon(id, position, yaw);
}
if kind == "minecraft:experience_orb" {
// Its value (ExperienceOrb.DATA_VALUE) follows in SetEntityData.
self.orbs.retain(|o| o.id != id as u32);
@@ -1361,13 +1370,17 @@ impl Game<'_> {
m.on_ground = on_ground;
return;
}
if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| {
p.id == id as u32
&& matches!(
p.kind,
horizon_client::projectiles::ProjectileKind::LargeFireball { .. }
)
}) {
// Projectiles keep Entity's NO_OP InterpolationHandler too: the
// server's position replaces theirs at once and their own tick
// flies them on (`tick_remote_projectiles`). Eased towards it
// instead, they only moved when an update came, every 10 to 20
// ticks.
if let Some(p) = self
.creatures
.projectiles
.iter_mut()
.find(|p| p.id == id as u32)
{
if path.is_some() {
p.position = Vec3::new(position[0], position[1], position[2]);
}
@@ -1377,6 +1390,14 @@ impl Game<'_> {
}
return;
}
if path.is_some()
&& (self.potions.move_remote_thrown(
id as u32,
Vec3::new(position[0], position[1], position[2]),
) || self.end.move_remote_eye(id as u32, position))
{
return;
}
if let Some(p) = r.pending_items.get_mut(&id) {
p.0 = position;
}
@@ -1423,6 +1444,8 @@ impl Game<'_> {
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
if let Some(r) = self.remote.as_mut() {
@@ -1631,9 +1654,7 @@ impl Game<'_> {
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
if let Some(renderer) = self.renderer.as_mut() {
for sy in 0..SECTIONS as i32 {
renderer.remove((x, sy, z));
}
renderer.remove_columns([(x, z)]);
}
}
C::ChunkBatchFinished(_) => {
@@ -1957,6 +1978,7 @@ impl Game<'_> {
}
self.remote_object_data(p.id, &p.values);
self.remote_projectile_data(p.id, &p.values);
self.remote_dragon_data(p.id, &p.values);
}
// ClientPacketListener.handleSetEquipment: what a mob or another
// player holds and wears (EquipmentSlot ordinals).
@@ -2038,6 +2060,8 @@ impl Game<'_> {
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::HurtAnimation(p) => {
@@ -2047,9 +2071,14 @@ impl Game<'_> {
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
}
}
C::EntityEvent(p) => {
if p.event == super::fireworks::FIREWORK_EXPLODE_EVENT {
self.remote_firework_explosion(p.id);
}
// LocalPlayer.setPermissions also refreshes an open World Options screen.
if (24..=28).contains(&p.event)
&& self.remote.as_ref().is_some_and(|r| r.entity_id == p.id)
@@ -2935,6 +2964,7 @@ impl Game<'_> {
/// entities whose own tick moves them on a client: dropped items, falling
/// blocks and primed TNT.
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
d.velocity = v;
@@ -2955,6 +2985,8 @@ impl Game<'_> {
.and_then(|r| r.pending_items.get_mut(&id))
{
p.1 = v;
} else if !self.potions.remote_thrown_motion(id as u32, v) {
self.end.remote_eye_motion(id as u32, velocity);
}
}
fn apply_remote_health(&mut self) {
@@ -3061,6 +3093,12 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Player.tick's ItemCooldowns.tick: this path replaces `tick_world`'s
// for a server's client (multiplayer and the default single player),
// so the entries advance here or the slot stays shaded until the
// server says the entry ended. `onCooldownEnded` is the plain
// ItemCooldowns' no-op on a client.
self.cooldowns.tick();
// Options.broadcastOptions: a changed render distance or Chat option
// is sent again.
let view = client_view_distance(self.settings.render_distance);
@@ -3424,6 +3462,13 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Creeper {
horizon_client::mobs::hostile::creeper_client_tick(m);
}
if matches!(
m.species(),
horizon_client::mobs::Species::Guardian
| horizon_client::mobs::Species::ElderGuardian
) {
horizon_client::mobs::guardian::client_tick(m);
}
if m.hurt_time > 0 {
m.hurt_time -= 1;
}
@@ -3491,6 +3536,9 @@ impl Game<'_> {
if m.species() == horizon_client::mobs::Species::Rabbit {
horizon_client::mobs::rabbit::post_tick(m);
}
if m.species() == horizon_client::mobs::Species::Bat {
horizon_client::mobs::bat::setup_animation_states(m);
}
}
for o in self.orbs.iter_mut().filter(|_| running) {
o.previous = o.position;
@@ -3519,6 +3567,18 @@ impl Game<'_> {
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
}
if running {
// The dragon: LivingEntity's interpolation, inside its aiStep.
let dragon_lerp = self
.dragon
.as_ref()
.and_then(|d| lerps.get_mut(&(d.id as i32)))
.filter(|l| l.steps > 0)
.map(|l| {
let steps = l.steps;
l.steps -= 1;
(l.target, l.rotation.map(|r| r.0), steps)
});
self.tick_remote_dragon(dragon_lerp);
// Boats, minecarts, armor stands, projectiles and the rest
// interpolate like mobs.
let mut step = |id: u32, at: Vec3| match lerps.get_mut(&(id as i32)) {
@@ -3820,19 +3880,25 @@ impl Game<'_> {
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
}
let s = self.res.facts().get(state).sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
// Block.playerWillDestroy's destroy event (particles and the break
// sound), which BaseFireBlock skips: the server's extinguish sound
// is all a player hears.
let facts = self.res.facts().get(state);
if !facts.has_class("BaseFireBlock") {
let s = facts.sounds.clone();
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
self.audio.play(
&s.break_sound,
Some(at),
(s.volume + 1.0) / 2.0,
s.pitch * 0.8,
);
self.destroy_particles(pos, state);
}
// The server's block update follows (waterlogged blocks leave water).
let air = self.world.air;
if self.world.set(pos[0], pos[1], pos[2], air).is_ok() {
@@ -4506,6 +4572,7 @@ impl Game<'_> {
}
KeyCode::KeyE => {
// The inventory opens without telling the server.
self.recipe_ui.reset_screen();
self.screen = if self.creative {
Screen::Creative
} else {
@@ -4772,6 +4839,7 @@ impl Game<'_> {
self.remote_close_local();
}
fn remote_close_local(&mut self) {
self.recipe_ui.reset_screen();
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
@@ -4807,16 +4875,20 @@ impl Game<'_> {
if self.open_menu_name(p.container) != "minecraft:merchant" {
return;
}
let cost = |g: &Self, c: &play::NetCost| horizon_client::merchant::ItemCost {
stack: ItemStack::new(&g.item_name(c.item), c.count.max(0) as u32),
let cost = |g: &Self, c: &play::NetCost| {
g.to_stack(&Some(c.clone().into()))
.map(|stack| horizon_client::merchant::ItemCost { stack })
};
let offers: Vec<_> = p
.offers
.iter()
.filter_map(|o| {
let mut offer = horizon_client::merchant::MerchantOffer {
base_cost_a: cost(self, &o.cost_a),
cost_b: o.cost_b.as_ref().map(|b| cost(self, b)),
base_cost_a: cost(self, &o.cost_a)?,
cost_b: match &o.cost_b {
Some(b) => Some(cost(self, b)?),
None => None,
},
result: self.to_stack(&Some(o.result.clone()))?,
uses: o.uses,
max_uses: o.max_uses,
@@ -4842,6 +4914,9 @@ impl Game<'_> {
}
/// ClientboundOpenScreenPacket for the menus Horizon draws.
fn remote_open_screen(&mut self, p: play::OpenScreen) {
// A new RecipeBookComponent owns fresh ghost slots and an overlay,
// even when the server replaces a menu without a close packet.
self.recipe_ui.reset_screen();
let menu = self
.remote
.as_ref()
@@ -4,14 +4,14 @@
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::render::{PROFESSIONS, VILLAGER_TYPES};
use horizon_client::mobs::{Mob, Species, tame};
use horizon_client::mobs::{Mob, Species, Who, bat, tame};
use horizon_protocol::entity_data::Value;
use horizon_protocol::registry::Builtin;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
/// `builtin` names the ids of built-in registries.
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_entity: i32) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
@@ -46,6 +46,21 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin) {
_ => {}
}
match (species, *index, value) {
(Species::Guardian | Species::ElderGuardian, 16, Value::Bool(moving)) => {
m.extra.flag2 = *moving;
}
(Species::Guardian | Species::ElderGuardian, 17, Value::Int(target)) => {
// Guardian.onSyncedDataUpdated resets the visual charge clock.
m.extra.counter2 = 0;
m.extra.flag = *target != 0;
m.ai.target = (*target != 0).then_some(if *target == local_entity {
Who::Player
} else {
Who::Mob(*target as u32)
});
}
// 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.
(Species::Sheep, 18, Value::Byte(wool)) => {
m.extra.color = (*wool & 15) as u8;
@@ -114,7 +129,7 @@ impl Game<'_> {
return;
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values, &r.builtin);
apply(m, values, &r.builtin, r.entity_id);
}
}
}
@@ -126,6 +141,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
if matches!(m.species(), Species::Wolf | Species::Cat) {
m.extra.color = tame::RED;
}
if m.species() == Species::Bat {
// Bat's constructor sets resting only on the server; the data default is 0.
bat::set_resting(m, false);
}
}
#[cfg(test)]
@@ -147,7 +166,7 @@ mod tests {
}
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
super::apply(m, values, &builtin());
super::apply(m, values, &builtin(), 99);
}
fn mob(kind: &str) -> Option<Mob> {
@@ -158,6 +177,49 @@ mod tests {
Some(m)
}
#[test]
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
use horizon_client::mobs::guardian::{beam, client_tick};
for (kind, duration) in [("guardian", 80), ("elder_guardian", 60)] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(beam(&m, 0.0).is_none());
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(true)),
(17, s::INT, Value::Int(99)),
],
);
assert!(m.extra.flag2);
assert_eq!(m.ai.target, Some(Who::Player));
assert_eq!(beam(&m, 0.0), Some((0.0, 0.0)));
for _ in 0..duration + 10 {
client_tick(&mut m);
}
assert_eq!(beam(&m, 0.0), Some((duration as f32, 1.0)));
// Another player or a mob retains its network entity id.
apply(&mut m, &[(17, s::INT, Value::Int(42))]);
assert_eq!(m.ai.target, Some(Who::Mob(42)));
assert_eq!(m.extra.counter2, 0);
client_tick(&mut m);
assert_eq!(m.extra.counter2, 1);
apply(
&mut m,
&[
(16, s::BOOLEAN, Value::Bool(false)),
(17, s::INT, Value::Int(0)),
],
);
client_tick(&mut m);
assert!(!m.extra.flag2);
assert_eq!(m.ai.target, None);
assert_eq!(m.extra.counter2, 0);
assert!(beam(&m, 0.0).is_none());
}
}
#[test]
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
let Some(mut m) = mob("minecraft:sheep") else {
@@ -174,6 +236,69 @@ mod tests {
assert!(!calf.is_baby());
}
#[test]
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
use horizon_core::geometry::Vec3;
use serde_json::json;
use std::{collections::HashMap, fs, sync::Arc};
// Original minimal resources keep this regression runnable without vanilla data.
let root =
std::env::temp_dir().join(format!("horizon-bat-animation-{}", std::process::id()));
fs::create_dir(&root).unwrap();
fs::write(root.join("entity-types.json"), r#"{"minecraft:bat": {}}"#).unwrap();
fs::write(
root.join("entity-models.json"),
r#"{"minecraft:bat#main": {"children": {"right_wing": {}, "head": {}}}}"#,
)
.unwrap();
let mut types = MobTypes::load(&root).unwrap();
fs::remove_dir_all(root).unwrap();
let position = Vec3::new(1.0, 2.25, 3.0);
let velocity = Vec3::new(0.2, 0.3, 0.4);
let mut m = Mob::new(
1,
Arc::new(types.types.remove("minecraft:bat").unwrap()),
position,
0.0,
);
assert!(bat::resting(&m));
client_defaults(&mut m);
assert!(!bat::resting(&m));
m.velocity = velocity;
m.age = 1;
bat::setup_animation_states(&mut m);
m.age = 4;
apply(&mut m, &[(16, s::BYTE, Value::Byte(2))]);
bat::setup_animation_states(&mut m);
assert_eq!(m.anim.states[0].started, Some(1.0));
let curve = Animation::parse(&json!({"length": 1, "looping": true,
"bones": {"right_wing": [{"target": "rotation", "keyframes":
[[0,[0,0,0],[0,0,0],"linear"],[1,[0,1,0],[0,1,0],"linear"]]}]}}));
let animations = HashMap::from([("BatAnimation.BAT_FLYING".into(), curve)]);
let wing = |partial| {
let mut poses = anim::Poses::new(&m.kind.model);
anim::setup(&m, &anim::state(&m, partial), &mut poses, &animations);
poses.get("right_wing")[4]
};
assert!(wing(0.5) > wing(0.0));
m.age = 5;
apply(&mut m, &[(16, s::BYTE, Value::Byte(3))]);
bat::setup_animation_states(&mut m);
assert!(bat::resting(&m));
assert_eq!(m.anim.states[0].started, None);
assert_eq!(m.anim.states[1].started, Some(5.0));
m.age = 7;
apply(&mut m, &[(16, s::BYTE, Value::Byte(0))]);
bat::setup_animation_states(&mut m);
assert_eq!(m.anim.states[0].started, Some(7.0));
assert_eq!(m.anim.states[1].started, None);
assert_eq!((m.position, m.velocity), (position, velocity));
}
#[test]
fn a_wolf_takes_its_owner_flags_and_collar() {
let Some(mut m) = mob("minecraft:wolf") else {
@@ -3,7 +3,7 @@
//! `handleSetEquipment`): the server simulates them, the client places them
//! where it says and interpolates their moves like a mob's.
use super::Game;
use horizon_client::entities::{ArmorStand, ItemFrame, Painting};
use horizon_client::entities::{ArmorStand, Cushion, ItemFrame, Painting};
use horizon_core::geometry::Vec3;
use horizon_protocol::entity_data::Value;
use std::sync::Arc;
@@ -57,6 +57,16 @@ impl Game<'_> {
invisible: false,
check_interval: 0,
});
} else if kind == "minecraft:cushion" {
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
self.cushions.push(Cushion {
id,
position: at,
pos: block,
yaw: super::cushions::snap_yaw(yaw),
color: 0,
check_interval: 0,
});
} else if kind == "minecraft:lightning_bolt" {
self.spawn_remote_bolt(id, position);
} else if kind == "minecraft:painting" {
@@ -152,6 +162,11 @@ impl Game<'_> {
}
_ => {}
}
} else if let Some(c) = self.cushions.iter_mut().find(|c| c.id == id) {
// Cushion.DATA_COLOR (DyeColor, a byte-backed id).
if let (8, Value::Id(color)) = (index, value) {
c.color = (*color).clamp(0, 15) as u8;
}
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
// VehicleEntity's hurt, direction and damage; the paddles.
match (index, value) {
@@ -267,6 +282,12 @@ impl Game<'_> {
out.push(json!({"id": a.id, "kind": "minecraft:armor_stand", "position": at(a.position), "yaw": a.yaw,
"small": a.small, "arms": a.show_arms, "no_base_plate": a.no_base_plate, "equipment": items}));
}
for c in &self.cushions {
out.push(
json!({"id": c.id, "kind": "minecraft:cushion", "position": at(c.position),
"yaw": c.yaw, "color": c.color_name()}),
);
}
for f in &self.item_frames {
out.push(
json!({"id": f.id, "kind": f.kind(), "position": f.pos, "facing": f.facing,
@@ -286,6 +307,7 @@ impl Game<'_> {
self.carts.clear();
self.boats.clear();
self.armor_stands.clear();
self.cushions.clear();
self.item_frames.clear();
self.paintings.clear();
}
@@ -295,6 +317,7 @@ impl Game<'_> {
self.carts.retain(|c| c.id != id);
self.boats.retain(|b| b.id != id);
self.armor_stands.retain(|a| a.id != id);
self.cushions.retain(|c| c.id != id);
self.item_frames.retain(|f| f.id != id);
self.paintings.retain(|p| p.id != id);
self.bolts.retain(|b| b.id != id);
@@ -3,7 +3,7 @@
//! and `handleSetEntityData`): the server simulates them, the client draws
//! them where it says and interpolates their moves.
use super::{FallStep, FallingBlock, Game, fireworks::fireworks_of, view_vector};
use horizon_client::projectiles::{Projectile, ProjectileKind};
use horizon_client::projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::entity_data::Value;
use horizon_world::registry::BlockStateId;
@@ -37,6 +37,32 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
})
}
/// `AbstractArrow.tick` on a client: water drag before the move, air drag
/// after it, then gravity; a block it reaches holds it (`inGround`).
fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
p.previous = p.position;
p.age += 1;
if p.stuck {
return;
}
let water = w.in_water(p.position);
if water {
p.velocity = p.velocity.scale(0.6);
}
let to = p.position.add_vector(p.velocity);
if let Some((at, _, _)) = w.clip(p.position, to) {
p.position = at;
p.velocity = Vec3::ZERO;
p.stuck = true;
return;
}
p.position = to;
if !water {
p.velocity = p.velocity.scale(0.99);
}
p.velocity.y -= 0.05;
}
impl Game<'_> {
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
@@ -246,29 +272,34 @@ impl Game<'_> {
}
}
self.falling = falling;
for p in &mut self.creatures.projectiles {
p.previous = p.position;
p.age += 1;
if matches!(p.kind, ProjectileKind::LargeFireball { .. }) {
// AbstractHurtingProjectile.tick advances on the client between
// the tracker updates (ten ticks apart for a ghast's fireball).
let at = p.position;
let water = self
.res
.facts()
.get(self.world.block(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water;
p.apply_hurting_inertia(water);
p.position = p.position.add_vector(p.velocity);
} else {
p.position = step(p.id, p.position);
// Projectiles tick on the client too and fly on between the tracker's
// updates (10 to 20 ticks apart); a block stops them there, and only
// the server decides what they hit.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
{
let w = self.mob_world_base(&[]);
let world = super::creatures::ProjWorld {
w: &w,
targets: Vec::new(),
};
for p in &mut projectiles {
match p.kind {
ProjectileKind::Arrow { .. } => remote_arrow_tick(p, &world),
// ShulkerBullet.tick: only the server steers it.
ProjectileKind::ShulkerBullet { .. } => {
p.previous = p.position;
p.age += 1;
p.position = p.position.add_vector(p.velocity);
}
_ => {
if !matches!(p.tick(&world).1, Hit::None) {
p.velocity = Vec3::ZERO;
}
}
}
}
}
self.creatures.projectiles = projectiles;
for t in &mut self.tridents {
let id = t.id;
t.remote_step(|at| step(id, at));
@@ -302,12 +333,9 @@ impl Game<'_> {
c.time += 1;
c.position = step(c.id, c.position);
}
self.potions.step_remote_thrown(&mut step);
self.step_remote_thrown();
self.step_remote_hooks(&mut step);
self.end.step_remote_eyes(|id, at| {
let at = step(id, Vec3::new(at[0], at[1], at[2]));
[at.x, at.y, at.z]
});
self.end.step_remote_eyes();
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
@@ -330,6 +358,8 @@ impl Game<'_> {
self.tridents.clear();
self.rockets.clear();
self.crystals.clear();
// The dragon a server sent goes with its crystals.
self.dragon = None;
self.potions.clear_thrown();
self.end.clear_remote_eyes();
self.clear_remote_hooks();
@@ -56,6 +56,11 @@ impl Game<'_> {
let p = a.position;
hits.push((a.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d, _)) = self.cushion_hit() {
let c = &self.cushions[i];
let p = c.position;
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
}
if let Some((i, d)) = self.boat_hit() {
let p = self.boats[i].position;
hits.push((self.boats[i].id as i32, d, [p.x, p.y, p.z]));
@@ -79,6 +84,8 @@ impl Game<'_> {
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
}
}
// EnderDragonPart.isPickable: the server's dragon hurts by part.
hits.extend(self.remote_dragon_part_hit(eye, dir, reach));
}
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
let point = at(distance);
@@ -138,7 +145,10 @@ impl Game<'_> {
self.position_rider();
return true;
}
let seat = if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let seat = if let Some(c) = self.cushions.iter().find(|c| c.id == id) {
// The seat does not move; the rider sits on the cushion.
Some(c.seat())
} else if let Some(i) = self.boats.iter().position(|b| b.id == id) {
// AbstractBoat.tick on the controlling client: steer and move.
let mut b = self.boats[i].clone();
b.input = [keys.left, keys.right, keys.forward, keys.backward];
@@ -0,0 +1,356 @@
//! Server mods in the game (`docs/mods.md`, "Server mods"): the game that
//! simulates the world (in-frame single player, the server thread, a LAN
//! host, the dedicated server) runs them. At the end of each tick it tells
//! them who joined and left, then what players said, broke and placed since
//! the last one, then ticks them; a player's command reaches them at once.
//! Their lines go to the players they name. With no server mod on, each hook
//! here is one check.
use super::{Game, lan::LanHost};
#[cfg(not(feature = "mods"))]
use horizon_world::registry::BlockStateId;
/// Who did something a server mod hears about, resolved at the tick's end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum Who {
/// The window's own player, in-frame.
Local,
/// A connection's player (the server thread's owner is one).
Conn(u32),
Uuid(u128),
}
/// What players did during a tick, for the server mods at its end.
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) enum ModEvent {
Chat(Who, String),
Block {
who: Who,
pos: [i32; 3],
state: String,
placed: bool,
},
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn tick_server_mods(&mut self) {}
pub(super) fn mod_block_event(&mut self, _pos: [i32; 3], _state: BlockStateId, _placed: bool) {}
pub(super) fn mod_player_chat(&mut self, _who: Who, _text: &str) {}
pub(super) fn local_server_mod_command(&mut self, _line: &str) -> bool {
false
}
pub(super) fn guest_server_mod_command(
&mut self,
_host: &mut LanHost,
_conn: u32,
_line: &str,
) -> bool {
false
}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{BlockPos, GameMode, Output, ServerPlayer};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::BlockStateId;
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
let h = format!("{u:032x}");
format!(
"{}-{}-{}-{}-{}",
&h[..8],
&h[8..12],
&h[12..16],
&h[16..20],
&h[20..]
)
}
fn mode(id: i32) -> GameMode {
match id {
1 => GameMode::Creative,
2 => GameMode::Adventure,
3 => GameMode::Spectator,
_ => GameMode::Survival,
}
}
impl Game<'_> {
/// The players on this game's server: the window's own player
/// in-frame, then every connection's. `host` is the hub when it is
/// out of `self.lan`.
pub(in crate::game) fn server_players(&self, host: Option<&LanHost>) -> Vec<ServerPlayer> {
let mut out = Vec::new();
if self.options.execution_role.is_client()
&& self.remote.is_none()
&& let Some(p) = &self.player
{
out.push(ServerPlayer {
id: self.player_uuid_string(),
name: crate::game::chat::PLAYER_NAME.to_owned(),
x: p.position.x,
y: p.position.y,
z: p.position.z,
dimension: self.dim.id().to_owned(),
game_mode: match self.mode {
Mode::Survival => GameMode::Survival,
Mode::Creative => GameMode::Creative,
Mode::Adventure => GameMode::Adventure,
Mode::Spectator => GameMode::Spectator,
},
health: p.health.health(),
});
}
let guests = host
.or(self.lan.as_ref())
.into_iter()
.flat_map(|h| &h.hub.guests);
out.extend(guests.filter(|g| g.placed).map(|g| ServerPlayer {
id: uuid_string(g.uuid),
name: g.name.clone(),
x: g.position[0],
y: g.position[1],
z: g.position[2],
dimension: g.dimension.clone(),
game_mode: mode(g.game_mode),
health: g.health,
}));
out
}
/// Whether what players do now reaches server mods: one is running
/// and this game simulates the world.
fn hears_players(&self) -> bool {
self.mods.server && self.remote.is_none()
}
/// The tick's end: joins and leaves, the tick's events, the tick hook.
pub(in crate::game) fn tick_server_mods(&mut self) {
if !self.options.execution_role.is_client() {
// A server has no frames to check its dev mods in.
self.reload_dev_mods();
}
if !self.mods.server {
return;
}
let players = self.server_players(None);
let snapshot = self.mod_snapshot_with(players.clone());
self.mods.mods.set_snapshot(snapshot);
let before = std::mem::take(&mut self.mods.players);
for p in before
.iter()
.filter(|p| !players.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_left(p);
}
for p in players
.iter()
.filter(|p| !before.iter().any(|q| q.id == p.id))
{
self.mods.mods.player_joined(p);
}
for event in std::mem::take(&mut self.mods.events) {
let who = |w: &Who| {
let id = match w {
Who::Local => self.player_uuid_string(),
Who::Uuid(u) => uuid_string(*u),
Who::Conn(c) => {
let guests = self.lan.iter().flat_map(|h| &h.hub.guests);
let g = guests.into_iter().find(|g| g.conn == *c);
g.map_or_else(String::new, |g| uuid_string(g.uuid))
}
};
players.iter().chain(&before).find(|p| p.id == id).cloned()
};
match event {
ModEvent::Chat(w, text) => {
if let Some(p) = who(&w) {
self.mods.mods.player_chat(&p, &text);
}
}
ModEvent::Block {
who: w,
pos,
state,
placed,
} => {
let Some(p) = who(&w) else { continue };
let pos = BlockPos {
x: pos[0],
y: pos[1],
z: pos[2],
};
if placed {
self.mods.mods.block_placed(&p, pos, &state);
} else {
self.mods.mods.block_broken(&p, pos, &state);
}
}
}
}
self.mods.mods.server_tick(self.ticks);
self.mods.players = players;
self.show_mod_output();
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
pub(in crate::game) fn mod_block_event(
&mut self,
pos: [i32; 3],
state: BlockStateId,
placed: bool,
) {
if !self.hears_players() {
return;
}
let who = self.lan_acting.map_or(Who::Local, Who::Conn);
let state = self.state_string(state);
self.mods.events.push(ModEvent::Block {
who,
pos,
state,
placed,
});
}
/// A chat message a player sent.
pub(in crate::game) fn mod_player_chat(&mut self, who: Who, text: &str) {
if self.hears_players() {
self.mods.events.push(ModEvent::Chat(who, text.to_owned()));
}
}
/// `/line` typed by the window's own player in-frame: true when a
/// server mod owns it.
pub(in crate::game) fn local_server_mod_command(&mut self, line: &str) -> bool {
if !self.hears_players() {
return false;
}
let players = self.server_players(None);
let Some(me) = players.first().cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
let owned = self.mods.mods.server_command(&me, line);
self.show_mod_output();
owned
}
/// `/line` typed by a connection's player: true when a server mod
/// owns it, whatever the player's permission level.
pub(in crate::game) fn guest_server_mod_command(
&mut self,
host: &mut LanHost,
conn: u32,
line: &str,
) -> bool {
if !self.hears_players() {
return false;
}
let Some(g) = host.hub.guest(conn) else {
return false;
};
let id = uuid_string(g.uuid);
let players = self.server_players(Some(host));
let Some(me) = players.iter().find(|p| p.id == id).cloned() else {
return false;
};
let snapshot = self.mod_snapshot_with(players);
self.mods.mods.set_snapshot(snapshot);
if !self.mods.mods.server_command(&me, line) {
return false;
}
self.route_mod_output(Some(host));
true
}
/// Sends a server mod's line to the player with this id: the
/// window's own chat for the local player, a system message for a
/// connection's. `host` is the hub when it is out of `self.lan`.
pub(in crate::game) fn tell_server_player(
&mut self,
host: Option<&mut LanHost>,
id: &str,
line: Text,
) {
let local = self.options.execution_role.is_client()
&& self.remote.is_none()
&& self.player.is_some();
if local && self.player_uuid_string() == id {
self.add_mod_chat_line(line);
return;
}
let Some(host) = host.or(self.lan.as_mut()) else {
return;
};
let to = host
.hub
.guests
.iter()
.find(|g| g.placed && uuid_string(g.uuid) == id);
if let Some(conn) = to.map(|g| g.conn) {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
overlay: false,
}),
);
}
}
/// Routes a server mod's output: `Tell` and `Reply` to their player,
/// `Broadcast` to everyone and the log.
pub(in crate::game) fn route_server_output(
&mut self,
mut host: Option<&mut LanHost>,
output: Output,
) {
let named = |name: &str, text: String| {
let mut line = Text::literal(format!("[{name}] ")).with_color(text::GRAY);
line.push(Text::literal(text));
line
};
match output {
Output::Tell { name, player, text } => {
self.tell_server_player(host, &player, named(&name, text));
}
Output::Reply {
player,
text,
error,
} => {
let line = Text::literal(text);
let line = if error {
line.with_color(text::RED)
} else {
line
};
self.tell_server_player(host, &player, line);
}
Output::Broadcast { name, text } => {
let plain = format!("[{name}] {text}");
if self.options.dedicated.is_some() {
super::super::dedicated::log("INFO", &plain);
} else {
log::info!("{plain}");
}
for p in self.server_players(host.as_deref()) {
let line = named(&name, text.clone());
self.tell_server_player(host.as_deref_mut(), &p.id, line);
}
}
_ => {}
}
}
}
}
@@ -13,6 +13,7 @@ use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use horizon_client::{lan::hub::TickCtx, text::Text};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// A trip under way: the player is in the destination's level, whose
/// chunks are loading around `target`.
@@ -103,8 +104,35 @@ impl Game<'_> {
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
// `ServerPlayer.showEndCredits`: the player stays out of the world while the
// poem rolls, then PERFORM_RESPAWN takes it to its respawn point.
let play = host.play.entry(conn).or_default();
if play.won_game {
if std::mem::take(&mut play.respawn_requested) {
play.won_game = false;
self.start_server_trip(host, conn, Dim::Overworld, None);
self.portals.player.cooldown = 10;
}
return;
}
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
// EndPortalBlock.entityInside: the first exit from the End shows the credits.
if self.dim == Dim::End && !host.hub.guest(conn).is_some_and(|g| g.seen_credits) {
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn) {
g.seen_credits = true;
}
host.play.entry(conn).or_default().won_game = true;
// ClientboundGameEventPacket.WIN_GAME.
host.hub.send_to(
conn,
&C::GameEvent(play::GameEvent {
event: 4,
param: 0.0,
}),
);
return;
}
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
+225 -89
View File
@@ -6,9 +6,9 @@ use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
ui::EditBox,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
@@ -18,7 +18,16 @@ pub(super) struct SignEditor {
pub pos: (i32, i32, i32),
pub front: bool,
pub line: usize,
pub opened: Instant,
/// The line being edited (`AbstractSignEditScreen`'s text field).
edit: EditBox,
}
fn sign_box() -> EditBox {
let mut edit = EditBox::new(0.0, 0.0, 90.0, 9.0);
edit.bordered = false;
edit.max_length = 384;
edit.focused = true;
edit
}
impl Game<'_> {
@@ -28,8 +37,9 @@ impl Game<'_> {
pos,
front,
line: 0,
opened: Instant::now(),
edit: sign_box(),
});
self.sign_load();
self.keys.clear();
self.right = false;
self.capture(false);
@@ -140,97 +150,154 @@ impl Game<'_> {
/// A key and the text it typed on the sign being edited (the keyboard,
/// or a controller's on-screen keyboard).
pub(super) fn sign_input(&mut self, key: Option<KeyCode>, typed: Option<&str>) {
let Some(edit) = self.sign_edit.as_mut() else {
if self.sign_edit.is_none() {
return;
};
let front = edit.front;
let id = sign_line_id(front, edit.line);
}
match key {
Some(KeyCode::Escape) => self.close_sign_editor(),
Some(KeyCode::ArrowUp) => edit.line = (edit.line + 3) & 3,
Some(KeyCode::ArrowUp) => self.sign_move_line(3),
Some(KeyCode::ArrowDown | KeyCode::Enter | KeyCode::NumpadEnter) => {
edit.line = (edit.line + 1) & 3
self.sign_move_line(1)
}
Some(KeyCode::Backspace) => {
let (line, key) = (edit.line, edit.pos);
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
let value = &mut text.lines[line];
if !self.text_selection.take(id, value) {
value.pop();
}
}
Some(
key @ (KeyCode::ArrowLeft
| KeyCode::ArrowRight
| KeyCode::Home
| KeyCode::End
| KeyCode::Backspace
| KeyCode::Delete),
) => {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
self.sign_change(|edit| {
edit.key(key, ctrl, shift);
});
}
_ => {
let Some(typed) = typed else {
return;
};
let line = edit.line;
let atlas = &self.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s);
let Some(key) = self.sign_edit.as_ref().map(|e| e.pos) else {
return;
};
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) {
let max = sign.max_line_width();
let text = if front {
&mut sign.front
} else {
&mut sign.back
};
self.text_selection.take(id, &mut text.lines[line]);
for ch in typed.chars().filter(|c| !c.is_control()) {
let mut next = text.lines[line].clone();
next.push(ch);
// TextFieldHelper filter: font.width(s) <= maxTextLineWidth.
if width(&next) <= max {
text.lines[line] = next;
}
}
}
self.sign_change(|edit| edit.insert_text(typed));
}
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line: a paste
/// (formatting and carriage returns removed) is taken whole or not at
/// all, as the line must stay within the sign's width.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
/// Up or down a line: the box writes the line it leaves and loads the next.
fn sign_move_line(&mut self, by: usize) {
self.sign_write();
if let Some(edit) = self.sign_edit.as_mut() {
edit.line = (edit.line + by) & 3;
}
self.sign_load();
}
/// The edited line's text.
fn sign_current(&self) -> Option<String> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let side = if edit.front { &sign.front } else { &sign.back };
Some(side.lines[edit.line].clone())
}
/// `EditBox.setValue` from the line now being edited.
fn sign_load(&mut self) {
let Some(text) = self.sign_current() else {
return;
};
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.set_value(&text);
}
}
/// The box's text onto the sign, when it still fits the line.
fn sign_write(&mut self) {
let Some(edit) = self.sign_edit.as_ref() else {
return;
};
let (key, front, line) = (edit.pos, edit.front, edit.line);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
let (pos, front, line, value) = (edit.pos, edit.front, edit.line, edit.edit.value.clone());
if !self.sign_fits(&value) {
return;
}
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&pos) else {
return;
};
let max = sign.max_line_width();
let text = if front {
let side = if front {
&mut sign.front
} else {
&mut sign.back
};
let atlas = &self.gui.atlas;
self.text_selection.apply(
sign_line_id(front, line),
&mut text.lines[line],
shortcut,
&mut self.clipboard,
|value, pasted| {
let next = format!(
"{value}{}",
horizon_client::clipboard::helper_clipboard_contents(pasted)
);
HudBatch::text_width(atlas, &next) <= max && {
*value = next;
true
}
},
);
side.lines[line] = value;
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20).
/// TextFieldHelper: `font.width(s) <= maxTextLineWidth`.
fn sign_fits(&self, value: &str) -> bool {
let Some(edit) = self.sign_edit.as_ref() else {
return false;
};
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) else {
return false;
};
HudBatch::text_width(&self.gui.atlas, value) <= sign.max_line_width()
}
/// Edit the line, then keep the result only when it still fits.
fn sign_change(&mut self, f: impl FnOnce(&mut EditBox)) {
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
f(&mut edit.edit);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Write the box when the line still fits; otherwise put the old text back.
fn sign_keep(&mut self, before: Option<(String, usize, usize)>) {
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
if self.sign_fits(&value) {
self.sign_write();
} else if let (Some(edit), Some((value, cursor, highlight))) =
(self.sign_edit.as_mut(), before)
{
edit.edit.value = value;
edit.edit.cursor = cursor;
edit.edit.highlight = highlight;
}
}
/// `TextFieldHelper.keyPressed`'s shortcuts on the edited line. Paste
/// reads `getClipboardContents` before `insertText`. A paste that would
/// pass the sign's width is refused whole.
fn sign_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
if shortcut == horizon_client::clipboard::EditShortcut::Paste {
let raw = horizon_client::clipboard::Clipboard::get(&mut self.clipboard);
let text = horizon_client::clipboard::helper_clipboard_contents(&raw);
self.sign_change(|edit| edit.insert_text(&text));
return;
}
let before = self
.sign_edit
.as_ref()
.map(|e| (e.edit.value.clone(), e.edit.cursor, e.edit.highlight));
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.shortcut(shortcut, &mut self.clipboard);
let clean = horizon_client::clipboard::strip_formatting(&edit.edit.value);
if clean != edit.edit.value {
let n = horizon_client::ui::char_len(&clean);
edit.edit.value = clean;
edit.edit.cursor = edit.edit.cursor.min(n);
edit.edit.highlight = edit.edit.highlight.min(n);
}
}
self.sign_keep(before);
}
/// Done button (`this.width / 2 - 100, this.height / 4 + 144`, 200x20),
/// or a click on a line (`EditBox.onClick`).
pub(super) fn sign_edit_click(&mut self, size: [u32; 2]) {
let b = HudBatch::new(size);
let (x, y) = (b.width / 2.0 - 100.0, b.height / 4.0 + 144.0);
@@ -238,7 +305,56 @@ impl Game<'_> {
if mx >= x && mx < x + 200.0 && my >= y && my < y + 20.0 {
self.audio.play_button_click();
self.close_sign_editor();
return;
}
let Some((cx, top, line_height, max_width, lines)) = self.sign_lines(b.width) else {
return;
};
let shift = self.has_shift_down();
for i in 0..lines.len() {
let ly = top + i as f32 * line_height;
let left = cx - max_width / 2.0;
if mx < left || mx >= left + max_width || my < ly || my >= ly + line_height {
continue;
}
let current = self.sign_edit.as_ref().map(|e| e.line);
if current != Some(i) {
self.sign_move_line((i + 4 - current.unwrap_or(0)) & 3);
}
let value = self
.sign_edit
.as_ref()
.map(|e| e.edit.value.clone())
.unwrap_or_default();
let lw = HudBatch::text_width(&self.gui.atlas, &value);
let text_x = cx - (lw as i32 / 2) as f32;
if let Some(edit) = self.sign_edit.as_mut() {
edit.edit.x = text_x;
edit.edit.y = ly;
edit.edit.width = max_width;
edit.edit.click(&self.gui.atlas, mx, shift, false);
}
self.sign_write();
return;
}
}
/// Centre x, the first line's y, the line height, the width limit and the four lines.
fn sign_lines(&self, width: f32) -> Option<(f32, f32, f32, f32, Vec<String>)> {
let edit = self.sign_edit.as_ref()?;
let BlockEntity::Sign(sign) = self.world.block_entities.get(&edit.pos)? else {
return None;
};
let text = if edit.front { &sign.front } else { &sign.back };
let line_height = sign.line_height();
let y = if sign.hanging { 125.0 } else { 90.0 };
let top = y - (4.0 * line_height / 2.0).floor();
Some((
width / 2.0,
top,
line_height,
sign.max_line_width(),
text.lines.to_vec(),
))
}
pub(super) fn draw_sign_editor(&self, batch: &mut HudBatch) {
let Some(edit) = &self.sign_edit else { return };
@@ -304,23 +420,48 @@ impl Game<'_> {
let color = rgba((color >> 16) as u8, (color >> 8) as u8, color as u8, 255);
let line_height = sign.line_height();
let midpoint = (4.0 * line_height / 2.0).floor();
let blink = (edit.opened.elapsed().as_millis() / 300) % 2 == 0;
for (i, line) in text.lines.iter().enumerate() {
let lw = HudBatch::text_width(atlas, line);
let shown = if i == edit.line {
edit.edit.value.as_str()
} else {
line.as_str()
};
let lw = HudBatch::text_width(atlas, shown);
let x = cx - (lw as i32 / 2) as f32;
let ly = y + i as f32 * line_height - midpoint;
batch.text(atlas, line, x, ly, color, false);
if i == edit.line
&& self
.text_selection
.covers(sign_line_id(edit.front, i), line)
{
// AbstractSignEditScreen: the selection from its start to its end.
batch.text_highlight(atlas, (x, ly, lw, line_height), true);
batch.text(atlas, shown, x, ly, color, false);
if i != edit.line {
continue;
}
if i == edit.line && blink {
// TextCursorUtils.extractAppendCursor: an underscore after the text.
batch.text(atlas, "_", x + lw, ly, color, false);
let value = &edit.edit.value;
if edit.edit.cursor != edit.edit.highlight {
let (a, b) = (
edit.edit.cursor.min(edit.edit.highlight),
edit.edit.cursor.max(edit.edit.highlight),
);
let pre: String = value.chars().take(a).collect();
let sel: String = value.chars().skip(a).take(b - a).collect();
batch.text_highlight(
atlas,
(
x + HudBatch::text_width(atlas, &pre),
ly,
HudBatch::text_width(atlas, &sel),
line_height,
),
true,
);
}
if edit.edit.cursor_visible() {
let pre: String = value.chars().take(edit.edit.cursor).collect();
batch.fill(
atlas,
x + HudBatch::text_width(atlas, &pre),
ly,
1.0,
line_height,
color,
);
}
}
let (bx, by) = (w / 2.0 - 100.0, h / 4.0 + 144.0);
@@ -344,8 +485,3 @@ impl Game<'_> {
);
}
}
/// The sign line being edited, for Select All.
fn sign_line_id(front: bool, line: usize) -> horizon_client::clipboard::FieldId {
(if front { "sign.front" } else { "sign.back" }, line)
}
+6 -2
View File
@@ -398,8 +398,12 @@ impl GameLevel<'_> {
}
}
PreRemove::Campfire => {
for _ in 0..4 {
self.drop_item_stack(pos, None);
let items = match &entity {
Some(BlockEntity::Campfire(c)) => c.items.clone(),
_ => Default::default(),
};
for stack in items {
self.drop_item_stack(pos, stack);
}
}
PreRemove::Jukebox => {
+263 -47
View File
@@ -135,8 +135,10 @@ pub(super) struct Station {
pub(super) offers: EnchantOffers,
bookcases: i32,
pub(super) anvil: AnvilResult,
/// Anvil rename box text.
/// Anvil rename box text (`AnvilMenu.itemName`).
pub(super) name: String,
/// The box itself (`AnvilScreen`'s `EditBox` of 50, unbordered).
pub(super) rename: horizon_client::ui::EditBox,
/// Stonecutter: the menu's recipe list (horizon_client::menu), its
/// `selectedRecipeIndex` and the screen's first shown index.
pub(super) stone: horizon_client::menu::Stonecutter,
@@ -146,8 +148,6 @@ pub(super) struct Station {
anvil_last: Option<ItemStack>,
/// Anvil on a server: the name last sent (the client menu's `itemName`).
pub(super) anvil_sent: Option<String>,
/// When the screen opened: the rename box has the focus from then.
opened: std::time::Instant,
pub(super) last_sound: u64,
/// Villager trading state.
pub(super) merchant: Option<Box<super::trading::MerchantState>>,
@@ -169,12 +169,12 @@ impl Station {
bookcases: 0,
anvil: AnvilResult::default(),
name: String::new(),
rename: anvil_box(),
stone: Default::default(),
selected: None,
start: 0,
anvil_last: None,
anvil_sent: None,
opened: std::time::Instant::now(),
last_sound: 0,
merchant: None,
beacon: [None; 2],
@@ -868,6 +868,9 @@ impl Game<'_> {
if kind == Kind::Stonecutter && self.stonecutter_click(local) {
return true;
}
if kind == Kind::Anvil && !right && self.anvil_name_click(ox, oy) {
return true;
}
}
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return true;
@@ -894,6 +897,8 @@ impl Game<'_> {
.as_ref()
.map(|i| stations::hover_name(&|id| self.pack.names.display_item(id), i))
.unwrap_or_default();
let name = s.name.clone();
s.rename.set_value(&name);
}
self.station_changed();
}
@@ -960,21 +965,29 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
let cleared = self.text_selection.take(ANVIL_NAME, &mut s.name);
if backspace {
if !cleared {
s.name.pop();
}
s.rename.delete_chars(-1);
} else if let Some(t) = text {
for c in t.chars().filter(|c| !c.is_control()) {
if s.name.chars().count() < ANVIL_NAME_CHARS {
s.name.push(c);
}
}
s.rename.insert_text(t);
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
/// `AnvilScreen.keyPressed`: the rename box handles the key.
pub(super) fn station_key(&mut self, key: winit::keyboard::KeyCode) -> bool {
let (ctrl, shift) = (self.has_control_down(), self.has_shift_down());
let Some(s) = self.station.as_mut() else {
return false;
};
if !s.rename.key(key, ctrl, shift) {
return false;
}
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
true
}
/// Ctrl+A/C/V/X in the anvil's rename box (an `EditBox` of 50) or the
/// recipe book's search box.
pub(super) fn station_shortcut(&mut self, shortcut: horizon_client::clipboard::EditShortcut) {
@@ -985,18 +998,52 @@ impl Game<'_> {
let Some(s) = self.station.as_mut() else {
return;
};
if Self::append_shortcut(
&mut self.text_selection,
&mut self.clipboard,
ANVIL_NAME,
&mut s.name,
shortcut,
ANVIL_NAME_CHARS,
) {
if s.rename.shortcut(shortcut, &mut self.clipboard) {
s.name.clone_from(&s.rename.value);
self.station_changed();
self.remote_anvil_rename();
}
}
/// A click in the anvil's name box (`EditBox.onClick`).
fn anvil_name_click(&mut self, ox: f32, oy: f32) -> bool {
let open = self
.station
.as_ref()
.is_some_and(|s| s.kind == Kind::Anvil && s.inputs[0].is_some());
if !open {
return false;
}
let (x, y, w, h) = (ox + 62.0, oy + 24.0, 103.0, 12.0);
let (mx, my) = self.cursor;
if mx < x || my < y || mx >= x + w || my >= y + h {
return false;
}
let shift = self.has_shift_down();
let Some(s) = self.station.as_mut() else {
return false;
};
s.rename.x = x;
s.rename.y = y;
s.rename.width = w;
s.rename.click(&self.gui.atlas, mx, shift, false);
true
}
/// AnvilScreen's name box, drawn after the station picture.
fn draw_anvil_name(&mut self, batch: &mut HudBatch, ox: f32, oy: f32) {
let Some(s) = self.station.as_mut() else {
return;
};
let edit = &mut s.rename;
edit.x = ox + 62.0;
edit.y = oy + 24.0;
edit.width = 103.0;
edit.height = 12.0;
edit.bordered = false;
edit.focused = true;
edit.text_color = u32::MAX;
edit.max_length = ANVIL_NAME_CHARS;
edit.draw(batch, &self.gui.atlas, None);
}
// ---------------------------------------------------------------- drawing
@@ -1094,6 +1141,10 @@ impl Game<'_> {
let (ox, oy) = self.inventory_origin(batch);
let local = (self.cursor.0 - ox, self.cursor.1 - oy);
let mut tooltip: Option<Vec<(horizon_client::text::Text, u32)>> = None;
let mut anvil_name = false;
// Keep item tooltips as stacks until drawing: text alone loses the
// bundle contents component (including in the player's inventory).
let mut tooltip_stack: Option<ItemStack> = None;
{
let atlas = &self.gui.atlas;
batch.fill(
@@ -1428,18 +1479,8 @@ impl Game<'_> {
);
}
if s.inputs[0].is_some() {
// AnvilScreen's name box: white, 103 wide, no inverted highlight.
horizon_client::ui::append_field(
batch,
atlas,
&s.name,
(ox + 62.0, oy + 24.0, 103.0),
(u32::MAX, true),
ANVIL_NAME_CHARS,
self.text_selection.covers(ANVIL_NAME, &s.name),
false,
s.opened.elapsed().as_millis(),
);
// Drawn once this borrow of the station ends.
anvil_name = true;
}
let cost = s.anvil.cost;
if cost > 0 {
@@ -1579,13 +1620,16 @@ impl Game<'_> {
);
self.draw_item(batch, &result, ox + x, oy + y);
if hover {
tooltip = Some(self.stack_tooltip(&result));
tooltip_stack = Some(result);
}
}
}
}
}
}
if anvil_name {
self.draw_anvil_name(batch, ox, oy);
}
let drag = self.drag_view();
for i in self.station_slots().unwrap_or_default() {
let Some((sx, sy)) = self.slot_position_any(i) else {
@@ -1606,13 +1650,16 @@ impl Game<'_> {
);
if self.carried.is_none()
&& tooltip.is_none()
&& tooltip_stack.is_none()
&& let Some(stack) = self.slot_mut(slot).clone()
{
tooltip = Some(self.stack_tooltip(&stack));
tooltip_stack = Some(stack);
}
}
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = tooltip_stack {
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(lines) = tooltip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
@@ -1656,6 +1703,8 @@ impl Game<'_> {
};
let name = if let Some(n) = stack.custom_name() {
n.to_owned()
} else if let Some(n) = stack.item_name() {
n
} else if stack.data().is_some_and(|d| d.ominous_banner) {
self.translate("block.minecraft.ominous_banner", "Ominous Banner")
} else if stack.data().and_then(|d| d.potion.as_ref()).is_some()
@@ -1682,7 +1731,9 @@ impl Game<'_> {
// ItemContainerContents.addToTooltip.
let hover_name = |s: &ItemStack| {
s.custom_name()
.map_or_else(|| self.pack.names.display_item(&s.item), str::to_owned)
.map(str::to_owned)
.or_else(|| s.item_name())
.unwrap_or_else(|| self.pack.names.display_item(&s.item))
};
if display.shows("minecraft:container") {
for text in
@@ -1907,32 +1958,65 @@ impl Game<'_> {
if lines.is_empty() {
return;
}
let lines: Vec<_> = lines
let lines = Self::resolved(lines);
let atlas = &self.gui.atlas;
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), 0.0);
Self::tooltip_box(batch, atlas, x, y, w, h);
Self::tooltip_text(batch, atlas, &lines, x, y);
}
/// A tooltip's lines with their legacy codes resolved and their colours.
fn resolved<T: Clone + Into<horizon_client::text::Text>>(
lines: &[(T, u32)],
) -> Vec<(horizon_client::text::Text, u32)> {
lines
.iter()
.map(|(text, color)| {
let text: horizon_client::text::Text = text.clone().into();
(text.resolve_legacy(), *color)
})
.collect();
let atlas = &self.gui.atlas;
.collect()
}
/// The tooltip's text's top-left corner and the box's size: beside the
/// cursor, kept on screen, `extra` pixels taller than the text.
fn tooltip_geometry(
atlas: &horizon_client::hud::HudAtlas,
lines: &[(horizon_client::text::Text, u32)],
(x, y): (f32, f32),
(width, height): (f32, f32),
extra: f32,
) -> (f32, f32, f32, f32) {
let w = lines
.iter()
.map(|(t, _)| atlas.font.width_text(t))
.fold(0.0, f32::max);
let h = if lines.len() == 1 {
.fold(0.0, f32::max)
.max(if extra > 0.0 { 96.0 } else { 0.0 });
let text_h = if lines.len() == 1 {
8.0
} else {
8.0 + 2.0 + (lines.len() - 1) as f32 * 10.0
};
let h = text_h + extra;
let mut x = x + 12.0;
let mut y = y - 12.0;
if x + w > batch.width - 4.0 {
if x + w > width - 4.0 {
x = (x - 28.0 - w).max(4.0);
}
if y + h + 3.0 > batch.height {
y = batch.height - h - 3.0;
if y + h + 3.0 > height {
y = height - h - 3.0;
}
y = y.max(4.0);
(x, y, w, h)
}
/// The box and its frame sprites.
fn tooltip_box(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
x: f32,
y: f32,
w: f32,
h: f32,
) {
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240));
batch.fill(
atlas,
@@ -1968,12 +2052,137 @@ impl Game<'_> {
h + 4.0,
rgba(60, 0, 190, 80),
);
}
fn tooltip_text(
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
lines: &[(horizon_client::text::Text, u32)],
x: f32,
y: f32,
) {
let mut ty = y;
for (i, (text, color)) in lines.iter().enumerate() {
batch.text_styled(atlas, text, x, ty, *color, true);
ty += if i == 0 { 12.0 } else { 10.0 };
}
}
/// A stack's tooltip: its lines, and under them a bundle's contents
/// (`ClientBundleTooltip`: the 4 wide grid, the `+N` of what it leaves
/// out, and the weight bar).
pub(super) fn draw_stack_tooltip(
&mut self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
y: f32,
) {
let lines = Self::resolved(&self.stack_tooltip(stack));
if lines.is_empty() {
return;
}
// The component's height does not depend on where it is drawn, so a
// probe at the origin gives the box its extra height.
let probe = horizon_client::bundle::tooltip_layout(self.res.items(), stack, 0.0, 0.0, 96.0);
let extra = probe.as_ref().map_or(0.0, |l| l.height + 2.0);
let atlas = &self.gui.atlas;
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), extra);
Self::tooltip_box(batch, atlas, x, y, w, h);
let text_h = h - extra;
Self::tooltip_text(batch, atlas, &lines, x, y);
let Some(layout) =
horizon_client::bundle::tooltip_layout(self.res.items(), stack, x, y + text_h + 2.0, w)
else {
return;
};
self.draw_bundle_tooltip(batch, &layout);
}
/// `ClientBundleTooltip.extractImage`: the slots, the count past them and
/// the progress bar.
fn draw_bundle_tooltip(
&mut self,
batch: &mut HudBatch,
layout: &horizon_client::bundle::TooltipLayout,
) {
for (at, stack) in &layout.slots {
// 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",
at[0],
at[1],
24.0,
24.0,
u32::MAX,
);
let stack = stack.clone();
let [x, y] = *at;
self.draw_item(batch, &stack, x + 4.0, y + 4.0);
}
let atlas = &self.gui.atlas;
if let Some((at, hidden)) = layout.overflow {
let label = horizon_client::text::Text::literal(format!("+{hidden}"));
let w = atlas.font.width_text(&label);
batch.text_styled(
atlas,
&label,
at[0] + 12.0 - (w / 2.0).floor(),
at[1] + 10.0,
u32::MAX,
true,
);
}
if let Some((at, description)) = layout
.empty_description
.zip(Some("item.minecraft.bundle.empty.description"))
{
let text = horizon_client::text::Text::translate(description, &[]);
let mut ty = at[1];
for line in atlas.font.split_lines(&text, layout.width) {
batch.text_styled(atlas, &line.0, at[0], ty, 0xFFAA_AAAA, false);
ty += 9.0;
}
}
let [bx, by] = layout.bar;
let fill_sprite = if layout.full {
"gui/sprites/container/bundle/bundle_progressbar_full"
} else {
"gui/sprites/container/bundle/bundle_progressbar_fill"
};
horizon_client::ui::nine_slice(
batch,
atlas,
fill_sprite,
bx + 1.0,
by,
layout.fill,
13.0,
u32::MAX,
);
horizon_client::ui::nine_slice(
batch,
atlas,
"gui/sprites/container/bundle/bundle_progressbar_border",
bx,
by,
96.0,
13.0,
u32::MAX,
);
if let Some(key) = layout.label {
let label = horizon_client::text::Text::translate(key, &[]);
let w = atlas.font.width_text(&label);
batch.text_styled(
atlas,
&label,
bx + 48.0 - (w / 2.0).floor(),
by + 3.0,
u32::MAX,
true,
);
}
}
/// Hovered-slot tooltip on the plain inventory, crafting and container screens.
pub(super) fn draw_slot_tooltip(&mut self, batch: &mut HudBatch) {
if self.carried.is_some() {
@@ -1984,8 +2193,7 @@ impl Game<'_> {
};
let stack = self.slot_view(slot);
if let Some(stack) = stack {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
} else if let Some(text) = self.crafter_tooltip(slot) {
self.draw_tooltip_lines(
batch,
@@ -2041,5 +2249,13 @@ impl Game<'_> {
}
/// The anvil's rename box (`AnvilScreen`: `setMaxLength(50)`).
const ANVIL_NAME: horizon_client::clipboard::FieldId = ("anvil.name", 0);
const ANVIL_NAME_CHARS: usize = 50;
fn anvil_box() -> horizon_client::ui::EditBox {
let mut edit = horizon_client::ui::EditBox::new(0.0, 0.0, 103.0, 12.0);
edit.bordered = false;
edit.max_length = ANVIL_NAME_CHARS;
edit.text_color = u32::MAX;
edit.focused = true;
edit
}
@@ -210,6 +210,7 @@ impl Game<'_> {
if bg != 0 {
batch.fill(
atlas,
// submitTextBackground(-1, -1, width, height): corners, not a size.
-1.0,
-1.0,
width + 1.0,
+1 -2
View File
@@ -803,8 +803,7 @@ impl Game<'_> {
text.split('\n').map(|l| (l.to_owned(), u32::MAX)).collect();
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
} else if let Some(stack) = tooltip {
let lines = self.stack_tooltip(&stack);
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
self.draw_stack_tooltip(batch, &stack, self.cursor.0, self.cursor.1);
}
true
}
+215 -52
View File
@@ -65,8 +65,10 @@ mod arena;
mod cull;
mod end_sky;
mod flat;
mod hdr;
pub mod lightning;
mod lod;
mod native_material;
mod native_pack;
mod native_post;
mod native_sky;
@@ -429,6 +431,8 @@ pub struct Renderer {
/// The mipmap levels sampled (the Mipmap Levels option), at most `mip_levels - 1`.
mipmaps: u32,
terrain_layout: wgpu::BindGroupLayout,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain.
terrain_sprites: [f64; 2],
sprite_sampler: wgpu::Sampler,
lightmap_view: wgpu::TextureView,
lightmap_sampler: wgpu::Sampler,
@@ -460,7 +464,7 @@ pub struct Renderer {
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: HashMap<(i32, i32), u32>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), u32>,
/// Bumped whenever a column becomes or stops being complete.
detail_revision: u64,
arena: arena::Arena,
@@ -494,13 +498,17 @@ pub struct Renderer {
/// The shadow camera's draws by page, in bands of distance from the light.
shadow_runs: page_runs::PageRuns,
/// Block-entity blocks per meshed section (world position, state id).
block_entities: HashMap<SectionKey, Vec<([i32; 3], u32)>>,
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is scRGB.
hdr: Option<hdr::Hdr>,
/// Whether the window's surface can present scRGB.
hdr_capable: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
@@ -907,15 +915,24 @@ async fn open_device(
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
match adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
})
.await
{
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
return Ok(Opened {
instance,
@@ -1040,7 +1057,7 @@ impl Renderer {
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let (config, present_modes) = if let Some(surface) = &surface {
let (config, present_modes, hdr_capable) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(&adapter, size.width.max(1), size.height.max(1))
.ok_or("surface has no supported configuration")?;
@@ -1068,7 +1085,7 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone())
(config, caps.present_modes.clone(), hdr::supported(&caps))
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -1082,7 +1099,7 @@ impl Renderer {
view_formats: vec![format],
color_space: wgpu::SurfaceColorSpace::default(),
};
(config, Vec::new())
(config, Vec::new(), false)
};
let offscreen = surface
.is_none()
@@ -1926,14 +1943,20 @@ impl Renderer {
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
// RenderPipelines.CELESTIAL: additive (src alpha, one).
// RenderPipelines.CELESTIAL: additive (src alpha, one). The
// alpha goes to 0: HDR output (hdr.wgsl) lets the sun and
// moon shine; nothing else reads the frame's alpha.
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::OVER,
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Zero,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -2370,6 +2393,7 @@ impl Renderer {
mip_levels,
mipmaps: mip_levels - 1,
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_sampler,
lightmap_view,
lightmap_sampler,
@@ -2391,7 +2415,7 @@ impl Renderer {
lod_cutout: Default::default(),
lod_retiring: Default::default(),
lod_pending: None,
detail_columns: HashMap::new(),
detail_columns: Default::default(),
detail_revision: 0,
arena,
sorted_indices,
@@ -2412,10 +2436,12 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
block_entities: HashMap::new(),
block_entities: Default::default(),
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
hdr: None,
hdr_capable,
pack_post: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
@@ -2485,7 +2511,12 @@ impl Renderer {
// Wayland allows only one tearing-control object per window surface.
self.release_surface();
let surface = self.instance.create_surface(self.window.clone())?;
surface.configure(&self.device, &self.config);
Self::configure(
&self.device,
&surface,
&self.surface_config(),
self.hdr.as_ref(),
);
self.surface = Some(surface);
Ok(())
}
@@ -2548,7 +2579,12 @@ impl Renderer {
&& let Some(surface) = &self.surface
{
self.config.present_mode = mode;
surface.configure(&self.device, &self.config);
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
}
pub fn resize(&mut self, width: u32, height: u32) {
@@ -2559,7 +2595,12 @@ impl Renderer {
self.config.width = width;
self.config.height = height;
if let Some(surface) = &self.surface {
surface.configure(&self.device, &self.config);
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
} else {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
@@ -2848,20 +2889,106 @@ impl Renderer {
self.detail_columns.insert(column, next);
}
}
pub fn remove(&mut self, key: SectionKey) {
self.set_detail(key, false);
if self.block_entities.remove(&key).is_some() {
/// Every section of these columns goes: one lookup per column where the
/// tables are by column, and the meshes' ranges freed in one batch. A
/// teleport removes a few thousand columns in one frame.
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
let mut released = Vec::new();
let mut block_entities = false;
for (x, z) in columns {
if self.detail_columns.remove(&(x, z)) == Some(COMPLETE_COLUMN) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
let retiring = !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(x, z));
for sy in 0..crate::chunk::SECTIONS as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
self.lod_retiring.insert(key);
} else if let Some(section) = self.sections.remove(key) {
released.push(section);
}
}
}
if block_entities {
self.block_entity_revision = next_block_entity_revision();
}
self.occlusion.remove(key);
if !self.lod_cutout.is_empty() && self.lod_cutout.contains(&(key.0, key.2)) {
// Distant tiles still leave its column out: drawn until they don't.
self.lod_retiring.insert(key);
} else {
self.release_section(key);
if let Some(flat) = &mut self.flat {
for id in released.iter().filter_map(|s| s.flat_id) {
flat.remove(id);
}
}
self.sorted_indices
.release_all(released.iter().filter_map(|s| {
let (page, start) = s.sorted_indices?;
Some((page, start, u64::from(s.translucent) * 6))
}));
self.arena.release_all(
released
.iter()
.map(|s| (s.page, s.start, u64::from(s.solid + s.translucent) * 4)),
);
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
/// again: a new swapchain starts without it.
fn configure(
device: &wgpu::Device,
surface: &wgpu::Surface<'_>,
config: &wgpu::SurfaceConfiguration,
hdr: Option<&hdr::Hdr>,
) {
surface.configure(device, config);
if let Some(hdr) = hdr {
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak);
}
}
fn configure_surface(&self) {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
}
fn send_hdr_metadata(&self) {
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if self.hdr.is_some() {
config.format = hdr::FORMAT;
config.view_formats = vec![hdr::FORMAT];
config.color_space = hdr::COLOR_SPACE;
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// HDR output (Horizon setting): scRGB at `paper_white` nits, the world's
/// highlights expanded up to `peak` nits. Off where the surface can't present it.
pub fn set_hdr(&mut self, on: bool, paper_white: f32, peak: f32) {
let on = on && self.hdr_capable && self.surface.is_some();
if on != self.hdr.is_some() {
self.hdr =
on.then(|| hdr::Hdr::new(&self.device, self.config.format.remove_srgb_suffix()));
self.configure_surface();
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak) != (paper_white, peak)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
self.send_hdr_metadata();
}
}
pub fn set_world_post(&mut self, scale: f32, antialias: bool) {
self.world_post.scale = scale.clamp(0.25, 2.0);
self.world_post.antialias = antialias;
@@ -2937,9 +3064,9 @@ impl Renderer {
self.native = None;
self.pack_atlas = None;
}
/// Runs the sky and post stages of one of Horizon's own shader packs from now on (`None`:
/// no pack), with its options' values; replaces a `shaders/` pack. Returns the stages'
/// labels; on an error the pack that ran keeps running.
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
/// stages' labels; on an error the pack that ran keeps running.
pub fn set_native_shader_pack(
&mut self,
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
@@ -2948,14 +3075,14 @@ impl Renderer {
self.native = None;
return Ok(Vec::new());
};
let format = self.config.format.remove_srgb_suffix();
let (pack, labels) = native_pack::NativePack::new(
&self.device,
&self.terrain_layout,
report,
&constants,
format,
)?;
let terrain = native_material::TerrainShape {
globals: &self.terrain_layout,
flat: self.flat.as_ref().map(|f| &f.draw_layout),
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
};
let (pack, labels) =
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
self.native = pack.draws().then_some(pack);
if self.native.is_some() {
self.pack_post = None;
@@ -3197,7 +3324,12 @@ impl Renderer {
}
Some(wgpu::CurrentSurfaceTexture::Outdated) => {
if let Some(surface) = &self.surface {
surface.configure(&self.device, &self.config);
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
return Ok(false);
}
@@ -3209,9 +3341,11 @@ impl Renderer {
return Err("surface acquisition failed validation".into());
}
};
let target_texture = match &surface_frame {
Some(f) => f.texture.clone(),
None => self.offscreen.clone().ok_or("no offscreen target")?,
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
(Some(f), None) => f.texture.clone(),
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
};
// One command buffer for the frame, after the section copies staged
// since the last one: Metal commits every command buffer wgpu-core
@@ -4179,6 +4313,21 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
sky.draw(&mut pass, &native.frame);
}
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
// which read the pack's frame at group 3.
let material = self
.native
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
let translucent_pipeline = material.map_or(&self.translucent, |(m, _)| &m.translucent);
let pack_flat = material.and_then(|(m, _)| m.flat.as_ref());
let pack_frame = |pass: &mut wgpu::RenderPass<'_>| {
if let Some((_, group)) = material {
pass.set_bind_group(horizon_shader_api::stages::GROUP, group, &[]);
}
};
{
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: world_view,
@@ -4212,6 +4361,7 @@ impl Renderer {
..Default::default()
});
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
if frame.title || (sky_visible && (frame.skybox || frame.end_sky)) {
if !frame.title && frame.skybox && !pack_draws_sky && !native_sky_pass {
match self
@@ -4260,7 +4410,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
Some(flat) => {
pass.set_pipeline(&flat.solid);
pass.set_pipeline(pack_flat.map_or(&flat.solid, |p| &p.0));
let vertices = |p| self.arena.buffer(p);
match cull {
// As below: phase 1, the test, then phase 2's draws
@@ -4278,7 +4428,8 @@ impl Renderer {
pack_reads_world,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&flat.solid);
pack_frame(&mut pass);
pass.set_pipeline(pack_flat.map_or(&flat.solid, |p| &p.0));
flat.draw_phase2(&mut pass, vertices);
}
None => flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices),
@@ -4287,7 +4438,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
None => {
pass.set_pipeline(&self.solid);
pass.set_pipeline(solid_pipeline);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
match cull {
@@ -4307,7 +4458,8 @@ impl Renderer {
pack_reads_world,
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&self.solid);
pack_frame(&mut pass);
pass.set_pipeline(solid_pipeline);
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
@@ -4399,7 +4551,7 @@ impl Renderer {
// The pack draws translucent terrain after its deferred programs.
_ if pack_draws_water => {}
Some(flat) => {
pass.set_pipeline(&flat.translucent);
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(&mut pass, flat.translucent_calls(), |p| {
self.arena.buffer(p)
});
@@ -4407,7 +4559,7 @@ impl Renderer {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
None => {
pass.set_pipeline(&self.translucent);
pass.set_pipeline(translucent_pipeline);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
@@ -4591,6 +4743,9 @@ impl Renderer {
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
}
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder);
}
if hand_quads > 0 && !pack_draws_hand {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -4745,6 +4900,9 @@ impl Renderer {
self.section_copy_bytes = 0;
self.section_uploads.finish(&encoder);
}
if let (Some(hdr), Some(f)) = (&self.hdr, &surface_frame) {
hdr.present(&self.queue, &mut encoder, &f.texture, !frame.title);
}
let commands = crate::spans::time("encode", || encoder.finish());
let first_frame = self.first_frame == FirstFrame::Submit;
if first_frame {
@@ -4781,7 +4939,12 @@ impl Renderer {
}
self.queue.present(surface_frame);
if reconfigure && let Some(surface) = &self.surface {
surface.configure(&self.device, &self.config);
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
} else {
// No swapchain paces offscreen frames: keep two on the GPU at most.
+87
View File
@@ -39,6 +39,35 @@ impl FreeList {
}
self.0.insert(start, len);
}
/// `give` for many ranges: one merge of the sorted ranges into the list,
/// where giving them one by one costs a few tree lookups each.
fn give_all(&mut self, mut ranges: Vec<(u64, u64)>) {
if ranges.len() < 64 {
for (start, len) in ranges {
self.give(start, len);
}
return;
}
ranges.sort_unstable();
let mut free = std::mem::take(&mut self.0).into_iter().peekable();
let mut given = ranges.into_iter().peekable();
let mut merged: Vec<(u64, u64)> = Vec::new();
loop {
let next = match (free.peek(), given.peek()) {
(Some(a), Some(b)) if a.0 <= b.0 => free.next(),
(_, Some(_)) => given.next(),
(Some(_), None) => free.next(),
(None, None) => break,
};
let (start, len) = next.expect("peeked");
match merged.last_mut() {
Some(last) if last.0 + last.1 == start => last.1 += len,
_ => merged.push((start, len)),
}
}
// Sorted: the tree is built in one pass.
self.0 = merged.into_iter().collect();
}
}
/// One vertex buffer of the arena; only the newest page grows.
@@ -237,6 +266,26 @@ impl Arena {
.free
.give(start, len);
}
/// `release` for many ranges (page, start, vertices) at once.
pub fn release_all(&mut self, ranges: impl IntoIterator<Item = (u32, u64, u64)>) {
let mut by_page: Vec<Vec<(u64, u64)>> = Vec::new();
for (page, start, vertices) in ranges {
let page = page as usize;
if by_page.len() <= page {
by_page.resize_with(page + 1, Vec::new);
}
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
}
for (page, ranges) in by_page.into_iter().enumerate() {
if !ranges.is_empty() {
self.pages[page]
.as_mut()
.expect("live arena page")
.free
.give_all(ranges);
}
}
}
/// Moves a page into a larger buffer; false (the page unchanged) when
/// there is no memory for it.
fn grow(
@@ -435,6 +484,44 @@ mod tests {
assert_eq!(free, FreeList::new(4 * GRAIN));
}
#[test]
fn many_ranges_given_at_once_coalesce_as_one_by_one() {
let fill = || {
let mut free = FreeList::new(1 << 30);
let taken: Vec<(u64, u64)> = (0..500u64)
.map(|i| {
let len = GRAIN * (1 + i * 7 % 5);
(free.take(len).unwrap(), len)
})
.collect();
for &(start, len) in taken.iter().step_by(3) {
free.give(start, len);
}
(free, taken)
};
// Every other range of those still taken, in a scattered order.
let (mut one_by_one, taken) = fill();
let (mut at_once, _) = fill();
let mut given: Vec<(u64, u64)> = (0..taken.len())
.filter(|i| i % 3 != 0 && i % 2 == 0)
.map(|i| taken[i])
.collect();
given.sort_by_key(|&(start, _)| start.wrapping_mul(0x9E37_79B9_7F4A_7C15));
assert!(given.len() >= 64);
for &(start, len) in &given {
one_by_one.give(start, len);
}
at_once.give_all(given);
assert_eq!(at_once, one_by_one);
// The rest too: everything is free again, in one range.
let rest: Vec<(u64, u64)> = (0..taken.len())
.filter(|i| i % 3 != 0 && i % 2 != 0)
.map(|i| taken[i])
.collect();
at_once.give_all(rest);
assert_eq!(at_once, FreeList::new(1 << 30));
}
#[test]
fn first_fit_reuses_the_lowest_hole() {
let mut free = FreeList::new(8 * GRAIN);
+222
View File
@@ -0,0 +1,222 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an scRGB swapchain at the player's paper white,
//! expanding the world's highlights towards their peak brightness. The world
//! is copied aside before the hand and HUD so only it is expanded.
/// The swapchain format and colour space HDR output presents in.
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub const COLOR_SPACE: wgpu::SurfaceColorSpace = wgpu::SurfaceColorSpace::ExtendedSrgbLinear;
/// Whether `caps` can present scRGB.
pub fn supported(caps: &wgpu::SurfaceCapabilities) -> bool {
caps.color_spaces(FORMAT)
.contains(wgpu::SurfaceColorSpaces::EXTENDED_SRGB_LINEAR)
}
struct Targets {
size: [u32; 2],
frame: wgpu::Texture,
world: wgpu::Texture,
group: wgpu::BindGroup,
}
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
params: wgpu::Buffer,
/// The frame's format: what every other pipeline draws in.
format: wgpu::TextureFormat,
targets: Option<Targets>,
/// `HORIZON_HDR_DEBUG=1`: paint expanded pixels magenta.
debug: bool,
/// Paper white and peak brightness, in nits.
pub paper_white: f32,
pub peak: f32,
}
impl Hdr {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
});
let texture = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon HDR bindings"),
entries: &[
texture(0),
texture(1),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon HDR"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon HDR"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_hdr"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_hdr"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
layout,
pipeline,
params,
format,
targets: None,
debug: std::env::var_os("HORIZON_HDR_DEBUG").is_some_and(|v| v == "1"),
paper_white: 200.0,
peak: 1000.0,
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
usage: usage | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
};
let frame = texture(
"Horizon HDR frame",
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let world = texture("Horizon HDR world", wgpu::TextureUsages::COPY_DST);
let view = |t: &wgpu::Texture| t.create_view(&Default::default());
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon HDR bindings"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view(&frame)),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&view(&world)),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.params.as_entire_binding(),
},
],
});
self.targets = Some(Targets {
size,
frame,
world,
group,
});
}
self.targets.as_ref().unwrap().frame.clone()
}
/// Keeps the world as drawn so far, before the hand and HUD cover it.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
};
encoder.copy_texture_to_texture(
t.frame.as_image_copy(),
t.world.as_image_copy(),
t.frame.size(),
);
}
/// Writes the finished frame to the swapchain image `target`.
pub fn present(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::Texture,
world: bool,
) {
let Some(t) = &self.targets else {
return;
};
let paper = self.paper_white.max(1.0);
let params = [
paper / 80.0,
(self.peak / paper).max(1.0),
f32::from(u8::from(world)),
f32::from(u8::from(self.debug)),
];
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
let view = target.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &t.group, &[]);
pass.draw(0..3, 0..1);
}
}
@@ -0,0 +1,328 @@
//! The material stage of Horizon's own shader packs (docs/shaders.md): the pack's `material`
//! function gives the lit colour of each point of a block's surface, before the alpha test and
//! fog. Terrain still draws with Horizon's meshes, culling and draw lists; only its pipelines
//! change. Their shader is the pack's stage (`CheckedStage::source`), then a copy of
//! terrain.wgsl's terrain section whose names all take a `horizon_terrain_` prefix
//! (`wgsl_prefix.rs`), then entries that hand the pack what vanilla's `fs_solid` and
//! `fs_translucent` combine: the texture, the vertex tint, the light levels and the lightmap's
//! colour. They read the terrain's group 0, the flat draws' group 1 where those run, and the
//! pack's frame at group 3.
use horizon_shader_api::{check::CheckedStage, stages::GROUP};
use super::native_post::{NativeFrame, present_constants};
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) lightmap: vec3<f32>,
@location(5) position: vec3<f32>,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.layer = layer;
out.tint = vec4(color.rgb, 1.0);
let block = max(f32(light.y), horizon_terrain_dynamic_block_light(pos));
let sky = f32(light.x);
// 16 per level, 15 levels.
out.light = min(vec2(block, sky) / 240.0, vec2(1.0));
out.lightmap = horizon_terrain_sample_lightmap(sky / 16.0, block / 16.0).rgb;
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
return out;
}
@vertex fn horizon_material_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonMaterialOut {
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
@vertex fn horizon_material_vs_flat(
@builtin(vertex_index) vertex: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonMaterialOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[vertex >> 8u]];
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
}
";
/// What terrain pipelines are built against: the renderer's layouts and constants.
pub struct TerrainShape<'a> {
/// The terrain's group 0.
pub globals: &'a wgpu::BindGroupLayout,
/// The flat draws' group 1, where they run (`flat.rs`).
pub flat: Option<&'a wgpu::BindGroupLayout>,
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`.
pub sprites: [f64; 2],
/// The world target's colour format.
pub format: wgpu::TextureFormat,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
/// renderer's instanced draws and, where they run, its flat ones.
pub struct NativeMaterial {
pub solid: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub flat: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>,
}
/// terrain.wgsl's terrain section (its globals, bindings, sampling, lighting and fog), with
/// every name it declares prefixed.
fn vanilla_terrain() -> String {
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX,
)
}
/// The terrain shader for the pack's material stage `source`.
fn shader_source(source: &str) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
vanilla_terrain()
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
let text = shader_source(&stage.source);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
impl NativeMaterial {
/// The pipelines of the pack's material `stage` with the option values `constants`.
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
) -> Result<Self, String> {
let module = module(stage)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame.layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack terrain"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
// As the renderer's terrain pipelines (gfx.rs): back faces culled, reversed depth.
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
fragment: &str,
blend: Option<wgpu::BlendState>| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: shape.format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let blend = Some(wgpu::BlendState::ALPHA_BLENDING);
let instanced = layout(None);
let buffers = [Some(vertices.clone()), Some(instances)];
let solid = pipeline(
&instanced,
"horizon_material_vs",
&buffers,
"horizon_material_fs_solid",
None,
);
let translucent = pipeline(
&instanced,
"horizon_material_vs",
&buffers,
"horizon_material_fs_translucent",
blend,
);
let flat = shape.flat.map(|group| {
let layout = layout(Some(group));
let buffers = [Some(vertices.clone())];
let vs = "horizon_material_vs_flat";
(
pipeline(&layout, vs, &buffers, "horizon_material_fs_solid", None),
pipeline(
&layout,
vs,
&buffers,
"horizon_material_fs_translucent",
blend,
),
)
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(Self {
solid,
translucent,
flat,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"material.wgsl\"\n\
[[options]]\nid = \"gain\"\nname = \"Gain\"\ntype = \"float\"\n\
default = 1.0\nmin = 0.0\nmax = 2.0\n";
let files = [(MANIFEST, manifest), ("material.wgsl", material)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
assert_eq!(report.stages[0].kind, StageKind::Material);
report.stages.remove(0)
}
#[test]
fn the_terrain_takes_a_prefix_and_validates_with_a_pack() {
let terrain = vanilla_terrain();
for name in [
"Globals",
"globals",
"sprites_7",
"sprite",
"SPRITE_SPLIT",
"block_slots",
"sample_lightmap",
"apply_fog",
] {
assert!(terrain.contains(&format!("{PREFIX}{name}")), "{name}");
}
assert!(!terrain.contains("fs_sky"), "the sky stays out");
// A pack may use any name Horizon's terrain uses unprefixed.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\n\
fn sprite(c: vec4<f32>) -> vec4<f32> { return c * PI; }\n\
fn apply_fog(c: vec4<f32>) -> vec4<f32> { return c * gain; }\n\
fn material(input: MaterialInput) -> Surface {\n \
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
return Surface(apply_fog(sprite(lit)));\n}\n";
let stage = stage(own);
let module = module(&stage).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
let entries: Vec<&str> = module
.entry_points
.iter()
.map(|e| e.name.as_str())
.collect();
for entry in [
"horizon_material_vs",
"horizon_material_vs_flat",
"horizon_material_fs_solid",
"horizon_material_fs_translucent",
] {
assert!(entries.contains(&entry), "{entry}");
}
drop(info);
assert_eq!(
present_constants(&module, &[("gain".into(), 1.5), ("other".into(), 1.0)]),
[("gain", 1.5)]
);
}
}
+19 -5
View File
@@ -1,8 +1,9 @@
//! One of Horizon's own shader packs while it runs (docs/shaders.md): the frame its stages
//! read, its sky and post stages, and the timer of their passes.
//! read, its sky, material and post stages, and the timer of their passes.
use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -10,6 +11,7 @@ use super::pack_timing::PassTiming;
pub struct NativePack {
pub frame: NativeFrame,
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
@@ -17,16 +19,16 @@ pub struct NativePack {
impl NativePack {
/// The pack of the checked stages `report` with the option values `constants`, drawing
/// onto targets of `format`; `globals` is the terrain's group 0, which the sky reads.
/// onto the world's targets as `terrain` describes; the sky reads the terrain's group 0.
/// Also returns its stages' labels, in the order they draw. Errors name the stage wgpu
/// refused.
pub fn new(
device: &wgpu::Device,
globals: &wgpu::BindGroupLayout,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let frame = NativeFrame::new(device);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
@@ -37,16 +39,28 @@ impl NativePack {
.find(|s| s.kind == StageKind::Sky)
.map(|stage| NativeSky::new(device, globals, &frame, stage, constants, format))
.transpose()?;
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
others
.iter()
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
let pack = Self {
frame,
sky,
material,
post,
timing: None,
};
@@ -55,6 +69,6 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.post.is_some()
self.sky.is_some() || self.material.is_some() || self.post.is_some()
}
}
+19 -8
View File
@@ -522,16 +522,27 @@ impl Occlusion {
}
pub fn set(&mut self, key: SectionKey, vis: u64) {
if self.columns.set(key, vis) {
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
self.changed(key, vis);
}
}
pub fn remove(&mut self, key: SectionKey) {
self.set(key, ALL_VISIBLE);
fn changed(&mut self, key: SectionKey, vis: u64) {
self.edits.push(Edit::Set(key, vis));
self.dirty = true;
self.changed.push(key);
if self.running.is_some() {
self.changed_late.push(key);
}
}
/// Every section of the column `x`, `z` goes (sees through every face).
pub fn remove_column(&mut self, x: i32, z: i32) {
let Some(column) = self.columns.map.remove(&(x, z)) else {
return;
};
for (y, &vis) in column.iter().enumerate() {
if vis != ALL_VISIBLE {
self.changed((x, y as i32, z), ALL_VISIBLE);
}
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
+2 -1
View File
@@ -169,6 +169,7 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
@@ -178,7 +179,7 @@ impl PackAtlas {
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = hz_v_color;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -399,7 +399,8 @@ impl FrameUniforms {
_ => Value::i(&[0]),
}
};
self.values = NAMES.iter().map(|n| value(n)).collect();
self.values.clear();
self.values.extend(NAMES.iter().map(|n| value(n)));
self.frame = (self.frame + 1) % 720_720;
}
+20 -2
View File
@@ -255,6 +255,12 @@ pub struct Graphics {
pub resolution_scale: u32,
/// FXAA over the world.
pub fxaa: bool,
/// HDR output (scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits.
pub hdr_peak: u32,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -286,6 +292,9 @@ impl Default for Graphics {
overlay: Overlay::Off,
resolution_scale: 100,
fxaa: false,
hdr: false,
hdr_paper_white: 200,
hdr_peak: 1000,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -337,6 +346,9 @@ impl Graphics {
"workers" => g.workers = number(g.workers).min(64),
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"fxaa" => g.fxaa = flag(g.fxaa),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => g.hdr_paper_white = number(g.hdr_paper_white).clamp(80, 480),
"hdrPeak" => g.hdr_peak = number(g.hdr_peak).clamp(200, 2000),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -354,7 +366,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -379,6 +391,9 @@ impl Graphics {
},
self.resolution_scale,
bool_text(self.fxaa),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
@@ -444,7 +459,7 @@ pub fn apply_preset(preset: Preset, s: &mut crate::settings::Settings) {
let g = &mut s.horizon;
g.preset = preset;
g.cave_culling = true;
g.animated_textures = preset != Preset::Performance;
g.animated_textures = true;
g.chunk_updates = 0;
g.upload_budget_kib = 0;
g.sky = true;
@@ -476,6 +491,9 @@ mod tests {
overlay: Overlay::Graph,
resolution_scale: 75,
fxaa: true,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
+27
View File
@@ -37,6 +37,9 @@ pub enum H {
Overlay,
ResolutionScale,
Antialiasing,
Hdr,
HdrPaperWhite,
HdrPeak,
BatteryMode,
GraphicsApi,
}
@@ -221,6 +224,21 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"FXAA smooths jagged edges of the world; slightly softer image.",
),
h(
H::Hdr,
None,
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
),
h(
H::HdrPaperWhite,
None,
"HDR: how bright white paper and menus look. Match your desktop.",
),
h(
H::HdrPeak,
None,
"HDR: how bright the brightest highlights get. Your display's peak.",
),
h(
H::PresentMode,
None,
@@ -324,6 +342,8 @@ impl H {
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
H::HdrPaperWhite => Some((g.hdr_paper_white as f32 - 80.0) / 400.0),
H::HdrPeak => Some((g.hdr_peak as f32 - 200.0) / 1800.0),
_ => None,
}
}
@@ -365,6 +385,9 @@ impl H {
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -387,6 +410,8 @@ impl H {
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::HdrPaperWhite => g.hdr_paper_white = (80.0 + (v * 40.0).round() * 10.0) as u32,
H::HdrPeak => g.hdr_peak = (200.0 + (v * 36.0).round() * 50.0) as u32,
_ => {}
}
}
@@ -418,6 +443,7 @@ impl H {
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
H::Antialiasing => g.fxaa = !g.fxaa,
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::GraphicsApi => g.api = step(&GraphicsApi::ALL, g.api, back),
H::Pacing => {
@@ -453,6 +479,7 @@ impl Item {
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::Antialiasing) => g.fxaa,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
+69
View File
@@ -0,0 +1,69 @@
// Horizon's HDR output pass: the frame, drawn as without HDR in vanilla's gamma
// space, decoded to linear light at the paper white and written to the scRGB
// swapchain (1.0 = 80 nits). Bright pixels of light sources that still show the
// world as it was before the hand and HUD are expanded towards the peak.
@group(0) @binding(0) var frame: texture_2d<f32>;
@group(0) @binding(1) var world: texture_2d<f32>;
struct Params {
// Paper white in scRGB units (nits / 80).
paper: f32,
// Peak brightness relative to paper white; 1 expands nothing.
peak: f32,
// 1 when this frame drew a world.
world: f32,
// 1 paints expanded pixels magenta (HORIZON_HDR_DEBUG=1).
debug: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
// near-white spots (glowstone) and leaves the rest (lava, a jack o'lantern's
// face) nearly as it was. Pixels brighter than LIFT_FULL get STRENGTH of the
// headroom, dimmer ones less down to none at LIFT_START (lava's crust, a
// lantern's metal), and no channel goes past the peak. Brightness is the mean
// of luminance and the brightest channel, so saturated light counts as bright.
const LIFT_START: f32 = 0.18;
const LIFT_FULL: f32 = 0.42;
const STRENGTH: f32 = 0.7;
// Whether `c` still shows the world pixel `w`: the same, or darkened evenly
// across its channels, as the vignette and other screen-wide tints darken it.
// The hand, HUD and text replace pixels instead, so they never pass.
fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
if all(c == w) {
return true;
}
let k = c / max(w, vec3(1.0 / 255.0));
let mean = (k.r + k.g + k.b) / 3.0;
return mean <= 1.01 && mean > 0.5 && all(abs(k - vec3(mean)) < vec3(0.03));
}
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
let c = textureLoad(frame, at, 0).rgb;
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
// The world's alpha is 0 on light-emitting blocks, the sun and the moon
// (terrain.wgsl, the celestial pipeline): only those shine.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && shows_world(c, w.rgb) {
let y = 0.5 * (dot(light, vec3(0.2126, 0.7152, 0.0722)) + max(light.r, max(light.g, light.b)));
let lift = smoothstep(LIFT_START, LIFT_FULL, y);
if lift > 0.0 {
let top = max(light.r, max(light.g, light.b));
let gain = min(1.0 + (params.peak - 1.0) * STRENGTH * lift, params.peak / max(top, 1e-4));
light *= max(gain, 1.0);
if params.debug > 0.5 {
light = mix(light, vec3(gain, 0.0, gain), 0.6);
}
}
}
return vec4(light * params.paper, 1.0);
}
+4 -4
View File
@@ -1111,8 +1111,9 @@ impl SubtitleOverlay {
s.purge_old_instances(3000.0 * frame.multiplier, now);
}
if s.audible_from(position) && !s.played.is_empty() {
width = width.max(width_of(&Text::translate(&s.key, &[])));
audible.push(i);
let text_width = width_of(&Text::translate(&s.key, &[]));
width = width.max(text_width);
audible.push((i, text_width));
}
}
if audible.is_empty() {
@@ -1121,7 +1122,7 @@ impl SubtitleOverlay {
let w = |s: &str| font.width(s) as i32;
width += w("<") + w(" ") + w(">") + w(" ");
let mut row = 0;
for i in audible {
for (i, text_width) in audible {
let s = &self.subtitles[i];
let text_value = Text::translate(&s.key, &[]);
let Some(closest) = s.best_candidate(position) else {
@@ -1145,7 +1146,6 @@ impl SubtitleOverlay {
let half_width = width / 2;
let height = 9;
let half_height = height / 2;
let text_width = width_of(&text_value);
let brightness = s.brightness(closest, frame.multiplier, now);
out.push(GuiDraw::Push(vec![
PoseOp::Translate(
+8
View File
@@ -204,6 +204,14 @@ impl ItemStack {
pub fn custom_name(&self) -> Option<&str> {
self.data().and_then(|d| d.custom_name.as_deref())
}
/// `minecraft:item_name` in the current language (`getItemName`'s
/// component). It stays in the patch as loaded, so a translatable name
/// saves and reaches clients unchanged; the ominous banner's is modelled
/// by `ominous_banner` instead.
pub fn item_name(&self) -> Option<String> {
let name = self.data()?.other("minecraft:item_name")?;
Some(crate::entity_nbt::name_text(name).plain())
}
pub fn potion(&self) -> Option<&str> {
self.data().and_then(|d| d.potion.as_deref())
}
+125
View File
@@ -238,6 +238,125 @@ mod tests {
use super::*;
use horizon_protocol::registry::Ids;
#[test]
fn merchant_water_cost_survives_the_offer_packet() {
use horizon_protocol::{
buf::{Codec, Reader, Writer},
packets::play::{NetOffer, Offers},
};
let mut components = vec![String::new(); component::POTION_CONTENTS as usize + 1];
components[component::POTION_CONTENTS as usize] = "minecraft:potion_contents".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:potion".into(), "minecraft:emerald".into()]),
),
(
"minecraft:potion".into(),
Ids::from_names(vec!["minecraft:water".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let water = ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water");
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|registry, name| builtin.get(registry)?.id(name),
&Some(water.clone()),
)
.unwrap();
let offers = vec![NetOffer {
cost_a: wire.into(),
result: Stack::new(1, 1),
cost_b: Some(Stack::new(1, 2).into()),
out_of_stock: false,
uses: 0,
max_uses: 2,
xp: 0,
special_price: 0,
price_multiplier: 0.05,
demand: 0,
}];
let mut w = Writer::new();
Offers::write(&offers, &mut w);
let mut r = Reader::new(&w.data);
let decoded = Offers::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0);
let cost = crate::merchant::ItemCost {
stack: from_network(&decoded[0].cost_a.clone().into(), &builtin, &dynamic),
};
assert_eq!(cost.stack, water);
assert_eq!(cost.stack.potion(), Some("minecraft:water"));
let authoritative = crate::merchant::ItemCost {
stack: water.clone(),
};
for payment in [
water,
ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"),
ItemStack::new("minecraft:potion", 1),
] {
assert_eq!(cost.test(&payment), authoritative.test(&payment));
}
assert!(cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water")));
assert!(
!cost.test(&ItemStack::new("minecraft:potion", 1).with_potion("minecraft:swiftness"))
);
let emerald = crate::merchant::ItemCost {
stack: from_network(
&decoded[0].cost_b.clone().unwrap().into(),
&builtin,
&dynamic,
),
};
assert!(emerald.test(&ItemStack::new("minecraft:emerald", 2)));
}
/// A bundle's contents cross the wire, so a server's client can draw
/// its tooltip (BundleContents' stream codec: a list of templates).
#[test]
fn a_bundle_keeps_its_contents_across_the_wire() {
let mut components = vec![String::new(); 60];
components[component::BUNDLE_CONTENTS as usize] = "minecraft:bundle_contents".into();
let items = ["minecraft:air", "minecraft:bundle", "minecraft:stone"];
let builtin = Builtin {
registries: [
(
"minecraft:item".to_owned(),
Ids::from_names(items.iter().map(|s| s.to_string()).collect()),
),
(
"minecraft:data_component_type".to_owned(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic {
registries: Default::default(),
};
let mut bundle = ItemStack::new("minecraft:bundle", 1);
bundle.data_mut().bundle_contents = vec![ItemStack::new("minecraft:stone", 32)];
let wire = crate::lan::guest::stack_to_network(
|name| items.iter().position(|i| *i == name).unwrap_or(0) as i32,
|_, _| None,
&Some(bundle),
)
.unwrap();
let back = from_network(&wire, &builtin, &dynamic);
let contents = &back.data().expect("components").bundle_contents;
assert_eq!(contents.len(), 1);
assert_eq!(
(&*contents[0].item, contents[0].count),
("minecraft:stone", 32)
);
}
/// What a creative client sends (`stack_to_network`) comes back whole on
/// the server: damage, both enchantment lists, repair cost, name, trim.
#[test]
@@ -246,6 +365,7 @@ mod tests {
for (id, name) in [
(component::DAMAGE, "minecraft:damage"),
(component::CUSTOM_NAME, "minecraft:custom_name"),
(component::ITEM_NAME, "minecraft:item_name"),
(component::ENCHANTMENTS, "minecraft:enchantments"),
(component::POTION_CONTENTS, "minecraft:potion_contents"),
(component::REPAIR_COST, "minecraft:repair_cost"),
@@ -305,6 +425,10 @@ mod tests {
.push((Arc::from("minecraft:sharpness"), 2));
d.repair_cost = 9;
d.custom_name = Some("Keeper".to_owned());
// A translatable item name reaches the client as the same component.
let item_name = Tag::compound([("translate", Tag::String("filled_map.monument".into()))]);
d.other
.push(("minecraft:item_name".into(), item_name.clone()));
d.trim = Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")));
let id = |registry: &str, name: &str| {
builtin
@@ -334,6 +458,7 @@ mod tests {
);
assert_eq!(d.repair_cost, 9);
assert_eq!(d.custom_name.as_deref(), Some("Keeper"));
assert_eq!(d.other("minecraft:item_name"), Some(&item_name));
assert_eq!(
d.trim,
Some((Arc::from("minecraft:gold"), Arc::from("minecraft:sentry")))
+14 -4
View File
@@ -56,10 +56,9 @@ impl<'a> TooltipDisplay<'a> {
else {
return true;
};
let component = horizon_world::nbt::JavaString::from(component);
!hidden
.iter()
.any(|tag| matches!(tag, Tag::String(id) if id == &component))
!hidden.iter().any(|tag| {
matches!(tag, Tag::String(id) if id.0.iter().copied().eq(component.encode_utf16()))
})
}
}
@@ -189,6 +188,9 @@ pub fn name_line(stack: &ItemStack, translate: &dyn Fn(&str) -> Option<String>)
if let Some(name) = stack.custom_name() {
return name.to_owned();
}
if let Some(name) = stack.item_name() {
return name;
}
if stack.data().is_some_and(|d| d.ominous_banner) {
return translate("block.minecraft.ominous_banner")
.unwrap_or_else(|| "Ominous Banner".to_owned());
@@ -612,6 +614,14 @@ mod tests {
assert_eq!(rarity(&banner, "common"), "uncommon");
banner.data_mut().custom_name = Some("Captain's Standard".into());
assert_eq!(name_line(&banner, &translate), "Captain's Standard");
let mut custom_item = ItemStack::new("minecraft:diamond_sword", 1);
custom_item.data_mut().other.push((
"minecraft:item_name".into(),
Tag::String("Excalibur".into()),
));
assert_eq!(name_line(&custom_item, &translate), "Excalibur");
custom_item.data_mut().custom_name = Some("Custom Blade".into());
assert_eq!(name_line(&custom_item, &translate), "Custom Blade");
banner.enchant("minecraft:unbreaking", 1);
assert_eq!(rarity(&banner, "common"), "rare");
let plain = ItemStack::new("minecraft:white_banner", 1);
+167 -1
View File
@@ -120,6 +120,8 @@ pub struct ItemInfo {
pub crafting_remainder: Option<String>,
/// `dye`: the dye colour name (dyes, and anything else with the component).
pub dye: Option<String>,
/// `cushion/color`: the DyeColor name of a cushion item.
pub cushion_color: Option<String>,
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
pub block_transformer: Option<String>,
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
@@ -251,6 +253,10 @@ pub struct Items {
loot: HashMap<String, Value>,
/// The `jukebox_song` registry.
pub songs: HashMap<String, Song>,
/// The enchantment registry: loot functions enchant the items they roll
/// (`EnchantWithLevelsFunction`, `EnchantRandomlyFunction`), and the
/// station menus share this copy.
pub enchantments: Arc<crate::enchant::Enchantments>,
}
/// A `JukeboxSong`.
#[derive(Clone, Debug)]
@@ -599,6 +605,7 @@ impl Items {
.map(str::to_owned),
jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned),
dye: c["minecraft:dye"].as_str().map(str::to_owned),
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(),
crafting_remainder: CRAFT_REMAINDERS
@@ -701,12 +708,15 @@ impl Items {
item_tag_order: load_tag_lists(root, "item")?,
loot,
songs: load_songs(root),
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
})
}
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
self.items.get(id)
}
/// Only the block and item tags, for tools that need tag membership.
/// Only the block and item tags (and the enchantment registry, which the
/// station rules and loot functions are built from), for tools that need
/// tag membership.
pub fn tags_only(root: &Path) -> Result<Self> {
Ok(Self {
items: HashMap::new(),
@@ -717,6 +727,7 @@ impl Items {
item_tag_order: load_tag_lists(root, "item")?,
loot: HashMap::new(),
songs: HashMap::new(),
enchantments: Arc::new(crate::enchant::Enchantments::load(root)),
})
}
/// An item table with only tags, for unit tests of item rules.
@@ -734,6 +745,7 @@ impl Items {
item_tags,
loot: HashMap::new(),
songs: HashMap::new(),
enchantments: Arc::new(crate::enchant::Enchantments::default()),
}
}
/// Whether `block` is in the block tag `tag` (`minecraft:fire`, no `#`).
@@ -1371,6 +1383,16 @@ impl LootContext<'_> {
self.modifiers(&modifiers, &mut stack, random);
produced.push(stack);
}
/// An enchantment function's `options` (`HolderSet`): a tag, a list or
/// one id; every enchantment when absent (`registryAccess().listElements`).
fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec<Arc<str>> {
match options {
Value::String(s) if s.starts_with('#') => e.tag(s).to_vec(),
Value::String(s) => vec![Arc::from(s.as_str())],
Value::Array(a) => a.iter().filter_map(|x| x.as_str().map(Arc::from)).collect(),
_ => e.list.iter().map(|x| Arc::clone(&x.id)).collect(),
}
}
fn modifiers<R: RandomSource>(&self, m: &Value, stack: &mut ItemStack, random: &mut R) {
let list: Vec<&Value> = match m {
Value::Array(a) => a.iter().collect(),
@@ -1449,6 +1471,54 @@ impl LootContext<'_> {
.with_potion(id);
}
}
// EnchantWithLevelsFunction: EnchantmentHelper.enchantItem on
// the context's random (the End cities' gear).
"minecraft:enchant_with_levels" => {
let cost = Self::int_number(&f["levels"], random);
let e = &self.items.enchantments;
let pool = Self::enchant_pool(e, &f["options"]);
let chosen = e.select(self.items, random, &stack.item, cost, &pool);
// EnchantmentHelper.enchantItem: a book becomes one
// enchanted book (the fresh stack's count).
if &*stack.item == "minecraft:book" {
*stack = ItemStack::new("minecraft:enchanted_book", 1);
}
for (id, level) in chosen {
stack.enchant(&id, level);
}
}
// EnchantRandomlyFunction: one enchantment the item accepts,
// at a level between its min and max.
"minecraft:enchant_randomly" => {
let e = &self.items.enchantments;
let book = &*stack.item == "minecraft:book";
let only_compatible = f["only_compatible"].as_bool().unwrap_or(true);
let options: Vec<Arc<str>> = Self::enchant_pool(e, &f["options"])
.into_iter()
.filter(|id| {
book || !only_compatible || e.can_enchant(self.items, id, &stack.item)
})
.collect();
if options.is_empty() {
continue;
}
let id = options
[random.next_int_bound(options.len() as i32).unwrap_or(0) as usize]
.clone();
let max = e.get(&id).map_or(1, |d| d.max_level).max(1);
// `Mth.nextInt(random, 1, max)`: a single-level
// enchantment (Mending) returns 1 without drawing, so the
// rolls after this one keep vanilla's sequence.
let level = if max <= 1 {
1
} else {
1 + random.next_int_bound(max as i32).unwrap_or(0) as u32
};
if book {
*stack = ItemStack::new("minecraft:enchanted_book", 1);
}
stack.enchant(&id, level);
}
"minecraft:limit_count" => {
let limit = &f["limit"];
if let Some(min) = limit["min"].as_i64() {
@@ -1658,6 +1728,102 @@ mod tests {
assert_eq!(drops[0].potion(), Some("minecraft:fire_resistance"));
}
/// `/loot give` and every other table this dispatcher rolls: the two
/// enchantment functions (`EnchantWithLevelsFunction`,
/// `EnchantRandomlyFunction`) put their enchantments on the stack.
#[test]
fn loot_enchant_functions_enchant_the_rolled_item() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
let props = BTreeMap::new();
let mut random = LegacyRandom::new(11);
// A sword at levels 20..39 comes out enchanted, as in an End city.
let levels = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:diamond_sword","modifier":[
{"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:uniform","min":20.0,"max":39.0}}]}]}]});
let mut enchanted = 0;
for _ in 0..40 {
let drops = ctx(&items, None, &props, "").evaluate(&levels, &mut random);
if drops[0].enchantments().is_empty() {
continue;
}
enchanted += 1;
assert_eq!(&*drops[0].item, "minecraft:diamond_sword");
// Levels stay within the enchantment's range.
for (id, level) in drops[0].enchantments() {
let max = items
.enchantments
.get(id)
.expect("a known enchantment")
.max_level;
assert!((1..=max).contains(level), "{id} at {level}");
}
}
assert!(
enchanted > 30,
"only {enchanted} of 40 rolls were enchanted"
);
// `enchant_randomly` on a book makes one enchanted book, its levels
// stored; the pool is the enchantment tag it names.
let book = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:book","modifier":[
{"type":"minecraft:enchant_randomly","options":"#minecraft:on_random_loot"}]}]}]});
let drops = ctx(&items, None, &props, "").evaluate(&book, &mut random);
assert_eq!(&*drops[0].item, "minecraft:enchanted_book");
assert_eq!(drops[0].count, 1);
assert!(drops[0].enchantments().is_empty());
let stored = drops[0].stored_enchantments();
assert_eq!(stored.len(), 1, "{stored:?}");
assert!(
items
.enchantments
.in_tag("minecraft:on_random_loot", &stored[0].0)
);
// An item no enchantment accepts keeps none of them.
let dirt = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:dirt","modifier":[
{"type":"minecraft:enchant_with_levels","levels":{"type":"minecraft:constant","value":30.0}}]}]}]});
let drops = ctx(&items, None, &props, "").evaluate(&dirt, &mut random);
assert!(drops[0].enchantments().is_empty());
}
/// `Mth.nextInt(random, 1, max)` returns `1` without drawing when the
/// enchantment has one level (`EnchantRandomlyFunction.enchantItem`), so
/// the rolls after it must not shift. Vanilla with `LegacyRandom(11)`: a
/// Mending book, then `set_count` uniform 1..64, is 46.
#[test]
fn a_single_level_enchantment_draws_no_level() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("items.json").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let items = Items::load(&root, assets.registry, &facts).unwrap();
let props = BTreeMap::new();
let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:book","modifier":[
{"type":"minecraft:enchant_randomly","options":"minecraft:mending"},
{"type":"minecraft:set_count","count":{"type":"minecraft:uniform","min":1.0,"max":64.0}}]}]}]});
let drops = ctx(&items, None, &props, "").evaluate(&table, &mut LegacyRandom::new(11));
assert_eq!(drops.len(), 1);
assert_eq!(&*drops[0].item, "minecraft:enchanted_book");
assert_eq!(
drops[0].stored_enchantments(),
[("minecraft:mending".to_owned().into(), 1)],
"the book keeps its single-level enchantment"
);
// 46 is vanilla's count for this seed; an extra level draw gives 28.
assert_eq!(drops[0].count, 46);
}
#[test]
fn always_edible_and_consume_effects_loaded() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+5
View File
@@ -952,6 +952,11 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
Some(Tag::compound(entries))
}
BlockEntity::Jukebox(_) | BlockEntity::Lectern(_) => None,
// CampfireBlockEntity.getUpdateTag: the items alone.
BlockEntity::Campfire(c) => Some(Tag::compound([(
"Items",
crate::save::encode_items(&c.items),
)])),
_ => None,
}
}
+11
View File
@@ -823,6 +823,17 @@ fn login_and_configure(
_ => {}
}
};
// A Horizon client hears that this server runs its mods' quiet commands
// (horizon_protocol::quiet); a vanilla one is told nothing.
if brand == "horizon" {
send(
c,
cfg::Clientbound::CustomPayload(cfg::CustomPayload {
channel: horizon_protocol::quiet::CHANNEL.into(),
data: Vec::new(),
}),
)?;
}
for packet in sync.registry_packets(known_pack) {
send(c, cfg::Clientbound::RegistryData(packet))?;
}
@@ -120,6 +120,21 @@ 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) {
let body = C::Animate(play::Animate {
id: target,
action: 1,
})
.encode();
for g in self.guests.iter().filter(|g| {
self.here(g) && (g.entity_id == attacker || g.tracker.is_tracking(attacker))
}) {
g.sender.send(body.clone());
}
}
/// `ServerLevel.broadcastDamageEvent`: the clients around show the hurt
/// entity's flash and hear its hurt sound.
pub fn damage_event(
+22
View File
@@ -237,6 +237,8 @@ pub struct Guest {
pub drag: Option<(u8, Vec<usize>)>,
/// The compound this player was loaded from, for fields Horizon does not model.
pub saved: Option<horizon_world::nbt::Tag>,
/// `ServerPlayer.seenCredits`: the end poem has been shown.
pub seen_credits: bool,
/// `ServerPlayer.recipeBook`.
pub recipe_book: crate::recipe_book::Settings,
/// `PlayerAdvancements`: the criteria obtained, whether they came from
@@ -358,6 +360,7 @@ impl Guest {
next_window: 0,
drag: None,
saved: None,
seen_credits: false,
recipe_book: Default::default(),
advancements: Default::default(),
advancements_loaded: false,
@@ -601,6 +604,18 @@ fn network_stack(
horizon_protocol::item::Component::Stacks(charged),
));
}
// What a bundle holds, for its tooltip (ClientBundleTooltip).
if !d.bundle_contents.is_empty() {
let contents = d
.bundle_contents
.iter()
.filter_map(|c| network_stack(item_id, synced_id, &Some(c.clone())))
.collect();
stack.added.push((
horizon_protocol::item::component::BUNDLE_CONTENTS,
horizon_protocol::item::Component::Stacks(contents),
));
}
// The books a lectern shows and a guest reads.
if !d.writable_pages.is_empty() {
stack.added.push((
@@ -633,6 +648,13 @@ fn network_stack(
horizon_protocol::item::Component::Text(horizon_protocol::text::literal(name)),
));
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
stack.added.push((
horizon_protocol::item::component::ITEM_NAME,
horizon_protocol::item::Component::Text(name.clone()),
));
}
Some(stack)
}
Loaded 100 of 384 files, more files were not shown because too many files have changed in this diff. Show more