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 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 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 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 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 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 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
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 02f147f137 Merge remote-tracking branch 'origin/main' into perf/uniform-values-reuse 2026-10-26 15:07:14 +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 11eb74b0b6 Merge remote-tracking branch 'origin/main' into perf/skip-lookup-once 2026-10-26 15:06:45 +03: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
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
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
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
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
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 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 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
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
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
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
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 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
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
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 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 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> 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
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 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
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
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
340 changed files with 9474 additions and 17489 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(())
}
@@ -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,
@@ -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]);
}
}
}
}
}
+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());
}
}
+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
}
+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();
+114 -24
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>,
@@ -2787,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,
@@ -3338,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.
@@ -3369,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);
@@ -3477,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()?
@@ -3663,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);
@@ -3711,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);
@@ -4244,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() {
@@ -4546,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());
@@ -4754,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.
@@ -5280,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));
@@ -5550,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 => {
@@ -5593,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();
@@ -5689,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);
@@ -6332,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 {
@@ -6996,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).
@@ -7238,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");
@@ -7534,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()
@@ -8278,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();
@@ -8696,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(_)))
@@ -9813,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 {
@@ -9934,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);
}
@@ -9988,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
@@ -10000,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.
@@ -11645,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());
}
@@ -11667,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);
@@ -12283,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,
@@ -12292,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(),
@@ -12430,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>,
@@ -12450,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"),
@@ -13805,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();
@@ -13825,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,
);
}
}
}
+5 -1
View File
@@ -623,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",
@@ -643,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());
+8 -4
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();
@@ -1065,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,
+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);
+42 -7
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,25 @@ 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 {
@@ -603,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());
@@ -828,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,
});
@@ -843,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;
@@ -968,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()
@@ -1037,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);
+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;
+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];
+3
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>,
@@ -136,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(),
@@ -187,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);
+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 {
+87 -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,7 +445,7 @@ impl Game<'_> {
mappings,
cps,
font_widths: self.mods.font_widths.clone(),
players: Vec::new(),
players,
view_hit: self.mod_view_hit(),
}
}
@@ -490,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;
}
@@ -537,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 } => {
@@ -566,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);
@@ -582,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.
@@ -604,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() {
+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).
+72 -12
View File
@@ -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 } => {
@@ -1258,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);
@@ -1364,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]);
}
@@ -1380,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;
}
@@ -1426,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() {
@@ -1958,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).
@@ -2039,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) => {
@@ -2048,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)
@@ -2936,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;
@@ -2956,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) {
@@ -3062,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);
@@ -3425,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;
}
@@ -3523,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)) {
@@ -4819,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,
@@ -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, bat, 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,19 @@ 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.
@@ -116,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);
}
}
}
@@ -153,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> {
@@ -164,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 {
@@ -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
}
+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
}
+134 -22
View File
@@ -65,6 +65,7 @@ mod arena;
mod cull;
mod end_sky;
mod flat;
mod hdr;
pub mod lightning;
mod lod;
mod native_material;
@@ -504,6 +505,10 @@ pub struct Renderer {
occlusion: occlusion::Occlusion,
/// Resolution scale and FXAA (Horizon settings).
world_post: world_post::Post,
/// HDR output (Horizon setting): `Some` while the swapchain is 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.
@@ -910,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,
@@ -1043,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")?;
@@ -1071,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 {
@@ -1085,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()
@@ -1929,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,
})],
@@ -2420,6 +2440,8 @@ impl Renderer {
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(),
@@ -2489,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(())
}
@@ -2552,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) {
@@ -2563,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));
}
@@ -2895,6 +2932,63 @@ impl Renderer {
);
}
/// 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;
@@ -3230,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);
}
@@ -3242,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
@@ -4642,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,
@@ -4796,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 {
@@ -4832,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.
+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);
}
}
@@ -36,7 +36,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color;
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.
+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")))
+11
View File
@@ -188,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());
@@ -611,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))?;
}
+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)
}
+19 -2
View File
@@ -128,6 +128,11 @@ pub enum HostEvent {
line: String,
secure: bool,
},
/// A chat message a player sent (after it was broadcast), for server mods.
PlayerChat {
sender: u128,
text: String,
},
/// A server log warning (ServerGamePacketListenerImpl's LOGGER.warn).
Warn(String),
/// A server log error (LOGGER.error).
@@ -371,9 +376,21 @@ impl Hub {
id
}
pub fn stack(&mut self, s: &Option<ItemStack>) -> Option<horizon_protocol::item::Stack> {
let id = s.as_ref().map(|s| self.item_id(&s.item))?;
let item = s.as_ref()?.item.clone();
let id = self.item_id(&item);
let sync = self.sync.clone();
stack_to_network(|_| id, |registry, name| sync.id(registry, name), s)
// The stacks inside it (a bundle's contents, a crossbow's projectiles)
// have ids of their own.
let item_id = |name: &str| {
if name == &*item {
return id;
}
sync.builtin
.get("minecraft:item")
.and_then(|i| i.id(name))
.unwrap_or(0)
};
stack_to_network(item_id, |registry, name| sync.id(registry, name), s)
}
pub fn broadcast(&self, packet: &C) {
@@ -219,6 +219,10 @@ impl Hub {
};
let line = format!("<{name}> {}", message.content);
self.broadcast_chat(&message, &bound, line, events);
events.push(HostEvent::PlayerChat {
sender: uuid,
text: message.content,
});
}
/// handleChatSessionUpdate: a new profile key, validated against the
+7 -1
View File
@@ -37,6 +37,10 @@ pub struct Menu {
pub sent_data: Vec<i16>,
}
/// The y of the key a chest minecart's or chest boat's items are kept under
/// while a menu shows them.
pub const VEHICLE_KEY_Y: i32 = -100_000;
/// Where the slots of a container menu live.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Slots {
@@ -72,10 +76,12 @@ impl Slots {
Slots::Ender => at.into_iter().collect(),
_ => self.halves(),
};
// A vehicle's container is kept under a key, not a block: the game
// checks its reach to the vehicle (`isChestVehicleStillValid`).
self.valid(world)
&& blocks
.iter()
.all(|k| guest.within_block_range([k.0, k.1, k.2], 4.0))
.all(|k| k.1 == VEHICLE_KEY_Y || guest.within_block_range([k.0, k.1, k.2], 4.0))
}
/// Container slot `n`.
pub fn get(self, world: &World, guest: &Guest, mut n: usize) -> Option<ItemStack> {
@@ -85,6 +85,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
guest.seen_credits = level.seen_credits;
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
@@ -166,6 +167,7 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.seen_credits = guest.seen_credits;
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
+1
View File
@@ -50,6 +50,7 @@ pub mod cooldowns;
pub mod crafting;
pub mod crash;
pub mod creative;
pub mod cushion;
pub mod data_cmd;
pub mod datapack;
pub mod debug_chart;
+17 -1
View File
@@ -113,6 +113,14 @@ mod vertex_tests {
assert!((a - b).abs() <= 0.5 / 32768.0, "{a} {b}");
}
}
#[test]
fn emission_keeps_the_colour_and_the_free_bits() {
let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
assert_eq!(super::emitting(rgb, 0), rgb);
assert_eq!(super::emitting(rgb, 9), 0xF656_3412);
assert_eq!(super::emitting(rgb, 15), 0xF056_3412);
assert_eq!(super::emitting(rgb, 15) | super::EMISSION_BITS, rgb);
}
}
pub struct SectionMesh {
pub solid: Vec<TerrainVertex>,
@@ -566,6 +574,14 @@ impl Padded {
}
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold the light its face emits, for
/// HDR output's highlights (`gfx/hdr.rs`): 15 minus the level, so the usual 0xFF means none.
/// The byte's other four bits stay set.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// `color` marked as emitting light `level` (0 to 15).
pub fn emitting(color: u32, level: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - level.min(15)) << 24
}
const UNSET: u32 = u32::MAX;
const NO_BIOME: u16 = u16::MAX;
/// The `ColorResolver`s `ClientLevel` caches block tints for.
@@ -880,7 +896,7 @@ impl Mesher<'_> {
} else {
face.layer as u16
},
pack_rgb(rgb),
emitting(pack_rgb(rgb), state.emission.max(q.light_emission)),
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
));
}
+4 -1
View File
@@ -227,8 +227,11 @@ pub(super) fn tesselate(
} else {
0xFF_FFFF
};
// Lava emits light level 15.
let emission = if water { 0 } else { 15 };
let color = |by: f32| {
pack_rgb([16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32))
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
+13 -9
View File
@@ -102,7 +102,7 @@ impl AnimState {
pub fn end_tick(&mut self, _swinging: bool) {}
}
/// Part poses for one draw, keyed by part name.
#[derive(Clone)]
#[derive(Clone, Default)]
pub struct Poses {
map: HashMap<String, [f32; 9]>,
dummy: [f32; 9],
@@ -113,20 +113,24 @@ pub struct Poses {
}
impl Poses {
pub fn new(model: &Part) -> Self {
let mut map = HashMap::new();
let mut poses = Self::default();
poses.reset(model);
poses
}
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
map.insert(p.name.clone(), p.pose);
for c in &p.children {
walk(c, map);
}
}
walk(model, &mut map);
Self {
map,
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
}
self.map.clear();
self.dummy = [0.0; 9];
self.skipped.clear();
self.hidden.clear();
walk(model, &mut self.map);
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
match self.map.get_mut(name) {
+8 -3
View File
@@ -241,9 +241,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
} else {
a.v[SPIKES] += (1.0 - a.v[SPIKES]) * 0.06;
}
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
m.extra.counter2 += 1;
}
client_tick(m);
if in_water {
m.air = 300;
} else if m.on_ground {
@@ -332,6 +330,13 @@ pub fn elder_server_ai_step(m: &mut Mob, cx: &mut Cx) {
]);
}
}
/// Guardian.aiStep's visual attack clock, also used by server clients.
/// Does not run attack goals, movement, damage or random draws.
pub fn client_tick(m: &mut Mob) {
if m.extra.flag && m.extra.counter2 < attack_duration(m) {
m.extra.counter2 += 1;
}
}
/// The beam's clock for rendering: (attack time with the partial tick,
/// attack scale), while a target is locked.
pub fn beam(m: &Mob, partial: f32) -> Option<(f32, f32)> {
+35 -19
View File
@@ -951,21 +951,25 @@ fn draw_normals(
return;
}
let normals = normals.part(p);
for poly in part.polygons.iter().filter(|_| !poses.skips(part)) {
let normal = poly.vertex_normal(normals);
for v in poly.vertices {
let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0);
out.push(Vertex {
normal,
pos: pos.to_array(),
uv: [
uv[0] + (uv[2] - uv[0]) * v[3],
uv[1] + (uv[3] - uv[1]) * v[4],
],
layer: overlay,
color,
light,
});
// `skips` is a name lookup: ask once per part, not once per polygon. A part
// hidden this way still draws its children, so only the polygons are gated.
if !poses.skips(part) {
for poly in &part.polygons {
let normal = poly.vertex_normal(normals);
for v in poly.vertices {
let pos = m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0);
out.push(Vertex {
normal,
pos: pos.to_array(),
uv: [
uv[0] + (uv[2] - uv[0]) * v[3],
uv[1] + (uv[3] - uv[1]) * v[4],
],
layer: overlay,
color,
light,
});
}
}
}
for c in &part.children {
@@ -1094,6 +1098,9 @@ fn render_visible(
}
}
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
for layer in all_layers {
let Some(part) = m.kind.layer(&layer.model).cloned() else {
continue;
@@ -1101,8 +1108,8 @@ fn render_visible(
let Some(uv) = atlas(&layer.texture) else {
continue;
};
let mut poses = Poses::new(&part);
anim::setup(m, &s, &mut poses, &data.animations);
layer_poses.reset(&part);
anim::setup(m, &s, &mut layer_poses, &data.animations);
if matches!(m.species(), Species::Villager | Species::ZombieVillager)
&& let Some((folder, variant)) = layer
.texture
@@ -1113,7 +1120,7 @@ fn render_visible(
data.villager_hats
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.apply(folder, variant, profession, &mut poses);
.apply(folder, variant, profession, &mut layer_poses);
}
let light = if layer.full_bright {
240 | 240 << 8
@@ -1128,7 +1135,16 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw(&part, base, &poses, uv, layer.color, light, overlay, out);
draw(
&part,
base,
&layer_poses,
uv,
layer.color,
light,
overlay,
out,
);
if layer.full_bright && m.species() != Species::Warden {
for vertex in &mut out[start..] {
vertex.normal = [0; 4];
+66 -54
View File
@@ -11,9 +11,15 @@ use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex},
time::Duration,
time::{Duration, Instant},
};
const SERVER_NAME: crate::clipboard::FieldId = ("server.name", 0);
const SERVER_ADDRESS: crate::clipboard::FieldId = ("server.address", 0);
const SERVER_DIRECT: crate::clipboard::FieldId = ("server.direct", 0);
const FRIEND_CODE: crate::clipboard::FieldId = ("friend.code", 0);
const FRIEND_RELAY: crate::clipboard::FieldId = ("friend.relay", 0);
/// The result of pinging one address.
#[derive(Clone, Debug)]
pub enum Ping {
@@ -587,18 +593,21 @@ impl Screen {
}
}
/// An EditBox and its label: the label at `label_y` (one pixel in, in
/// 0xA0A0A0), the 200x20 box at `box_y`.
/// 0xA0A0A0), the 200x20 box at `box_y`. The box is the same `EditBox`
/// the world screens edit (`Caret`): cursor, selection and scrolling.
fn field(
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
label: &str,
value: &str,
id: crate::clipboard::FieldId,
max: usize,
(label_y, box_y): (f32, f32),
focused: bool,
blink: bool,
) {
let x = (batch.width.ceil() / 2.0).floor() - 100.0;
crate::widgets::target_as([x, box_y, 200.0, 20.0], crate::widgets::Kind::Field);
batch.text(
atlas,
label,
@@ -607,35 +616,25 @@ impl Screen {
rgba(160, 160, 160, 255),
true,
);
batch.fill(
caret.draw(
batch,
atlas,
x,
box_y,
200.0,
20.0,
if focused {
u32::MAX
} else {
rgba(160, 160, 160, 255)
},
);
batch.fill(atlas, x + 1.0, box_y + 1.0, 198.0, 18.0, rgba(0, 0, 0, 255));
let shown = if focused && blink {
format!("{value}_")
} else {
value.to_owned()
};
batch.text(
atlas,
&shown,
x + 4.0,
box_y + 6.0,
rgba(224, 224, 224, 255),
true,
(id, value, max),
[x, box_y, 200.0, 20.0],
focused,
focused_at,
);
}
pub fn draw(&self, atlas: &HudAtlas, batch: &mut HudBatch, blink: bool, cursor: (f32, f32)) {
pub fn draw(
&self,
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
cursor: (f32, f32),
) {
let (w, h) = (batch.width, batch.height);
let max = self.max_length();
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let centered = |batch: &mut HudBatch, text: &str, y: f32, color: u32| {
let tw = HudBatch::text_width(atlas, text);
@@ -888,20 +887,26 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterName", "Server Name"),
name,
SERVER_NAME,
max,
(53.0, 66.0),
*focus == 0,
blink,
);
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
address,
SERVER_ADDRESS,
max,
(94.0, 106.0),
*focus == 1,
blink,
);
}
Self::Direct { address } => {
@@ -916,11 +921,14 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get_or("manageServer.enterIp", "Server Address"),
address,
SERVER_DIRECT,
max,
(100.0, 116.0),
true,
blink,
);
}
Self::ConfirmDelete { name, lines, .. } => {
@@ -954,20 +962,26 @@ impl Screen {
Self::field(
atlas,
batch,
caret,
focused_at,
"Invite Code",
code,
FRIEND_CODE,
max,
(53.0, 64.0),
*focus == 0,
blink,
);
Self::field(
atlas,
batch,
caret,
focused_at,
"Relay (optional)",
relay,
FRIEND_RELAY,
max,
(94.0, 105.0),
*focus == 1,
blink,
);
}
Self::Disconnected { reason, retry } => {
@@ -1261,6 +1275,19 @@ impl Screen {
scroll.release();
}
}
/// The focused box, in GUI pixels: name at 66, address at 106
/// (`ManageServerScreen`), direct address at 116, friend fields at 64 and 105.
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
let y = match self {
Self::Edit { focus: 0, .. } => 66.0,
Self::Edit { .. } => 106.0,
Self::Direct { .. } => 116.0,
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { .. } => 105.0,
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
}
fn focused_field(&mut self) -> Option<&mut String> {
match self {
Self::Edit {
@@ -1297,11 +1324,11 @@ impl Screen {
/// The focused text field, its id and maximum length (copy and paste).
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
let id = match self {
Self::Edit { focus: 0, .. } => ("server.name", 0),
Self::Edit { .. } => ("server.address", 0),
Self::Direct { .. } => ("server.direct", 0),
Self::Friend { focus: 0, .. } => ("friend.code", 0),
Self::Friend { .. } => ("friend.relay", 0),
Self::Edit { focus: 0, .. } => SERVER_NAME,
Self::Edit { .. } => SERVER_ADDRESS,
Self::Direct { .. } => SERVER_DIRECT,
Self::Friend { focus: 0, .. } => FRIEND_CODE,
Self::Friend { .. } => FRIEND_RELAY,
_ => return None,
};
let max = self.max_length();
@@ -1315,21 +1342,6 @@ impl Screen {
128
}
}
pub fn type_text(&mut self, text: &str) {
let max = self.max_length();
if let Some(field) = self.focused_field() {
for c in text.chars().filter(|c| !c.is_control()) {
if field.chars().count() < max {
field.push(c);
}
}
}
}
pub fn backspace(&mut self) {
if let Some(field) = self.focused_field() {
field.pop();
}
}
pub fn tab(&mut self) {
if let Self::Edit { focus, .. } | Self::Friend { focus, .. } = self {
*focus = (*focus + 1) % 2;
+224 -29
View File
@@ -526,6 +526,12 @@ pub fn encode_block_entity(
}
}
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
// CampfireBlockEntity.saveAdditional.
BlockEntity::Campfire(c) => {
entries.push(("Items", encode_items(&c.items)));
entries.push(("CookingTimes", Tag::IntArray(c.progress.to_vec())));
entries.push(("CookingTotalTimes", Tag::IntArray(c.total.to_vec())));
}
BlockEntity::Crafter(c) => {
entries.push(("Items", encode_items(&c.items)));
entries.push((
@@ -676,8 +682,10 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
Tag::Int(n) => *n,
_ => continue,
};
f.recipes_used
.insert(id.to_string_lossy(), count.max(0) as u32);
// Saves from before the key was namespaced hold both forms of an id.
*f.recipes_used
.entry(crate::containers::recipe_used_key(&id.to_string_lossy()))
.or_insert(0) += count.max(0) as u32;
}
}
}
@@ -704,6 +712,18 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
}
BlockEntity::Sign(sign) => sign.decode(tag),
BlockEntity::Sculk(s) => s.load(tag),
// CampfireBlockEntity.loadAdditional: missing time arrays stay zeros.
BlockEntity::Campfire(c) => {
for (slot, item) in decode_items(tag.get("Items"), 4).into_iter().enumerate() {
c.items[slot] = item;
}
let ints = |key: &str| match tag.get(key) {
Some(Tag::IntArray(v)) => std::array::from_fn(|i| v.get(i).copied().unwrap_or(0)),
_ => [0; 4],
};
c.progress = ints("CookingTimes");
c.total = ints("CookingTotalTimes");
}
BlockEntity::Crafter(c) => {
for (slot, item) in decode_items(tag.get("Items"), 9).into_iter().enumerate() {
c.items[slot] = item;
@@ -734,18 +754,67 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
Some((pos, entity))
}
/// What a dimension's saved chunks depend on: skylight and the build height
/// (`DimensionType.minY` and `height`). `Chunk` always holds the Overworld's
/// sections; a level saves only the ones inside its own height.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct LevelShape {
pub sky_light: bool,
pub min_y: i32,
pub height: i32,
}
impl LevelShape {
pub const OVERWORLD: Self = Self {
sky_light: true,
min_y: MIN_Y,
height: SECTIONS as i32 * 16,
};
pub const NETHER: Self = Self {
sky_light: false,
min_y: 0,
height: 256,
};
pub const END: Self = Self {
sky_light: true,
min_y: 0,
height: 256,
};
/// The shape of a dimension folder (`dimensions/minecraft/<name>`, or the
/// old `DIM-1` and `DIM1`).
pub fn for_dir(root: &Path) -> Self {
if root.ends_with("the_nether") || root.ends_with("DIM-1") {
Self::NETHER
} else if root.ends_with("the_end") || root.ends_with("DIM1") {
Self::END
} else {
Self::OVERWORLD
}
}
fn min_section(self) -> i32 {
self.min_y >> 4
}
fn section_count(self) -> usize {
(self.height >> 4) as usize
}
fn has_section(self, y: i32) -> bool {
(self.min_section()..self.min_section() + self.section_count() as i32).contains(&y)
}
}
/// A column's sections as `Heightmap.primeHeightmaps` reads them.
struct HeightmapView<'a> {
sections: &'a [Arc<Section>],
tables: &'a SaveTables,
min_y: i32,
height: i32,
}
impl HeightmapChunk for HeightmapView<'_> {
type State = crate::assets::StateFacts;
fn min_y(&self) -> i32 {
MIN_Y
self.min_y
}
fn height(&self) -> i32 {
SECTIONS as i32 * 16
self.height
}
fn highest_section_y(&self) -> i32 {
let top = self
@@ -765,7 +834,7 @@ impl HeightmapChunk for HeightmapView<'_> {
}
}
/// Heightmaps kept after world generation (`ChunkStatus.FULL.heightmapsAfter`).
fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
fn heightmaps(chunk: &Chunk, tables: &SaveTables, shape: LevelShape) -> Tag {
let kinds = [
HeightmapType::WorldSurface,
HeightmapType::OceanFloor,
@@ -774,11 +843,13 @@ fn heightmaps(chunk: &Chunk, tables: &SaveTables) -> Tag {
];
let mut maps: Vec<Heightmap> = kinds
.iter()
.filter_map(|k| Heightmap::new(*k, MIN_Y, SECTIONS as i32 * 16).ok())
.filter_map(|k| Heightmap::new(*k, shape.min_y, shape.height).ok())
.collect();
let view = HeightmapView {
sections: &chunk.sections,
tables,
min_y: shape.min_y,
height: shape.height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().collect();
if Heightmap::prime(&view, &mut refs).is_err() {
@@ -808,7 +879,12 @@ pub fn client_heightmaps_in(
.iter()
.filter_map(|(id, k)| Heightmap::new(*k, min_y, height).ok().map(|m| (*id, m)))
.collect();
let view = HeightmapView { sections, tables };
let view = HeightmapView {
sections,
tables,
min_y,
height,
};
let mut refs: Vec<&mut Heightmap> = maps.iter_mut().map(|(_, m)| m).collect();
if Heightmap::prime(&view, &mut refs).is_err() {
return Vec::new();
@@ -834,17 +910,18 @@ fn nibbles(light: &Light, sky: bool) -> Vec<u8> {
}
pub fn encode(chunk: &Chunk, registry: &BlockRegistry, biomes: &Biomes) -> NamedTag {
encode_with(chunk, registry, biomes, None, true)
encode_with(chunk, registry, biomes, None, LevelShape::OVERWORLD)
}
/// The chunk NBT vanilla's `SerializableChunkData.write` produces for a full
/// chunk. `sky_light` is false in dimensions without skylight.
/// chunk in a level of `shape`.
pub fn encode_with(
chunk: &Chunk,
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let sky_light = shape.sky_light;
let mut sections = Vec::new();
let air = tables.map_or(0, |t| t.air);
// Sky layers below the highest section with light other than 15 are
@@ -856,6 +933,9 @@ pub fn encode_with(
});
for (si, section) in chunk.sections.iter().enumerate() {
let y = (MIN_Y >> 4) + si as i32;
if !shape.has_section(y) {
continue;
}
// PalettedContainer.pack: distinct states in index order, indices at
// the saved width.
let packed = section.blocks.pack();
@@ -985,7 +1065,7 @@ pub fn encode_with(
("DataVersion", Tag::Int(DATA_VERSION)),
("xPos", Tag::Int(chunk.x)),
("zPos", Tag::Int(chunk.z)),
("yPos", Tag::Int(MIN_Y >> 4)),
("yPos", Tag::Int(shape.min_section())),
("Status", string("minecraft:full")),
("LastUpdate", Tag::Long(extras.map_or(0, |e| e.last_update))),
("isLightOn", Tag::Byte(i8::from(chunk.lit))),
@@ -1018,10 +1098,10 @@ pub fn encode_with(
s.insert("References".into(), Tag::Compound(refs));
}
map.entry("PostProcessing".into())
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); SECTIONS]));
.or_insert_with(|| Tag::List(vec![Tag::List(Vec::new()); shape.section_count()]));
}
if let Some(t) = tables {
put(&mut root, "Heightmaps", heightmaps(chunk, t));
put(&mut root, "Heightmaps", heightmaps(chunk, t, shape));
let names = |k: u32| t.block_names.get(k as usize).cloned();
let (blocks, fluids) = extras.map_or((&[][..], &[][..]), |e| {
(&e.block_ticks[..], &e.fluid_ticks[..])
@@ -1237,18 +1317,19 @@ pub fn encode_proto(
registry: &BlockRegistry,
biomes: &Biomes,
tables: Option<&SaveTables>,
sky_light: bool,
shape: LevelShape,
) -> NamedTag {
let mut root = encode_with(&proto.column, registry, biomes, tables, sky_light);
let mut root = encode_with(&proto.column, registry, biomes, tables, shape);
let status = if proto.decorated {
ChunkStatus::Features
} else {
ChunkStatus::Terrain
};
// ProtoChunk.postProcessing: one list per section, packed section offsets.
let mut marks = vec![Vec::new(); SECTIONS];
let mut marks = vec![Vec::new(); shape.section_count()];
for &[x, y, z] in &proto.column.postprocess {
if let Some(list) = marks.get_mut(((y - MIN_Y) >> 4) as usize) {
let section = usize::try_from((y - shape.min_y) >> 4).ok();
if let Some(list) = section.and_then(|s| marks.get_mut(s)) {
list.push(Tag::Short(
((x & 15) | (y & 15) << 4 | (z & 15) << 8) as i16,
));
@@ -1562,7 +1643,7 @@ struct Encoding {
registry: &'static BlockRegistry,
biomes: Arc<Biomes>,
tables: Option<Arc<SaveTables>>,
sky_light: bool,
shape: LevelShape,
}
impl Record {
fn tag(&self) -> Option<Arc<NamedTag>> {
@@ -1573,7 +1654,7 @@ impl Record {
e.registry,
&e.biomes,
e.tables.as_deref(),
e.sky_light,
e.shape,
))),
Record::Delete => None,
}
@@ -1610,12 +1691,12 @@ pub struct SaveDir {
outstanding: Arc<Outstanding>,
/// Set by `Persistent` before the first load.
pub tables: OnceLock<Arc<SaveTables>>,
/// False in dimensions without skylight (no SkyLight layers are written).
pub sky_light: bool,
/// The dimension's skylight and height, from its folder name.
pub shape: LevelShape,
}
impl SaveDir {
pub fn new(root: PathBuf) -> Self {
let sky_light = !root.ends_with("the_nether") && !root.ends_with("DIM-1");
let shape = LevelShape::for_dir(&root);
Self {
root,
regions: Default::default(),
@@ -1625,7 +1706,7 @@ impl SaveDir {
writer: Mutex::new(None),
outstanding: Default::default(),
tables: OnceLock::new(),
sky_light,
shape,
}
}
fn region_path(&self, folder: &str, rx: i32, rz: i32) -> PathBuf {
@@ -1656,7 +1737,7 @@ impl SaveDir {
let root = self.read_record("region", x, z)?;
let tables = self.tables.get().map(|t| &**t);
let mut chunk = decode_with(&root, registry, biomes, tables)?;
load_light(&root.tag, &mut chunk, self.sky_light);
load_light(&root.tag, &mut chunk, self.shape.sky_light);
Some(chunk)
}
/// A proto chunk saved before it became final (`decode_proto`).
@@ -1681,7 +1762,7 @@ impl SaveDir {
let records = protos
.iter()
.map(|p| {
let tag = encode_proto(p, registry, biomes, tables, self.sky_light);
let tag = encode_proto(p, registry, biomes, tables, self.shape);
((p.column.x, p.column.z), Some(tag))
})
.collect();
@@ -1856,7 +1937,7 @@ impl SaveDir {
.map(|c| {
(
(c.x, c.z),
Some(encode_with(c, registry, biomes, tables, self.sky_light)),
Some(encode_with(c, registry, biomes, tables, self.shape)),
)
})
.collect();
@@ -1875,7 +1956,7 @@ impl SaveDir {
registry,
biomes: biomes.clone(),
tables: self.tables.get().cloned(),
sky_light: self.sky_light,
shape: self.shape,
});
let records = chunks
.iter()
@@ -3345,7 +3426,7 @@ impl SaveDir {
{
return None;
}
decode_lod_surfaces(&root, assets, biomes, self.sky_light)
decode_lod_surfaces(&root, assets, biomes, self.shape.sky_light)
}
}
@@ -3436,6 +3517,71 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
#[cfg(test)]
mod tests {
#[test]
fn nether_and_end_chunks_save_with_their_own_height() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Biomes::load(&root).expect("biomes");
let tables = SaveTables::new(&assets);
let rack = assets
.registry
.default_state("minecraft:netherrack")
.unwrap();
let mut chunk = Chunk::empty(0, 0);
for y in 0..128 {
chunk.set(0, y, 0, rack.0);
}
let saved = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::NETHER,
);
assert_eq!(number(saved.tag.get("yPos")), Some(0));
let Some(Tag::List(sections)) = saved.tag.get("sections") else {
panic!("no sections");
};
let ys: Vec<i32> = sections
.iter()
.filter_map(|s| number(s.get("Y")).map(|y| y as i32))
.collect();
assert_eq!(ys, (0..16).collect::<Vec<_>>());
let Some(Tag::List(post)) = saved.tag.get("PostProcessing") else {
panic!("no PostProcessing");
};
assert_eq!(post.len(), 16);
// Heightmap values count from the level's minimum: the column's top
// block at y=127 reads 128 (vanilla), not 192 (from the Overworld's -64).
let Some(Tag::LongArray(surface)) = saved
.tag
.get("Heightmaps")
.and_then(|h| h.get("WORLD_SURFACE"))
else {
panic!("no WORLD_SURFACE");
};
assert_eq!(surface[0] & 0x1ff, 128);
let overworld = encode_with(
&chunk,
assets.registry,
&biomes,
Some(&tables),
LevelShape::OVERWORLD,
);
assert_eq!(number(overworld.tag.get("yPos")), Some(-4));
assert_eq!(
LevelShape::for_dir(Path::new("w/dimensions/minecraft/the_end")),
LevelShape::END
);
assert_eq!(
LevelShape::for_dir(Path::new("w/DIM-1")),
LevelShape::NETHER
);
}
#[test]
fn saved_far_surface_reads_builds_and_water_without_gameplay_chunks() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
@@ -3554,7 +3700,7 @@ mod tests {
"unfinished proto saves must not suppress complete decoration"
);
let mut nether = SaveDir::new(dir.clone());
nether.sky_light = false;
nether.shape = LevelShape::NETHER;
let dark = nether.lod_surfaces(-2, 3, &assets, &biomes).unwrap();
assert!(
dark.iter()
@@ -3873,6 +4019,31 @@ mod tests {
assert_eq!(back.redstone_signal(), 2);
}
#[test]
fn campfire_block_entity_round_trips() {
let mut c = crate::containers::Campfire::default();
c.items[1] = Some(crate::items::ItemStack::new("minecraft:beef", 1));
c.progress[1] = 120;
c.total[1] = 600;
let tag = encode_block_entity(
(3, 64, -2),
&crate::containers::BlockEntity::Campfire(Box::new(c.clone())),
"minecraft:soul_campfire",
);
// One block entity type for both campfires.
assert_eq!(text(tag.get("id")).as_deref(), Some("minecraft:campfire"));
let (pos, back) = decode_block_entity(&tag).expect("campfire");
assert_eq!(pos, (3, 64, -2));
let crate::containers::BlockEntity::Campfire(back) = back else {
panic!("not a campfire");
};
assert_eq!(*back, c);
// The update tag a server's clients get: the items alone.
let update =
crate::lan::chunks::entity_tag(&crate::containers::BlockEntity::Campfire(Box::new(c)))
.expect("update tag");
assert!(update.get("Items").is_some() && update.get("CookingTimes").is_none());
}
#[test]
fn spawner_block_entity_round_trips() {
// A dungeon spawner as vanilla saves it (`id` is mob_spawner, not the block).
let saved = horizon_world::nbt::parse_snbt(
@@ -4382,4 +4553,28 @@ mod tests {
};
assert_eq!(back.recipes_used.get("minecraft:cooked_porkchop"), Some(&3));
}
/// A save written before the key was namespaced holds an id twice, once bare: vanilla
/// refuses such a furnace, so a load merges them under the full id.
#[test]
fn a_furnaces_recipes_used_merges_bare_and_full_ids() {
use crate::containers::BlockEntity;
let Some(BlockEntity::Furnace(mut f)) = BlockEntity::for_block("minecraft:furnace") else {
panic!("a furnace");
};
f.recipes_used
.insert("iron_ingot_from_smelting_iron_ore".to_owned(), 2);
f.recipes_used
.insert("minecraft:iron_ingot_from_smelting_iron_ore".to_owned(), 3);
let tag = encode_block_entity((1, 64, 2), &BlockEntity::Furnace(f), "minecraft:furnace");
let Some((_, BlockEntity::Furnace(back))) = decode_block_entity(&tag) else {
panic!("a furnace back");
};
assert_eq!(back.recipes_used.len(), 1);
assert_eq!(
back.recipes_used
.get("minecraft:iron_ingot_from_smelting_iron_ore"),
Some(&5)
);
}
}
+73
View File
@@ -134,3 +134,76 @@ impl SprintState {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn standing() -> Conditions {
Conditions {
enough_food: true,
on_ground: true,
..Conditions::default()
}
}
fn step(state: &mut SprintState, keys: Keys, sprinting: &mut bool) {
let current = *sprinting;
let mut next = current;
state
.tick(
keys,
Conditions {
sprinting: current,
..standing()
},
7,
|value| {
next = value;
Ok::<(), ()>(())
},
)
.unwrap();
*sprinting = next;
}
/// Vanilla sprint is a state. Releasing the sprint key, including on the
/// jump tick, keeps it while forward is held; releasing forward stops it.
#[test]
fn sprint_survives_releasing_the_key_through_a_jump() {
let mut state = SprintState::default();
let mut sprinting = false;
step(
&mut state,
Keys {
forward: true,
sprint: true,
..Keys::default()
},
&mut sprinting,
);
assert!(sprinting);
step(
&mut state,
Keys {
forward: true,
jump: true,
..Keys::default()
},
&mut sprinting,
);
assert!(
sprinting,
"a jump after the sprint key comes up must stay a sprint"
);
step(
&mut state,
Keys {
jump: true,
..Keys::default()
},
&mut sprinting,
);
assert!(!sprinting);
}
}
+6 -3
View File
@@ -14,7 +14,8 @@ use std::{fs, path::Path, sync::Arc};
/// Everything the workstation menus read, loaded once with the resources.
pub struct StationData {
pub enchantments: Enchantments,
/// The same registry `Items` carries (`Items::enchantments`).
pub enchantments: std::sync::Arc<Enchantments>,
pub recipes: StationRecipes,
pub brewing: crate::brewing::Brewing,
pub trades: crate::merchant::Trades,
@@ -25,7 +26,7 @@ pub struct StationData {
impl StationData {
pub fn load(root: &Path, items: &Items) -> Self {
Self {
enchantments: Enchantments::load(root),
enchantments: std::sync::Arc::clone(&items.enchantments),
recipes: StationRecipes::load(root, items),
brewing: crate::brewing::Brewing::load(root, items),
trades: crate::merchant::Trades::load(root),
@@ -174,7 +175,9 @@ pub fn increased_repair_cost(cost: u32) -> u32 {
pub fn hover_name(names: &dyn Fn(&str) -> String, stack: &ItemStack) -> String {
stack
.custom_name()
.map_or_else(|| names(&stack.item), str::to_owned)
.map(str::to_owned)
.or_else(|| stack.item_name())
.unwrap_or_else(|| names(&stack.item))
}
/// `AnvilMenu.createResult`. `name` is the rename box text (None = untouched).
pub fn anvil_result(
+8 -2
View File
@@ -69,6 +69,8 @@ struct TerrainOut {
@location(2) color: vec4<f32>,
@location(3) spherical: f32,
@location(4) cylindrical: f32,
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
@location(5) @interpolate(flat) emission: u32,
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
@@ -121,7 +123,8 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.uv = uv;
out.layer = layer;
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = color * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
return out;
@@ -140,7 +143,10 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
// Alpha 0 tells HDR output (hdr.wgsl) a light-emitting block's bright pixels
// may shine. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.0, in.emission > 0u);
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
Loaded 100 of 340 files, more files were not shown because too many files have changed in this diff. Show more