Compare commits

...
Author SHA1 Message Date
Ludovic Marie baa379d717 Merge pull request #3578 from d3grando/fix-3432
🤖 Agent: proven on union/pt1-w3 by pr-triage-1: every vanilla harness this branch reaches passes (mob_ai and the equine ones included), clippy -D warnings and the horizon-client tests pass (the one failing test is main's own, fixed by #3851), and the last commit only reorders an import for rustfmt. Thank you!
2026-10-29 20:31:31 +01:00
Ludovic Marie 28a65af5fc Merge pull request #3848 from horizon-rust/perf/block-light-check-node
🤖 Agent: merging. Block light follows vanilla BlockLightEngine.checkNode (an emitter in its own light only brightens): in-frame relight during the RD 32 ocean flight 17–30 ms per run down to 7–8 ms; light harness and the light tests pass; CI verify green.
2026-10-29 20:26:44 +01:00
Ludovic Marie a6751f2aa9 Merge pull request #2960 from kacpherek/fix/shelf-interaction
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:20 +01:00
Ludovic Marie ea60e1e062 Merge pull request #3327 from damnurmum/fix/inline-object-text
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:16 +01:00
Ludovic Marie 047815c5d7 Merge pull request #3303 from doodlyyousuf/fix/container-use-swing
🤖 Agent: proven by pr-triage-3 (Ludovic's AI agent) on union/pt3-w1 7e05a39a9 (main 62a23ba02 + 15 PRs): pr_ready green (fmt, clippy -D warnings, 1810 tests, doc, content root), parity --changed 192/192, lan_e2e 13 checks on both paths. Mojang rules, supply chain and per-frame cost read.
2026-10-29 20:24:11 +01:00
Ludovic MarieandClaude Opus 5.5 d22aecd526 Format the equine tests' imports
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:20:19 +01:00
Ludovic MarieandClaude Opus 5.5 01b4e5cbc4 Light: an emitter placed in its own light only brightens
Vanilla's BlockLightEngine.checkNode clears a changed block's light and
decreases from it only when the new emission is below the stored level;
otherwise the level stays, the neighbours' light is pulled in and the
emission increases from there. Horizon's relight cleared every changed
block, so each flowing lava block placed next to lava (fluids settling
in fresh chunks while flying over the ocean) darkened and relit up to
14 blocks around it on the render thread, and marked every section
there for remeshing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:13:11 +01:00
Ludovic Marie 7ebf7d26cb Merge pull request #3350 from horizon-rust/draft/remove-wasm-mods-game
🤖 Agent: WebAssembly removal 1/4 (game), merged up to date with main c7065027b. The official mods are native in the registry (Zoom, FPS Counter, Keystrokes, Minimap, Recipes, Coordinates HUD 2.0.0, verified in the published index), and the authors of the WebAssembly mods with no native version were told (list in the body). Each layer compiles; pr_ready --quick on the tip: only main's own macOS clippy (log_display) and axolotl test fail. Parity: 340/340 locally on the previous heads; CI parity on these. Merge before #3351, #3353, #3371.
2026-10-29 19:48:54 +01:00
Ludovic Marie cbea22fbb6 Merge pull request #3831 from horizon-rust/perf/streaming-results-no-wait
🤖 Agent: merging. The frame no longer waits on a descheduled worker's mpsc send: 8–24 ms stalls in Streaming::update gone in the RD 32 ocean flight (0–1 per run vs 5–14), 109/109 affected harnesses pass, CI verify green.
2026-10-29 19:43:45 +01:00
Ludovic Marie 319876a186 Merge pull request #2795 from s1f102500012/launcher-simplified-chinese
🤖 Agent: correction from pr-triage-1: my first push of the main merge missed one line (`Language::ALL` still said six), so it didn't build; 46ac49ad0 adds it and merges main once more. On this exact head the launcher's fmt, clippy -D warnings and all its tests pass, including the check that every language has every string. Sorry for the noise.
2026-10-29 19:38:07 +01:00
levzzz 9093960719 Merge pull request #3355 from Sticky-Bug/feat/audio-output-device
options: choose the audio output device in Music & Sounds (#1035)
2026-10-29 23:37:50 +05:00
Ludovic MarieandClaude Opus 5.5 68e57e86c5 Streaming: the frame takes finished jobs without waiting for a worker
The game thread drained the workers' results with std's mpsc try_recv,
which spins and then yields until a sender it finds half way through a
send has written its message (std's mpmc list: start_recv, wait_write).
A worker descheduled at that point on a busy machine held the frame for
a scheduler quantum: 8 to 24 ms waits with no CPU time in
Streaming::update, measured with HORIZON_SPIKES during the RD 32 ocean
flight. The results now go through a mutex-guarded queue the frame
takes with try_lock: while a worker holds it, the frame leaves the
results for the next one. The blocking waits (save, wait, drop) keep
their timeouts on a condvar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:28:52 +01:00
Ludovic MarieandClaude Opus 5.5 ab0309581c Merge main (c7065027b) into the WebAssembly removal (game)
Conflict: the help text; main's --in-frame deprecation kept, --dev-mod still
takes content mods only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:21:29 +01:00
Ludovic MarieandClaude Opus 5.5 46ac49ad05 Launcher: Language::ALL holds seven languages, formatted
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:02:12 +01:00
Ludovic Marie d23ab5ae50 Merge remote-tracking branch 'origin/main' into pt1-2795 2026-10-29 19:02:03 +01:00
Ludovic MarieandClaude Opus 5.5 9ad4c9fb8b Merge main into launcher-simplified-chinese: keep Chinese beside main's Ukrainian
Language::ALL holds seven, and every list of codes names zh, es and uk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:01:03 +01:00
levzzz c7065027b1 Merge pull request #3461 from senjinthedragon/hdr-wayland
HDR: Auto Paper White and Peak Brightness, from what the compositor reports
2026-10-29 22:52:21 +05:00
rdbt b97e39e074 Merge pull request #3800 from horizon-rust/chore/deprecate-in-frame
Deprecate --in-frame (unsupported, will be removed soon)
2026-10-29 17:49:28 +00:00
levzzz 1deaac7263 Merge pull request #3820 from horizon-rust/fix/prepare-output-version-2
horizon-prepare: OUTPUT_VERSION 2 (the command registries now export selected_entities)
2026-10-29 22:48:11 +05:00
Ludovic MarieandClaude Opus 5.5 26f099d168 Merge main (d009ebd) into the WebAssembly removal (game)
Conflict: main made chat_tree callable from command blocks, with the
WebAssembly mods for its argument; it stays callable, without them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:47:49 +01:00
rdbt ef66d5b892 Merge branch 'main' into chore/deprecate-in-frame 2026-10-29 17:41:01 +00:00
Ludovic MarieandClaude Opus 5.5 b732e582b9 Merge main into feat/audio-output-device: keep soundDevice beside main's new option keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:38:36 +01:00
levzzzandClaude Opus 5.5 f145c903b6 Merge main into hdr-wayland (with #3176 and #3203 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:38:32 +05:00
levzzz d009ebd140 Merge pull request #3203 from senjinthedragon/hdr-screen
HDR: its settings get a screen of their own
2026-10-29 22:38:18 +05:00
Ludovic Marie 0c49146fc8 Merge remote-tracking branch 'origin/main' into fix/prepare-output-version-2 2026-10-29 18:37:49 +01:00
Ludovic MarieandClaude Opus 5.5 1fb8e84561 Merge main into the WebAssembly removal (game)
Conflicts: main's changes to mod_render.rs, server_mods.rs and the runtime
part of mods.rs sit in WebAssembly code this branch deletes. Kept main's
Event::NativeSettings (a native mod's page asks for its labels again) and
the bundle's wheel; dropped the WebAssembly ModSettings event, the mod
screen's wheel and its title-screen settings page (9c6325c1f).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:36:42 +01:00
Ludovic Marie cbbdf773e2 Merge pull request #3817 from GIGABAIT93/fix/launcher-language-count
Launcher: Language::ALL holds six languages (main doesn't build)
2026-10-29 18:34:25 +01:00
Ludovic Marie e1207bb017 Merge pull request #3453 from d3grando/fix-3357
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:17 +01:00
Ludovic Marie 94a737918a Merge pull request #3443 from d3grando/fix-shulker-box-menu
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:34:10 +01:00
Ludovic MarieandClaude Opus 5.5 7ed5855e4a horizon-prepare: OUTPUT_VERSION 2 (command registries export selected_entities)
#3410 made the command parse oracle, which also exports
command-registries.json during preparation, write "selected_entities". The
prepared output changed (digest 55f3f289…) but OUTPUT_VERSION stayed 1, so
every parity run since fails "the prepared output changed", and players
would keep resources prepared before it. #3410 was proven in a local union,
which skipped CI parity and with it this check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:32:53 +01:00
levzzz e3cbe2688f Merge pull request #2911 from masa-masa-0314/fix/world-select-icons
Select World: show each world's preview icon
2026-10-29 22:30:37 +05:00
levzzz 7dc72d6c33 Merge pull request #3582 from Username59138/fix/command-block-screen
Important fix: command blocks keep their command, suggest commands and dim the screen as vanilla
2026-10-29 22:30:23 +05:00
Ludovic Marie f4e1a98127 Merge pull request #3446 from d3grando/fix-3407
🤖 Agent: proven on the integration branch union/pt1-w2 (23 PRs on main 89f949155) by pr-triage-1: tools/pr_ready.py green (clippy -D warnings, the touched crates' tests, launcher checks), the 126 affected vanilla harnesses pass, and lan_e2e passes 13 checks on both single-player paths. Thank you!
2026-10-29 18:27:36 +01:00
levzzzandClaude Opus 5.5 e5087bdb5a Merge main into hdr-screen (with #3176 merged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:27:34 +05:00
levzzz 0e7918fb88 Merge pull request #3176 from senjinthedragon/hdr-ui
HDR: UI Brightness, and the HUD and menus never lift
2026-10-29 22:27:20 +05:00
levzzz e5076cd6ab Merge pull request #3230 from Mik00000/fix/spawner-mob-display
Display mob inside spawners and trial spawners (fixes #3107)
2026-10-29 22:23:08 +05:00
levzzz 3d4cade73b Merge pull request #3544 from degepie/fix/issue-2841-frog-jump
Frogs long jump: FrogAi's long jump activity (fixes #2841)
2026-10-29 22:22:45 +05:00
levzzzandClaude Opus 5.5 1332c90868 Merge main into the branch; the command box types AltGr characters as main's other text boxes do
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:22:27 +05:00
levzzz 1b38710e92 Merge pull request #2970 from kacpherek/fix/multiplayer-safety-notice
Show the multiplayer safety notice on the first Multiplayer click
2026-10-29 22:20:02 +05:00
levzzz aaa301b2e5 Merge pull request #2924 from elythepreciouscat/feat/vanilla-controls-26-3
Options: add Java 26.3 controls
2026-10-29 22:19:46 +05:00
levzzzandClaude Opus 5.5 3052227936 HDR Auto: watch the display only while HDR output is on
The Wayland watch was made whenever the surface offered HDR, so with
HDR off (the default) the renderer still did a blocking dispatch on
winit's connection at startup. It's now made when set_hdr turns HDR
output on and dropped when it turns off; the next display_changed
applies what it first reported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:19:42 +05:00
levzzz 95a366fe89 Merge pull request #3049 from kacpherek/feat/render-distance-128
Raise the render distance cap from 64 to 128 chunks
2026-10-29 22:19:30 +05:00
levzzz fc0e275414 Merge pull request #3229 from Mik00000/fix/bow-arrow-types
Support firing spectral and tipped arrows from bows and crossbows
2026-10-29 22:19:13 +05:00
levzzz 5dc0515248 Merge pull request #3148 from echo-ilovech3ss/fix/middle-click-pick-block-selection
Select the picked hotbar slot in singleplayer and LAN
2026-10-29 22:18:57 +05:00
levzzzandClaude Opus 5.5 20a3c3e399 Merge main, #3176 and #3203 into hdr-wayland: Auto beside Black Level and UI Brightness
Paper White and Peak keep their Auto step (0) on Graphics: HDR, where
#3203 moved them; Black Level and UI Brightness are saved, parsed and
passed to set_hdr beside the Auto nits. The docs paragraph keeps the
HDR screen, Auto, Black Level and UI Brightness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:18:50 +05:00
levzzz 0a6020695b Merge pull request #3131 from Mik00000/fix/invisible-frames-custom-heads
Fix invisible item frames (#2925)
2026-10-29 22:18:42 +05:00
levzzzandClaude Opus 5.5 dfde0ac091 Merge main and #3176 into hdr-screen: all five HDR settings on HDR's screen
Main gained Black Level (#3145) and #3176 adds UI Brightness; both
move from Display to Graphics: HDR with the rest, in main's order
(Black Level, Peak, Paper White, UI Brightness), and wait for the HDR
switch like the other sliders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:16:24 +05:00
GIGABAIT93andClaude Opus 5.5 dc274d681c Launcher: Language::ALL holds six languages
Merging main into #3625 put Spanish and Ukrainian both in Language::ALL
but left its length at 5, so the launcher no longer builds on main
(E0308: expected an array with a size of 5, found one with a size of 6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:14:26 +02:00
levzzzandClaude Opus 5.5 246a071dbc Merge main into hdr-ui: UI Brightness beside main's Black Level
Main gained HDR Black Level (#3145). Both settings are kept: the
params slot after `pq` is the black level and the next one is the UI
brightness; set_hdr takes both; options save hdrBlackLevel and
hdrUiBrightness; the UI Brightness row follows Paper White.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:14:22 +05:00
levzzz 1e965ec5a8 Merge pull request #3749 from reservedbytes/fix/jmath-atan2
fix(core): atan2 matches StrictMath
2026-10-29 22:12:08 +05:00
levzzzandClaude Opus 5.5 a7ff35cf7b Merge horizon-rust/main into fix/world-select-icons
Resolves two conflicts:
- gfx.rs: main made padded_row/copy_frame free functions (copy_frame takes
  the device and size) and dropped write_screenshot's COPY_SRC check for
  the OpenGL readback target; take_readback now calls the free copy_frame
  and write_screenshot keeps the ScreenshotRequest signature.
- remote.rs leave_server: keeps main's hud_disconnected (which resets the
  boss overlay), overlay_time and item_activation reset, plus this branch's
  world_icon = None.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:10:21 +05:00
levzzz 3e31a55950 Merge pull request #3644 from iLostName/invisible-player-third-person
invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
2026-10-29 22:10:15 +05:00
levzzz 4e910746ce Merge pull request #3625 from GIGABAIT93/feat/ukrainian
Launcher: Ukrainian
2026-10-29 22:10:04 +05:00
levzzz 06254caea3 Merge pull request #3751 from leifiyoo/fix/creative-drop-items
Creative display drops reach the server on a server connection (#3523)
2026-10-29 22:07:59 +05:00
levzzz 7b78be8d4e Merge pull request #3738 from GIGABAIT93/fix/server-pack-overlay-cache
Server resource packs: keep the overlay when the pack is only touched
2026-10-29 22:07:53 +05:00
levzzz 73602536bb Merge pull request #3717 from 1038april/feat/own-name-tag
Extras: show your own name tag in third person
2026-10-29 22:07:47 +05:00
levzzzandClaude Opus 5.5 cb3f4387db Merge main into the invisibility branch: render_layers beside main's cape, and no cape while invisible
Main added CapeLayer (mobs::render::cape) at the spot this branch adds render_layers;
both stay. CapeLayer draws only when !state.isInvisible, so the player's own cape is
left out under Invisibility too, in the world and the inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:00:59 +05:00
levzzzandClaude Opus 5.5 5815bf4f11 jmath::atan2: use the fdlibm port geometry::trig already has
horizon_core::geometry::trig::fdlibm_atan2 is the same e_atan2.c port, and it
passes every vector of atan2_matches_strictmath, so jmath calls it instead of
keeping a second copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:58:48 +05:00
levzzzandClaude Opus 5.5 9c21a95409 Merge main into feat/ukrainian: Ukrainian beside main's Spanish
Both languages stay in Language; the translation test walks Language::ALL; the CLI's
help, its error and the docs now list es too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:57:20 +05:00
rdbtCVSandClaude Sonnet 5.5 74f0265db1 Deprecate --in-frame, --no-server-thread and HORIZON_SERVER_THREAD=0
The in-frame single-player path is unsupported and will be removed soon.
The game prints a deprecation warning when it is requested, the help text
and docs say never to use it, and the e2e drivers warn on their flags.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-29 16:43:43 +00:00
levzzzandClaude Opus 5.5 fe47804f28 Merge main into hdr-wayland: display_changed beside main's TAA set_world_post
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:43:21 +05:00
levzzzandClaude Opus 5.5 f12d87b5d9 Merge main into hdr-screen: the HDR offer beside main's log, and the test on main's slider API
Main's graphics screen now takes the settings in layout and extra_rows, and a
slider press starts a drag (SliderDrag) that saves on release (#2711).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:40:34 +05:00
levzzzandClaude Opus 5.5 0b63d040ea Merge main into hdr-ui: hdrUiBrightness beside main's taa and upscaling
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:13 +05:00
levzzzandClaude Opus 5.5 f9b002f804 Merge horizon-rust/main into fix/spawner-mob-display
Resolves the conflict in game/remote.rs tick_remote: keeps both the
remote spawner tick and main's client_tick_beacons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:31:36 +05:00
levzzzandClaude Opus 5.5 f065bab2e4 Merge main into the branch; the zombie villager's cure step gets the level random
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:55:13 +05:00
levzzz cefa6153d1 Merge remote-tracking branch 'hr/main' into fix-3544 2026-10-29 20:54:41 +05:00
Gitlab Main 8fdbad2d4b Merge origin/main into feat/audio-output-device 2026-10-29 20:58:32 +05:30
Ludovic MarieandClaude Opus 5.5 38da7d8d6e Merge main into this branch
brush.rs: main's player_generates_container_loot trigger (#33c15a6d7) is
kept in unpack_brush_loot, which now reads the Brushable block entity.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:24:59 +01:00
leifiyooandClaude Haiku 5.5 bb6305da25 Keep creative display drops on a server connection
A throw on an item tab's display slot (Q or a right-click in the creative
screen) dropped the stack with a local Player.drop. On a server connection
(single player's server thread, LAN, multiplayer) that item entity was
never sent to the server, so it vanished. Send the stack as
SetCreativeModeSlot to slot -1 instead, as the survival tab's throw and
vanilla's handleCreativeModeItemDrop already do.

Vanilla: CreativeModeInventoryScreen.slotClicked (item tab THROW),
MultiPlayerGameMode.handleCreativeModeItemDrop.

Co-Authored-By: Claude Haiku 5.5 <[email protected]>
2026-10-29 15:54:24 +01:00
reservedbytes d385d188f3 fix(core): atan2 matches StrictMath 2026-10-29 13:47:57 -01:00
GIGABAIT93andClaude Opus 5.5 f383fdf74c Server resource packs: keep the overlay when the pack is only touched
Joining a server whose resource pack is already in the download cache
marks the file used (HttpUtil.updateModificationTime, ported as touch),
and the overlay cache keyed a ZIP pack by its stamp, modified time
included. Every join therefore rebuilt the whole overlay and reloaded
from thousands of new files: on Windows, where Defender scans each, a
join waited about a minute on "Preparing resources" (a 1.2 MB Nexo
pack on tensa.co.ua). A ZIP is now known by its bytes, one file read;
directory packs keep their stamps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:22:06 +02:00
Claude f4f9897c9e Extras: show your own name tag in third person
A new extra, Own name tag (ownNameTag, off by default), under a Third
person header on the Extras page. With it on, the player's own model in
third person gets the name tag other players see: the profile name in
its team's colours, the below-name score, faded while crouching. It goes
through the same checks as other players' tags (team name tag
visibility, distance, F1) and hides in first person, while sleeping, in
spectator mode and while invisible. Vanilla never names the camera
entity, so this stays off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 23:26:36 +10:00
levzzzandClaude Opus 5.5 35eb60e2f1 Merge main into launcher-simplified-chinese: keep Spanish and Chinese
Both languages stay in Language::ALL; the PR's loop over every language
replaces main's fixed list in the completeness test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:12:09 +05:00
Ludovic MarieandClaude Opus 5.5 5a1c095724 Merge main into this branch
hud.rs: main's name_tag (and its test) and this branch's draw_inline_object
(and its tests) both kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:00:51 +01:00
Ludovic MarieandClaude Opus 5.5 bc78cba5b9 Merge main into this branch
remote.rs: main's spectator check and off-hand anchor charge are kept with
this branch's InteractionResult: an anchor charged from the off hand is
SUCCESS (RespawnAnchorBlock.useItemOn) and swings, a jukebox taking a disc
is SUCCESS (JukeboxPlayable.tryInsertIntoJukebox), an anchor left to the
off hand's glowstone is PASS. level/tests.rs: both new tests kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:59:19 +01:00
Ludovic MarieandClaude Opus 5.5 62c0834880 Merge main into this branch (rearing beside the horse finalize tests, one tests module)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:57:27 +01:00
Ludovic MarieandClaude Opus 5.5 98c0883c8f Merge main into this branch; suspicious blocks are not containers
remote.rs: main's spectator check (a spectator's use predicts nothing) and
this branch's shelf prediction are both kept, the spectator first.

machines.rs: the new Brushable block entity made the dispenser harness's
dropper under suspicious sand treat it as an empty container and drop
nothing; BrushableBlockEntity is no Container, so the dropper drops in
front of it as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:55:05 +01:00
Ludovic MarieandClaude Opus 5.5 0c06082dfa Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:05 +01:00
Ludovic MarieandClaude Opus 5.5 e2de446128 Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:51:02 +01:00
levzzzandClaude Opus 5.5 f7efb0d710 The launcher's Simplified Chinese covers the strings added on main: native mods, moving an instance to the Trash, and the build's new time estimate.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:45:38 +05:00
levzzz 9da77d9d4b Merge remote-tracking branch 'hr/main' into pr-2795
# Conflicts:
#	launcher/src/i18n/tests.rs
2026-10-29 17:41:21 +05:00
Ludovic Marie a7f1f70508 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game 2026-10-29 13:38:59 +01:00
Ludovic Marie 6e8ee3ed07 Merge remote-tracking branch 'origin/main' into draft/remove-wasm-mods-game
# Conflicts:
#	crates/horizon-client/src/game/mods.rs
2026-10-29 13:23:27 +01:00
damnurmumandCodex a5a0e1bb98 Merge main into inline object text
Resolve HUD text rendering conflict by preserving inline player and atlas objects alongside the new world text shadow and depth behavior.

Co-Authored-By: Codex <[email protected]>
2026-10-29 15:18:19 +03:00
degepieandClaude Opus 5.5 7e459e5fb1 Frogs long jump: level and unseeded randoms, activity gate TODO
Address the review on #3544:
- goals::Cx carries the level random (the one the brains already get);
  the weighted jump-candidate pick and the three TIME_BETWEEN_LONG_JUMPS
  samples draw from it, as LongJumpToRandomPos/LongJumpMidJump do.
- The angle shuffle in calculateOptimalJumpVector uses a fresh unseeded
  random (Collections.shuffle's global one), not the frog's.
- The preferred-block roll stays on the frog's random (vanilla uses the
  body's random there).
- TODO in LongJump::can_use for FrogAi.initJumpActivity's TEMPTING_PLAYER /
  BREED_TARGET gate and LAY_SPAWN priority: frogs have no tempting,
  breeding or frogspawn here yet.

Other mobs only get the new field threaded through; nothing else reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 16:15:54 +05:00
Ludovic Marie e932a2da61 Merge remote-tracking branch 'origin/main' into upd/2924
# Conflicts:
#	crates/horizon-client-render/src/options_ui.rs
2026-10-29 11:47:35 +01:00
iLostName 2677a397e5 invisibility: the player's own body is hidden in third person and the inventory, its armour still drawn
The model check looked up `minecraft:invisibility`, but the player's effects are kept without the namespace, so the body always showed. As LivingEntityRenderer.submit does, the body is now skipped unless the player's team sees friendly invisibles, while armour, elytra and held items still draw. Fixes #3615.
2026-10-29 11:42:52 +01:00
Mik00000 e0f11804f2 Address review: drop legacy readers; keep flag sync and entity_data placement 2026-10-29 12:17:39 +02:00
GIGABAIT93andClaude Opus 5.5 e7e5413667 Launcher: Ukrainian
launcher/lang/uk.json translates the launcher's 194 strings, Horizon's
own, written from the English; Language::Ukrainian ("uk", Українська)
joins the picker, the system language pick and `settings language`. The
translation test now covers every language but English.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:15:38 +02:00
Mik00000 7fb1f77870 Address review: drop overlapping spectral arrow paths and remove double arms 2026-10-29 12:06:20 +02:00
GIGABAIT93andClaude Opus 5.5 8a9db2dc7b Fonts: Pixelify Sans gets the Cyrillic І
Ukrainian headings, buttons and navigation need the capital І (U+0406),
which upstream doesn't map, so it came from the fallback font, smooth
and taller than the pixel letters around it. As #2844 did for О, it
takes each weight's own Latin I glyph, the same shape. Only cmap changes
(and head's checksum adjustment); every other table, the timestamps
included, is byte-identical to the files it replaces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:06:06 +02:00
Mik00000 4d13a4ebcc Address review comments: empty Spawner default, remove legacy fallbacks, use block light 2026-10-29 12:00:54 +02:00
Ludovic MarieandClaude Opus 5.5 a20aaf5396 Text objects: vanilla's default fallback when none is given
ObjectContents.visit uses the object's defaultFallback without one:
[item/porkchop], [porkchop@gui] off the blocks atlas, [Steve head].

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:51:40 +01:00
Ludovic MarieandClaude Opus 5.5 f44042e0eb Sound device: vanilla's own option names, and a missing device asks once
options.audioDevice and options.audioDevice.default come from vanilla's
language files ("Device", "System Default"): the game's own lang no longer
overrides them. Audio keeps the device asked for, so a saved device that is
unplugged plays on the default without reopening the stream at every
settings change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
Ludovic MarieandClaude Opus 5.5 4a2a5b9977 Merge main into this branch after the render crate split (#3186)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:25:05 +01:00
Ludovic MarieandClaude Opus 5.5 1eb34fa68e Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:40 +01:00
Ludovic MarieandClaude Opus 5.5 4623d2e59f Merge main into this branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:18:33 +01:00
Ludovic Marie 04cd6ece23 Drop the test that only exercised std::mem::replace (the fix is the one line in as_guest) 2026-10-29 10:16:08 +01:00
Ludovic Marie 9218dc3e7d Leave horizon-server's lists test as on main (not part of this fix) 2026-10-29 10:15:47 +01:00
Ludovic Marie 5b66bd0ec0 Merge remote-tracking branch 'origin/main' into upd/3148 2026-10-29 10:15:46 +01:00
Username59138andClaude Opus 5.5 3451e4e29a Command blocks: keep the command in the edit screen, suggest as vanilla
Command blocks barely worked from the default single player (the window
is a client of its server thread), a LAN guest or a dedicated server:

- ServerPlayer.openCommandBlock: the server now sends the block's data
  (saveCustomOnly) to the player whose use opened it, and the open
  screen refills from it (handleBlockEntityData, updateGui). The chunk's
  update tag leaves the command out, so the screen showed an empty box
  and Done then wiped the block's command.
- Screen.extractBackground of an isInGameUi screen: the dimmed
  gradient, not the in-world menu background.
- CommandSuggestions in the command box with the arguments
  AbstractCommandBlockEditScreen passes (commands without a slash,
  completions from the start, 7 lines under the box, fill 0x80000000):
  the chat's suggestions take a SuggestLayout. Server completions
  (ask_server) reach the box too.
- horizon-ctl key presses reach the command block screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:53:14 +03:00
d3grandoandClaude Opus 5.5 a30b9525d1 Horses rear when hurt
AbstractHorse.hurtServer had no port: after a hurt that went through,
vanilla draws random.nextInt(3) and on 0 calls standIfPossible, which
rears the horse for 20 ticks unless canPerformRearing is false (llamas,
camels). Mob::damage_with now ends with that roll for every equine, and
make_mad shares the new stand_if_possible.

The stand counter runs down in AbstractHorse.tick, with or without AI,
but Horizon ran it down only in the AI step, so a NoAI horse that reared
from a hit stayed reared for good. A mob with no AI now runs it down too.

The mob_ai harness gets a llama_hurt scenario (the draw without the
rear), which matched for 10 ticks before and all 201 after; the probe
names MountPanic and LlamaHurtBy as vanilla does.

Fixes #3432

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:45:42 +07:00
SenjinandClaude Opus 5.5 ef2ec4c0dc CI: run the checks again (horizon-server's lists test got no answer from Mojang's services)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 09:33:06 +01:00
Senjin a0964a8010 Merge remote-tracking branch 'origin/main' into hdr-wayland 2026-10-29 08:32:57 +01:00
Senjin d91bd61281 Merge remote-tracking branch 'origin/main' into hdr-screen 2026-10-29 08:32:57 +01:00
Senjin 289c2df527 Merge remote-tracking branch 'origin/main' into hdr-ui 2026-10-29 08:32:56 +01:00
時穂andCodex 6b96bb96b6 Merge main into launcher-simplified-chinese
Keep German and Simplified Chinese together, retain token-based translation
checks, and refresh Chinese strings for the current launcher.

Co-Authored-By: Codex <[email protected]>
2026-10-29 16:29:05 +09:00
Ludovic MarieandClaude Opus 5.5 e7614d2197 Game: WebAssembly code mods don't run any more
Horizon's code mods are native now (game/native_hooks.rs, built in by the
launcher). The game drops the WebAssembly runtime and everything around
it: the `mods` feature (wasmtime and cranelift), the mods' hooks, HUD
panels and screens, the item panel and its recipes, input claims, camera
shakes, far terrain, server mods and the extras that moved to WebAssembly
mods. Content mods (blocks, items, data and assets) load as before.

A WebAssembly code mod's row on the Mods page says it doesn't run any more,
or that its native version replaced it. The first world after the update
says once in the chat which stopped and where to get their native
versions; their files stay where they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:46:57 +01:00
Ludovic MarieandClaude Opus 5.5 bc33470d5c Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:15 +01:00
Ludovic MarieandClaude Opus 5.5 c48c2f2167 Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 cef862471f Merge main into this branch
Main now has the render crate split (#3186); Git followed the moved files,
and nothing in this branch needed changing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:13:13 +01:00
d3grandoandClaude Opus 5.5 44d9bddebd Open shulker boxes with the shulker box menu on servers
ShulkerBoxBlockEntity.createMenu opens a ShulkerBoxMenu
(minecraft:shulker_box), whose ShulkerBoxSlots refuse shulker boxes. The
server (single player on its server thread, LAN, dedicated) sent a
generic_9x3 chest menu instead, so it took a shulker box put into a
shulker box, and a vanilla client drew a chest screen.

lan_open_container now sends the block entity's menu type from
horizon_client::containers::block_menu_type; the server already refuses
shulker boxes in that menu, and the client already draws it.

New harness tools/test_storage_menu_parity.py (VanillaStorageMenuOracle,
storage_menu_fixture): 31/31 blocks, 14/31 with the old menu type. New
lan_e2e check shulker_menu.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:41:34 +07:00
Ludovic MarieandClaude Opus 5.5 984273f5c8 Merge main into this branch after the render crate split (#3186)
containers.rs moved to horizon-client-render (#3186): this branch's SkullSkin fallback (the "Name" key and a plain string profile) is carried over to the moved file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:41:28 +01:00
Gitlab Main 48e731483a options: choose the audio output device in Music & Sounds (#1035)
Adds audio output device selection matching vanilla options.txt format:
- Bind soundDevice option in Settings with serialization roundtrip
- Enumerate output devices and support live stream switching in Audio
- Add SoundDevice option button to Music & Sounds page cycling devices
- Apply audio device changes dynamically on options save and startup
- Unit tests for settings parsing and options UI button cycling
2026-10-29 09:13:13 +05:30
SenjinandClaude Opus 5.5 cd70f518e9 HDR Auto: what Hyprland 0.57 reports, in the docs and the code comment
Tested on Hyprland 0.57 (always-on HDR, NVIDIA): it reports reference white
203 and 0..1060 nits, so Auto gives Paper White 203 / Peak 1060, and it shows
HDR windows below its desktop's SDR white (sdr_max_luminance 430, which the
protocol doesn't carry): a white browser page is brighter than the game's
white and than a 1000-nit patch in mpv.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 04:35:38 +01:00
Ludovic MarieandClaude Opus 5.5 f7300bd96b Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 02:12:57 +01:00
SenjinandClaude Opus 5.5 8202f02f4e HDR: log what the compositor reports once per change
The renderer is made anew a few times while the game starts, and each asked
the compositor again and logged the same line; a slider dragged in the
desktop's HDR settings logged a line per step. Now a line comes only when the
values changed, and a change within a second of the last line waits until the
values have held for a second, so only the value they settled on is logged.
The values themselves still apply at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:50:57 +01:00
Ludovic MarieandClaude Opus 5.5 b6cd09442e World icon: wait for more than 10 rendered sections
GameRenderer.takeAutoScreenshot saves only when countRenderedSections() > 10
and every section is rendered. A window that joined its server thread
had nothing pending before its first chunks, so the icon was only sky.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 e3b1ae512f Single player: the world icon keeps its save folder through the async start
Since #3011 the window joins the server thread in poll_integrated_start,
where open_integrated's folder is no longer in scope: Starting carries it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 01:37:33 +01:00
Ludovic Marie 5b4dd8b72e Merge main into fix/world-select-icons 2026-10-29 01:37:27 +01:00
degepieandClaude Opus 5.5 5f269772d9 Frogs long jump: FrogAi's long jump activity (fixes #2841)
Frogs had no long jump on the server, so they only ever walked. Port the
LONG_JUMP activity as one goal: LongJumpToPreferredBlock searches the
9x5x9 box (weighted by squared distance, lily pads and big dripleaf first
half the time) for a spot FrogAi.isAcceptableLandingSpot accepts and an
8-block path cannot reach, faces it for 40 ticks and leaps along the
LongJumpUtil arc (now with its collision check); LongJumpMidJump holds the
LONG_JUMPING pose without friction until the frog lands. The
LONG_JUMP_COOLDOWN_TICKS memory counts down and is reset as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UE3PMKbnTcxfBSeT5uQWCK
2026-10-29 05:23:58 +05:00
d3grandoandClaude Opus 5.5 7211d029ab Swing the hand when Q drops a stack from an inventory slot on a server
On a server (single player included) a menu click is predicted by the
menu engine and sent, and what the prediction dropped was ignored, so
Q over a slot threw the item without the arm swing. Vanilla's
AbstractContainerMenu.doClick drops with Prediction.PREDICTED, and
LivingEntity.drop then swings the main hand on the client itself; the
server shows that swing only to the other players.

The prediction loop moves to predict_clicks, which also says whether a
click dropped something; remote_menu_ops swings the main hand then.

Fixes #3357

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 07:17:59 +07:00
Senjin 25e00a0ecf Merge remote-tracking branch 'origin/main' into hdr-wayland
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/options.rs
2026-10-29 01:05:23 +01:00
d3grandoandClaude Opus 5.5 1d2d495686 Shields: an enchanted banner shield keeps its patterns under the glint
An enchanted shield with a banner turned white: after the patterns, the
renderer drew the shield base again with the foil for vanilla's
`patternedShieldGlint` pass. In vanilla that pass draws the glint alone
(`RenderPipelines.GLINT` with only the glint texture), but a foil quad
here is its texture with the glint over it, so the plain base covered the
base colour and every pattern.

Every layer of a patterned shield now carries the foil instead, and the
extra pass is gone. The GUI adds the glint masked by each layer's alpha
and the world shader composites it per layer, so each pixel ends with
the glint once over the layers below it, as vanilla's single glint pass
over the finished plate gives.

Vanilla: ShieldSpecialRenderer.submit, BannerRenderer.submitPatterns,
RenderTypes.patternedShieldGlint.

Fixes #3407

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 06:55:53 +07:00
Senjin 3ea82790ea Merge remote-tracking branch 'origin/main' into hdr-ui
# Conflicts:
#	crates/horizon-client/src/game/options.rs
#	crates/horizon-client/src/gfx.rs
2026-10-29 00:45:46 +01:00
SenjinandClaude Opus 5.5 31ead52d21 HDR: follow the compositor's changes while the game runs
The renderer keeps the window's surface feedback and looks at it every frame
(only events winit already read; no waiting). On preferred_changed it asks
again, and the game applies its settings, so Auto follows a move to another
output or a recalibrated desktop without a restart. Docs: the Auto values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 00:08:10 +01:00
SenjinandClaude Opus 5.5 7ef092fabc HDR: Auto paper white, at the desktop's SDR white
Paper White 0 is Auto, the new default: where the system puts SDR white (on
Wayland the compositor's reference white, 203 nits in HDR output's scale), else
203. Both Auto values come from one report the renderer makes at surface open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:42:34 +01:00
Ludovic MarieandClaude Opus 5.5 326719539d Merge main into fix/shelf-interaction
main's #2952 added banner, vault and trial spawner arms to lan::chunks::entity_tag next to this branch's brushable and shelf arms; both stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:32:54 +01:00
SenjinandClaude Opus 5.5 dc418c7623 HDR: Auto peak brightness from what the compositor reports
Peak Brightness 0 is Auto, the new default: the peak the system reports for
the display (on Wayland, the surface's preferred image description, in HDR
output's scale), else 1000. The slider's lowest step is Auto; 200..2000 stay
manual.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 d3559e2a5f Proceed past the safety notice in the F5 server list test
main's f5_reloads_the_server_list (4c4fcedf4) came after this branch: its first Multiplayer click now opens SafetyScreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:28 +01:00
Ludovic MarieandClaude Opus 5.5 c90893c29c Merge main into fix/multiplayer-safety-notice
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 23:00:18 +01:00
damnurmumandCodex dd130f1902 Render player heads and atlas sprites in text components
Parse Minecraft object components into inline spans and draw player heads and atlas sprites with the existing HUD atlas. Resolve head profiles through the skin cache and preserve fallback text for plain output.

Co-Authored-By: Codex <[email protected]>
2026-10-28 22:07:28 +03:00
Muhammad Asif 1a9040ad79 Swing the arm when opening containers
MultiPlayerGameMode.useItemOn swings on InteractionResult.SUCCESS
(SwingSource.CLIENT). The remote client only swung when the held item
was a block, so opening a chest, barrel, furnace, or similar storage
did not start the arm animation.
2026-10-28 22:33:23 +05:00
Senjin 8ebcf6c13d WIP HDR: ask the compositor for the window's luminances (log only) 2026-10-28 16:00:54 +01:00
Mik00000 08b9765278 Ensure mob effect IDs are namespaced so tipped arrows tick damage on living mobs 2026-10-28 16:09:27 +02:00
SenjinandClaude Opus 5.5 380d5ebe9f HDR: its settings get a screen of their own
Paper White and Peak Brightness sat among the display settings, where players
without an HDR display took them for settings of their own display. Display now
has only HDR Settings..., greyed where the display path offers no HDR output (the
renderer tells the settings when it opens its surface, `graphics::offer_hdr`).
It opens Graphics: HDR: the HDR switch, then its sliders, greyed while it is off.

Where HDR isn't offered, `horizon-graphics.txt`'s HDR settings are left as saved
and not used: the renderer already keeps HDR off there, and the screen no longer
opens, so a game folder copied from another computer, or a display swapped for
an SDR one, shows nothing that does nothing; an HDR display brings them back.

The screen leaves room for a calibration step and an in-game shortcut to it
later. horizon-ctl opens it as `options graphics-hdr`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:58:55 +01:00
SenjinandClaude Opus 5.5 18c23a6df3 HDR: UI Brightness, and the HUD and menus never lift
Pixels counted as the world when they matched it darkened evenly (the vignette),
and grey text over the white sun matches that: menu text in front of the sun was
lifted to the peak. Once the world is kept for the last pass, the frame's alpha
is now zeroed; the hand, HUD and menus raise it where they draw (the vignettes
leave it), and after the hand a blend pass scales its alpha down to 1/255. The
last pass lifts nothing covered, keeps the hand at the world's brightness (it's
the player's, not UI), and scales the rest by UI Brightness.

UI Brightness (Display, 100% by default) is the HUD and menus as a share of paper
white, so pure white labels needn't sit at the world's white. At 100% the picture
is as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 12:57:35 +01:00
Mik00000 445183a7df Display mob inside spawners and trial spawners (fixes #3107) 2026-10-28 13:22:28 +02:00
Shreyas 380d71d8a8 lists: fall back to offline resolution when services do not answer in tests 2026-10-28 16:31:23 +05:30
Mik00000 85f53198ce Allow bows and crossbows to shoot spectral and tipped arrows (#3071) 2026-10-28 12:37:54 +02:00
Shreyas 0543cf0345 Select the picked hotbar slot in singleplayer and LAN
Preserve the guest's updated hotbar selection when restoring the host
player state after an action. The previous restore sequence discarded
the guest's new selection while keeping the inventory changes.

The held-slot response now uses the selected slot produced by the pick,
so integrated-server and LAN players select the picked block or spawn
egg. Keep the host's selection independent of the guest's selection.

Vanilla reference: ServerGamePacketListenerImpl.tryPickItem,
Minecraft.pickBlockOrEntity, and ClientPacketListener.handleSetHeldSlot.

Fixes #3031
2026-10-28 16:00:22 +05:30
Mik00000 13e441e235 Fix invisible item frames and custom head skins 2026-10-28 12:00:31 +02:00
Kacpherek e0a48b3f66 Document the 2..128 render distance range 2026-10-28 05:56:49 +01:00
Kacpherek d35efa25ec Accept render distances up to 128 from the command line and horizon-ctl
--render-distance and horizon-ctl render-distance check against the same cap as the options.
2026-10-28 05:56:48 +01:00
Kacpherek b8b15b1682 Raise the render distance cap from 64 to 128 chunks
The options slider and options.txt now accept 2..128. 128 is where the server protocol's view distance (an i8) tops out.
2026-10-28 05:56:48 +01:00
Kacpherek 05f8ec650d Pass the safety notice in the direct connection test
Multiplayer now opens SafetyScreen first, so the test proceeds through it.
2026-10-28 05:45:51 +01:00
時穂andCodex 2702542c73 launcher: complete Chinese strings and strengthen translation checks
Fill the 17 missing Microsoft sign-in, resource setup and Nightly strings.
Scan Rust tokens for multiline translation calls and require shared catalog
keys in every language, including labels translated through widgets or constants.

Co-Authored-By: Codex <[email protected]>
2026-10-28 13:37:54 +09:00
Ludovic MarieandClaude Opus 5.5 3e930fc74b Launcher reference: the language setting lists zh
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:55:39 +01:00
Ludovic MarieandClaude Opus 5.5 04ec03745d Launcher Chinese: drop the coming-soon strings main removed
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:52 +01:00
Ludovic MarieandClaude Opus 5.5 6a6b5957eb Launcher Chinese: the nightly strings main added since
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:52:29 +01:00
Ludovic Marie 8c18d9429f Merge remote-tracking branch 'origin/main' into cg24/f2795 2026-10-27 22:50:42 +01:00
Ludovic MarieandClaude Opus 5.5 2ee238e5f1 Merge #2931 into #2924: both add vanilla 26.3 options to options_ui.rs
The controls list and the mouse list sit side by side; #2931 lands
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:28:53 +01:00
Ludovic MarieandClaude Opus 5.5 6bb648db1e Controls: the three switches nothing reads yet show as unavailable
Operator Items Tab, Quit Shortcuts and Control-click are stored and saved
like vanilla's, but no code reads them yet: grey them out like the other
placeholders instead of offering a switch that does nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 22:26:24 +01:00
Kacpherek 42388d2512 Swap items with shelves and powered shelf chains 2026-10-27 22:02:24 +01:00
Kacpherek 2deaab2602 Render items stored in shelves 2026-10-27 22:02:24 +01:00
Kacpherek ca28a0edad Show items revealed by brushing suspicious blocks 2026-10-27 22:02:18 +01:00
Kacpherek 694568a89c Show the multiplayer safety notice on the first Multiplayer click
Horizon opened the server list at once and never read skipMultiplayerWarning. Vanilla TitleScreen shows SafetyScreen first, with a do-not-show-again box saved to options.txt, unless that option is set.
2026-10-27 21:56:00 +01:00
ElyTheCat 80df20837b Options: add Java 26.3 controls 2026-10-27 22:09:31 +03:30
masa-masa-0314andZCode 2092c5a2e8 Worlds: take the save's icon.png from the rendered frame
GameRenderer.tryTakeScreenshotIfNeeded saves a world icon the Select
World list never had: while a local world is on screen without one, the
frame is grabbed after the level and the hand (before the HUD), centre
cropped to a square, averaged down to 64x64 and written to the save's
icon.png once a second until it sticks. The list already drew the icon
when a save had one; nothing ever created it.

Co-Authored-By: ZCode <[email protected]>
2026-10-28 02:29:39 +08:00
1F10250001andClaude Opus 5.5 66969d8a93 launcher: Simplified Chinese
Adds Simplified Chinese (zh) beside English, French and Russian: the 160 strings
of launcher/lang, written for Horizon, and the language in Settings > Launcher,
the system pick (zh-Hans-CN and other zh locales) and `settings language zh`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 21:29:17 +09:00
134 changed files with 5193 additions and 3572 deletions

No files matched your search

+4 -4
View File
@@ -63,7 +63,7 @@ jobs:
run: |
cargo +stable fmt --all -- --check
# With the game's code mods (on in every release build, off by default).
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
@@ -71,11 +71,11 @@ jobs:
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib --bins --tests --features horizon-mods/pack
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib --features horizon-client/mods,horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
cargo +stable test --workspace --lib --features horizon-mods/pack mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc --features horizon-mods/pack
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+3 -5
View File
@@ -138,8 +138,7 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
@@ -267,9 +266,8 @@ jobs:
run: |
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
# and with code mods like the client.
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+8 -7
View File
@@ -76,7 +76,7 @@ cargo build --profile fast -p horizon-client # optimized, quick to link:
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host); --in-frame compares the fallback
python3 tools/lan_e2e.py --check chest # two real games over LAN (server-thread host)
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
@@ -169,17 +169,18 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Deterministic rule fixtures still use this path; player-facing integration uses the default server thread.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`): DEPRECATED, never use it, it
will be removed soon.** The window's `Game` ticks the world itself, one level that follows the player
(`fn enter_dimension`). Compatibility does not matter: don't add features or fixes for it, don't write new
tests against it, and move code off it when you touch it. The deterministic rule fixtures still run on it
until they are ported.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test the server
thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/lan_e2e.py --in-frame --check <name> # comparison (portals, levels, distance, lava need server-thread mode)
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
Generated
+3
View File
@@ -1527,6 +1527,9 @@ name = "horizon-hdr"
version = "0.1.0"
dependencies = [
"ash",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wgpu",
]
+106 -2
View File
@@ -1,6 +1,6 @@
//! Text components from commands: `ComponentArgument` reads an SNBT (or JSON)
//! value, `ComponentSerialization` turns it into styled text.
use crate::text::{Style, Text, named_color};
use crate::text::{InlineObject, Style, Text, named_color};
use horizon_world::nbt::Tag;
use serde_json::{Map, Value};
@@ -296,6 +296,10 @@ pub fn to_text_with(
let style = object_style(o);
let content = if let Some(t) = contents(o) {
t
} else if let Some(object) = inline_object(o, resolve, contents) {
let mut text = Text::literal("\u{fffc}");
text.spans[0].style.inline_object = Some(object);
text
} else if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
@@ -332,12 +336,74 @@ pub fn to_text_with(
{
out.push(to_text_with(extra, resolve, contents).with_style(&style));
}
out
if o.contains_key("object") || o.contains_key("player") || o.contains_key("sprite") {
out.with_component(Value::Object(o.clone()))
} else {
out
}
}
Value::Null => Text::empty(),
}
}
fn inline_object(
object: &serde_json::Map<String, Value>,
resolve: &mut dyn FnMut(&str) -> Text,
contents: &mut dyn FnMut(&serde_json::Map<String, Value>) -> Option<Text>,
) -> Option<InlineObject> {
let kind = object.get("object").and_then(Value::as_str);
let fallback = object
.get("fallback")
.map(|value| to_text_with(value, resolve, contents).plain());
match kind {
Some("player") | None if object.contains_key("player") => {
let profile = object.get("player")?.clone();
// PlayerSprite.defaultFallback.
let fallback = fallback.unwrap_or_else(|| {
match profile
.as_str()
.or_else(|| profile.get("name").and_then(Value::as_str))
{
Some(name) => format!("[{name} head]"),
None => "[unknown player head]".to_owned(),
}
});
Some(InlineObject::Player {
profile,
hat: object.get("hat").and_then(Value::as_bool).unwrap_or(true),
fallback,
})
}
Some("atlas") | None if object.contains_key("sprite") => {
let atlas = object
.get("atlas")
.and_then(Value::as_str)
.unwrap_or("minecraft:blocks")
.to_owned();
let sprite = object.get("sprite")?.as_str()?.to_owned();
// AtlasSprite.defaultFallback: short names, the atlas unless blocks.
let fallback = fallback.unwrap_or_else(|| {
let short = |id: &str| match id.split_once(':') {
Some(("minecraft", path)) => path.to_owned(),
Some(_) => id.to_owned(),
None => id.to_owned(),
};
if short(&atlas) == "blocks" {
format!("[{}]", short(&sprite))
} else {
format!("[{}@{}]", short(&sprite), short(&atlas))
}
});
Some(InlineObject::Atlas {
atlas,
sprite,
fallback,
})
}
_ => None,
}
}
/// `CustomName` as text: a string keeps its legacy formatting codes, anything
/// else is a component (`ComponentSerialization`).
pub fn name_text(tag: &Tag) -> Text {
@@ -351,6 +417,44 @@ pub fn name_text(tag: &Tag) -> Text {
mod tests {
use super::*;
#[test]
fn player_object_survives_text_parsing_and_keeps_its_siblings() {
let component = serde_json::json!({
"text": "<",
"extra": [
{"object": "player", "player": {"name": "elyapluggin"}, "hat": false,
"fallback": "[head]"},
{"text": " >", "color": "gold"}
]
});
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(text.plain(), "<[head] >");
assert!(matches!(
&text.spans[1].style.inline_object,
Some(InlineObject::Player { hat: false, .. })
));
assert!(text.spans[2].style.inline_object.is_none());
assert_eq!(text.component(), component);
let sprite = serde_json::json!({"object": "atlas", "sprite": "minecraft:item/porkchop"});
let text = to_text(&sprite, &mut |_| Text::empty());
assert!(matches!(
&text.spans[0].style.inline_object,
Some(InlineObject::Atlas { sprite, .. }) if sprite == "minecraft:item/porkchop"
));
// ObjectContents.visit without a fallback: the sprite's defaultFallback.
assert_eq!(text.plain(), "[item/porkchop]");
let head = serde_json::json!({"object": "player", "player": {"name": "Steve"}});
assert_eq!(
to_text(&head, &mut |_| Text::empty()).plain(),
"[Steve head]"
);
let gui = serde_json::json!({"atlas": "gui", "sprite": "hud/heart/full"});
assert_eq!(
to_text(&gui, &mut |_| Text::empty()).plain(),
"[hud/heart/full@gui]"
);
}
#[test]
fn snbt_and_json_components() {
let input = "{text:'Hi ',color:red,extra:[{text:\"there\",bold:1b}]} rest";
+2 -1
View File
@@ -91,6 +91,7 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
/// Namespaced effect ids for mob effect storage.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
IDS.get_or_init(|| {
EFFECTS
.iter()
@@ -102,7 +103,7 @@ pub fn namespaced(id: &'static str) -> &'static str {
})
.collect()
})
.get(id)
.get(key)
.copied()
.unwrap_or(id)
}
+31 -1
View File
@@ -58,6 +58,28 @@ pub enum ClickEvent {
OpenFile(String),
}
/// A non-character `minecraft:object` embedded in a text component.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum InlineObject {
Player {
profile: Value,
hat: bool,
fallback: String,
},
Atlas {
atlas: String,
sprite: String,
fallback: String,
},
}
impl InlineObject {
pub fn fallback(&self) -> &str {
match self {
Self::Player { fallback, .. } | Self::Atlas { fallback, .. } => fallback,
}
}
}
/// Text style; `None` fields inherit from the enclosing style.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Style {
@@ -77,6 +99,8 @@ pub struct Style {
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
/// Content metadata for a single U+FFFC object span; never inherited.
pub inline_object: Option<InlineObject>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
@@ -337,7 +361,13 @@ impl Text {
}
/// Component.getString: the plain characters.
pub fn plain(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
self.spans
.iter()
.map(|s| match &s.style.inline_object {
Some(object) if s.text == "\u{fffc}" => object.fallback(),
_ => s.text.as_str(),
})
.collect()
}
pub fn is_empty(&self) -> bool {
self.spans.iter().all(|s| s.text.is_empty())
+91 -5
View File
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -543,15 +544,48 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
)?;
Ok(stream)
}
/// Available audio output device names.
#[cfg(feature = "audio")]
pub fn output_devices() -> Vec<String> {
if output_disabled(|name| std::env::var_os(name)).is_some() {
return Vec::new();
}
let host = cpal::default_host();
let mut names = Vec::new();
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(name) = device.name()
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
#[cfg(not(feature = "audio"))]
pub fn output_devices() -> Vec<String> {
Vec::new()
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
Self::with_device(root, "")
}
pub fn with_device(root: &Path, device_name: &str) -> Self {
Self::with_output(root, true, device_name)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false)
Self::with_output(root, false, "")
}
fn with_output(root: &Path, output: bool) -> Self {
/// The output device asked for (`soundDevice`), empty for the system's default; the
/// default plays when that one can't be opened, as in vanilla.
pub fn device(&self) -> &str {
&self.device_name
}
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
@@ -564,7 +598,7 @@ impl Audio {
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
Self::open_named(mixer.clone(), target_device)
} else {
Err("no output on a dedicated server".into())
};
@@ -581,6 +615,7 @@ impl Audio {
engine.loaded = live;
Self {
_stream: stream,
device_name: target_device.to_owned(),
engine,
backend: MixerBackend {
mixer,
@@ -602,10 +637,61 @@ impl Audio {
}
}
#[cfg(not(feature = "audio"))]
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
if !target.is_empty()
&& let Ok(devices) = host.output_devices()
{
for device in devices {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if name == target {
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => {
log::warn!(
"Horizon: could not open audio device '{target}': {e}; falling back to default"
);
}
}
break;
}
}
}
Self::open(mixer)
}
#[cfg(feature = "audio")]
pub fn set_device(&mut self, name: &str) -> Result<()> {
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
self.device_name = name.to_owned();
return Ok(());
}
self._stream = Some(Self::open_named(self.backend.mixer.clone(), name)?);
self.device_name = name.to_owned();
self.backend.live = true;
self.engine.loaded = true;
Ok(())
}
#[cfg(not(feature = "audio"))]
pub fn set_device(&mut self, name: &str) -> Result<()> {
self.device_name = name.to_owned();
Ok(())
}
#[cfg(feature = "audio")]
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<Stream> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
+177 -43
View File
@@ -653,6 +653,42 @@ pub struct Commands<'a> {
pub registries: &'a Registries,
}
/// `CommandSuggestions`' constructor arguments: how a screen's suggestions
/// parse and where they are drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SuggestLayout {
/// `commandsOnly`: the input is a command without a leading `/`.
pub commands_only: bool,
/// `onlyShowIfCursorPastError`: completions from the command's start,
/// not only past the `/`.
pub only_past_error: bool,
/// `suggestionLineLimit`.
pub line_limit: usize,
/// `anchorToBottom`: the list and usage over a box at the bottom, else
/// under one at the top (y 72).
pub anchor_to_bottom: bool,
/// `fillColor`: behind the list and the usage lines.
pub fill: i32,
}
impl SuggestLayout {
/// ChatScreen.init's `CommandSuggestions`.
pub const CHAT: Self = Self {
commands_only: false,
only_past_error: false,
line_limit: 10,
anchor_to_bottom: true,
fill: FILL,
};
/// AbstractCommandBlockEditScreen.init's `CommandSuggestions`.
pub const COMMAND_BLOCK: Self = Self {
commands_only: true,
only_past_error: true,
line_limit: 7,
anchor_to_bottom: false,
fill: i32::MIN,
};
}
/// The open chat screen: vanilla's `ChatScreen` with its `EditBox` and
/// `CommandSuggestions` (argument colouring, usage lines, parse errors,
/// the suggestion list, Tab completion, history).
@@ -680,6 +716,7 @@ pub struct ChatScreen {
pub auto_suggestions: bool,
/// `CommandSuggestions.setRestrictions`: what the Chat option allows.
pub abilities: ChatAbilities,
pub layout: SuggestLayout,
}
/// Local completions waiting for the server's reply to request `id`.
struct Awaiting {
@@ -750,6 +787,43 @@ impl ChatScreen {
keep_suggestions: false,
auto_suggestions: true,
abilities,
layout: SuggestLayout::CHAT,
};
s.update_command_info(m, c);
s
}
/// `AbstractCommandBlockEditScreen.init`: the command box at the top,
/// focused, holding `command`, with suggestions from the start.
pub fn command_block(
m: &Measure,
c: &Commands,
command: &str,
width: i32,
height: i32,
) -> Self {
let mut input = TextField::new(width / 2 - 150, 50, 300, 20);
input.max_length = 32500;
input.focused = true;
input.set_value(m, command);
let mut s = Self {
input,
width,
height,
history_buffer: String::new(),
history_pos: 0,
parse: None,
pending: None,
awaiting: None,
request: None,
suggestions: None,
usage: Vec::new(),
usage_position: 0,
usage_width: 0,
allow_suggestions: true,
keep_suggestions: false,
auto_suggestions: true,
abilities: ChatAbilities::new(ChatVisibility::Full),
layout: SuggestLayout::COMMAND_BLOCK,
};
s.update_command_info(m, c);
s
@@ -768,7 +842,11 @@ impl ChatScreen {
let value = self.input.value.clone();
let auto = self.auto_suggestions;
let abilities = self.abilities;
*self = Self::new(m, c, &value, self.history_pos, width, height, abilities);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
} else {
Self::command_block(m, c, &value, width, height)
};
self.auto_suggestions = auto;
}
}
@@ -814,11 +892,18 @@ impl ChatScreen {
self.usage.clear();
let cursor = self.input.cursor;
let cursor_b = byte_at(&command, cursor);
if command.starts_with('/') {
let slash = command.starts_with('/');
if self.layout.commands_only || slash {
let start = usize::from(slash);
if self.parse.is_none() {
self.parse = Some(c.dispatcher.parse_at(&command, 1, c.registries));
self.parse = Some(c.dispatcher.parse_at(&command, start, c.registries));
}
if cursor >= 1 && (self.suggestions.is_none() || !self.keep_suggestions) {
let from = if self.layout.only_past_error {
start
} else {
1
};
if cursor >= from && (self.suggestions.is_none() || !self.keep_suggestions) {
let parse = self.parse.as_ref().expect("parsed above");
let (range, list, ask) =
c.dispatcher
@@ -1002,7 +1087,11 @@ impl ChatScreen {
0,
self.input.screen_x(m, 0) + self.input.inner_width() - width,
);
let y = self.height - 12;
let y = if self.layout.anchor_to_bottom {
self.height - 12
} else {
72
};
// sortSuggestions: those starting with the word at the cursor first.
let partial = &value[..byte_at(&value, self.input.cursor)];
let word = partial[last_word_index(partial)..].to_lowercase();
@@ -1011,9 +1100,16 @@ impl ChatScreen {
s.text.starts_with(&word) || s.text.starts_with(&format!("minecraft:{word}"))
});
first.extend(rest);
let n = first.len().min(10) as i32;
// SuggestionsList: beside a bordered box, or over an unbordered one.
let n = first.len().min(self.layout.line_limit) as i32;
let bordered = i32::from(self.input.bordered);
let top = if self.layout.anchor_to_bottom {
y - 3 - n * 12
} else {
y - bordered
};
let mut l = SuggestionList {
rect: [x - 1, y - 3 - n * 12, width + 1, n * 12],
rect: [x - (1 - bordered), top, width + 1, n * 12],
original: value,
list: first,
offset: 0,
@@ -1042,11 +1138,12 @@ impl ChatScreen {
}
fn cycle(&mut self, l: &mut SuggestionList, dir: isize) {
self.select(l, l.current as isize + dir);
let max = l.list.len().saturating_sub(10);
let limit = self.layout.line_limit;
let max = l.list.len().saturating_sub(limit);
if l.current < l.offset {
l.offset = l.current.min(max);
} else if l.current > l.offset + 9 {
l.offset = (l.current as isize + 1 - 10).clamp(0, max as isize) as usize;
} else if l.current >= l.offset + limit {
l.offset = (l.current as isize + 1 - limit as isize).clamp(0, max as isize) as usize;
}
}
/// `SuggestionsList.useSuggestion`.
@@ -1074,7 +1171,57 @@ impl ChatScreen {
ctrl: bool,
) -> ChatAction {
use winit::keyboard::KeyCode as K;
// CommandSuggestions.keyPressed.
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
self.field_key(m, c, key, shift, ctrl);
}
}
ChatAction::None
}
/// The input box's own keys (`EditBox.keyPressed`), the suggestions
/// following an edit: whether the box took the key.
pub fn field_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
ctrl: bool,
) -> bool {
match self.input.key(m, key, shift, ctrl) {
Some(changed) => {
if changed {
self.edited(m, c);
}
true
}
None => false,
}
}
/// `CommandSuggestions.keyPressed`: the list's arrows, Tab and Escape,
/// and Tab to show it. True when the suggestions took the key.
pub fn suggestions_key(
&mut self,
m: &Measure,
c: &Commands,
key: winit::keyboard::KeyCode,
shift: bool,
) -> bool {
use winit::keyboard::KeyCode as K;
if let Some(mut l) = self.suggestions.take() {
let handled = match key {
K::ArrowUp | K::ArrowDown => {
@@ -1091,37 +1238,18 @@ impl ChatScreen {
}
K::Escape => {
self.input.suggestion = None;
return ChatAction::None;
return true;
}
_ => false,
};
self.suggestions = Some(l);
if handled {
return ChatAction::None;
}
handled
} else if key == K::Tab {
self.show_suggestions(m);
return ChatAction::None;
true
} else {
false
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
// ChatScreen.keyPressed: without `hasAllowedInput` the input
// stays, unsent, and the screen stays open.
if !self.abilities.allows(&self.input.value) {
return ChatAction::None;
}
return ChatAction::Submit(self.normalized());
}
K::ArrowUp => self.move_in_history(m, c, history, -1),
K::ArrowDown => self.move_in_history(m, c, history, 1),
_ => {
if self.input.key(m, key, shift, ctrl) == Some(true) {
self.edited(m, c);
}
}
}
ChatAction::None
}
/// `ChatScreen.moveInHistory`.
fn move_in_history(&mut self, m: &Measure, c: &Commands, history: &[String], dir: isize) {
@@ -1175,7 +1303,7 @@ impl ChatScreen {
if !l.contains(mouse.0, mouse.1) {
return false;
}
let max = l.list.len().saturating_sub(10) as i32;
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
@@ -1246,7 +1374,9 @@ impl ChatScreen {
) -> Vec<Draw> {
let (w, h) = (self.width, self.height);
let mut out = Vec::new();
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
if self.layout.anchor_to_bottom {
d::fill(&mut out, 2, h - 14, w - 2, h - 2, background);
}
let format = |s: &str, o: usize| self.format(s, o);
self.input.draw(&mut out, m, blink_ms, Some(&format));
if let Some(mut l) = self.suggestions.take() {
@@ -1254,14 +1384,18 @@ impl ChatScreen {
self.suggestions = Some(l);
} else {
for (i, line) in self.usage.iter().enumerate() {
let y = h - 27 - 12 * i as i32;
let y = if self.layout.anchor_to_bottom {
h - 27 - 12 * i as i32
} else {
72 + 12 * i as i32
};
d::fill(
&mut out,
self.usage_position - 1,
y,
self.usage_position + self.usage_width + 1,
y + 12,
FILL,
self.layout.fill,
);
d::text(&mut out, line.clone(), self.usage_position, y + 2, -1, true);
}
@@ -1275,7 +1409,7 @@ impl ChatScreen {
l: &mut SuggestionList,
mouse: (i32, i32),
) {
let limit = l.list.len().min(10);
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
@@ -1284,8 +1418,8 @@ impl ChatScreen {
l.last_mouse = mouse;
}
if has_prev || has_next {
d::fill(out, rx, ry - 1, rx + rw, ry, FILL);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, FILL);
d::fill(out, rx, ry - 1, rx + rw, ry, self.layout.fill);
d::fill(out, rx, ry + rh, rx + rw, ry + rh + 1, self.layout.fill);
if has_prev {
for x in (0..rw).step_by(2) {
d::fill(out, rx + x, ry - 1, rx + x + 1, ry, -1);
@@ -1300,7 +1434,7 @@ impl ChatScreen {
let mut hovered = false;
for i in 0..limit {
let y = ry + 12 * i as i32;
d::fill(out, rx, y, rx + rw, y + 12, FILL);
d::fill(out, rx, y, rx + rw, y + 12, self.layout.fill);
if mouse.0 > rx && mouse.0 < rx + rw && mouse.1 > y && mouse.1 < y + 12 {
if moved {
self.select(l, (i + l.offset) as isize);
@@ -74,9 +74,28 @@ pub enum BlockEntity {
/// `DaylightDetectorBlockEntity`: no data, only the ticker
/// (`DaylightDetectorBlock.tickEntity`).
DaylightDetector,
/// Suspicious sand and gravel: saved loot and the transient brushed face.
Brushable(Box<Brushable>),
/// A shelf holds three displayed item stacks.
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
}
/// `BrushableBlockEntity`: the loot table is resolved on the first brush.
#[derive(Clone, Debug, Default)]
pub struct Brushable {
pub loot_table: Option<horizon_world::nbt::Tag>,
pub loot_seed: Option<horizon_world::nbt::Tag>,
pub item: Option<ItemStack>,
/// Direction's legacy network id; not saved to disk by vanilla.
pub hit_direction: Option<i32>,
}
/// `ShelfBlockEntity`'s displayed slots and alignment flag.
#[derive(Clone, Debug, Default)]
pub struct Shelf {
pub items: [Option<ItemStack>; 3],
pub align_to_bottom: bool,
}
/// `ChiseledBookShelfBlockEntity`: six slots and `lastInteractedSlot`.
#[derive(Clone, Debug, PartialEq)]
pub struct ChiseledBookshelf {
+42 -5
View File
@@ -2,8 +2,8 @@
//! kinds, ticking as `AbstractFurnaceBlockEntity.serverTick` with fuel times
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Campfire, ChiseledBookshelf, Crafter, CreakingHeart, EndGateway,
Furnace, FurnaceKind, Jukebox, Lectern, Skull, SkullSkin,
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -230,6 +230,14 @@ impl BlockEntity {
recipes_used: HashMap::new(),
}));
}
if matches!(
name,
"minecraft:suspicious_sand"
| "minecraft:suspicious_gravel"
| "minecraft:brushable_block"
) {
return Some(Self::Brushable(Box::default()));
}
if name == "minecraft:creaking_heart" {
return Some(Self::CreakingHeart(Box::default()));
}
@@ -292,6 +300,9 @@ impl BlockEntity {
if name == "minecraft:campfire" || name == "minecraft:soul_campfire" {
return Some(Self::Campfire(Box::default()));
}
if name == "minecraft:shelf" || name.ends_with("_shelf") {
return Some(Self::Shelf(Box::default()));
}
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
@@ -334,8 +345,10 @@ impl BlockEntity {
Self::Skull(_) => "minecraft:skull",
Self::EndGateway(_) => "minecraft:end_gateway",
Self::DaylightDetector => "minecraft:daylight_detector",
Self::Brushable(_) => "minecraft:brushable_block",
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -354,13 +367,15 @@ impl BlockEntity {
| Self::CommandBlock(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => Vec::new(),
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
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(),
Self::ChiseledBookshelf(b) => b.items.iter().flatten().cloned().collect(),
Self::Shelf(b) => b.items.iter().flatten().cloned().collect(),
}
}
pub fn slots(&self) -> &[Option<ItemStack>] {
@@ -376,9 +391,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &[],
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -402,9 +419,11 @@ impl BlockEntity {
| Self::Banner(_)
| Self::EndGateway(_)
| Self::Skull(_)
| Self::DaylightDetector => &mut [],
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -486,6 +505,24 @@ pub fn create_experience(
xp
}
/// The `MenuType` the block entity of a one-block storage container opens
/// (`createMenu`): `ChestMenu.threeRows` for chests, trapped and copper chests
/// and barrels, `ShulkerBoxMenu` for shulker boxes of any colour (its
/// `ShulkerBoxSlot`s refuse shulker boxes), `DispenserMenu` for dispensers
/// and droppers, `HopperMenu` for hoppers. None for other blocks.
pub fn block_menu_type(block: &str) -> Option<&'static str> {
Some(match block {
"minecraft:chest" | "minecraft:trapped_chest" | "minecraft:barrel" => {
"minecraft:generic_9x3"
}
n if n.ends_with("copper_chest") => "minecraft:generic_9x3",
n if n.ends_with("shulker_box") => "minecraft:shulker_box",
"minecraft:dispenser" | "minecraft:dropper" => "minecraft:generic_3x3",
"minecraft:hopper" => "minecraft:hopper",
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
+27 -10
View File
@@ -72,9 +72,8 @@ pub struct Extras {
pub zoom_smooth: bool,
/// Divide mouse turning by the zoom.
pub zoom_sensitivity: bool,
/// The FPS counter and keystrokes extras are official mods now
/// (`moved_extras`): their old settings stay here until the game has
/// installed the mod for a player who had one on.
/// The FPS counter and keystrokes extras are official mods now: their
/// old settings stay here, kept as they were.
pub fps: bool,
pub fps_corner: Corner,
/// Also show the minimum and 1% low.
@@ -83,8 +82,8 @@ pub struct Extras {
pub keystrokes_corner: Corner,
/// Clicks per second under the mouse buttons.
pub keystrokes_cps: bool,
/// The official mods installed for the extras they replace, by mod id
/// (`movedToMods`): each is done once.
/// The official mods once installed for the extras they replace, by mod
/// id (`movedToMods`).
pub moved: Vec<String>,
pub minimap: bool,
pub minimap_corner: Corner,
@@ -97,6 +96,8 @@ pub struct Extras {
pub minimap_rotate: bool,
/// Retry lost multiplayer connections (`reconnect`).
pub auto_reconnect: bool,
/// The player's own name tag over its model in third person.
pub own_name_tag: bool,
/// Mouse Tweaks-style drags in the inventory and storage containers.
pub mouse_tweaks: bool,
/// With them, the wheel over a slot moves its items one at a time.
@@ -137,6 +138,7 @@ impl Default for Extras {
minimap_zoom: 1.0,
minimap_rotate: false,
auto_reconnect: false,
own_name_tag: false,
mouse_tweaks: false,
mouse_tweaks_wheel: true,
sort: false,
@@ -211,6 +213,7 @@ impl Extras {
}
"minimapRotate" => e.minimap_rotate = flag(e.minimap_rotate),
"autoReconnect" => e.auto_reconnect = flag(e.auto_reconnect),
"ownNameTag" => e.own_name_tag = flag(e.own_name_tag),
"mouseTweaks" => e.mouse_tweaks = flag(e.mouse_tweaks),
"mouseTweaksWheel" => e.mouse_tweaks_wheel = flag(e.mouse_tweaks_wheel),
"sort" => e.sort = flag(e.sort),
@@ -225,7 +228,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -251,6 +254,7 @@ impl Extras {
self.minimap_zoom,
bool_text(self.minimap_rotate),
bool_text(self.auto_reconnect),
bool_text(self.own_name_tag),
bool_text(self.mouse_tweaks),
bool_text(self.mouse_tweaks_wheel),
bool_text(self.sort),
@@ -282,7 +286,7 @@ impl Extras {
}
/// The field-of-view modifier target `Camera.tickFov` eases toward: vanilla's
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`render.set-zoom`). A
/// (`getFieldOfViewModifier`) divided by a mod's zoom (`native_zoom`). A
/// zoom of 1 leaves it as is.
pub fn fov_target(vanilla: f32, zoom: f32) -> f32 {
if zoom == 1.0 { vanilla } else { vanilla / zoom }
@@ -378,6 +382,7 @@ impl Cps {
pub enum Item {
Fullbright,
DynamicLights,
OwnNameTag,
AutoReconnect,
MouseTweaks,
MouseTweaksWheel,
@@ -396,6 +401,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("Lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Ok(OwnNameTag),
Err("Inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
@@ -410,6 +417,7 @@ impl Item {
match self {
Item::Fullbright => "Fullbright",
Item::DynamicLights => "Dynamic lights",
Item::OwnNameTag => "Own name tag",
Item::AutoReconnect => "Auto-reconnect",
Item::MouseTweaks => "Mouse Tweaks controls",
Item::MouseTweaksWheel => "Scroll wheel moves items",
@@ -426,7 +434,7 @@ impl Item {
Some(match self {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | MouseTweaks | Sort | AutoReconnect | NoAi
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
return None;
}
@@ -439,6 +447,9 @@ impl Item {
Item::DynamicLights => {
"Torches and other light sources held, dropped or on fire light the blocks around them."
}
Item::OwnNameTag => {
"Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible."
}
Item::AutoReconnect => {
"Rejoins a server after a lost connection or a restart, never after a kick or ban."
}
@@ -471,7 +482,9 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::AutoReconnect | Item::MouseTweaks | Item::Sort => Some(Cost::None),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
_ => None,
}
}
@@ -480,6 +493,7 @@ impl Item {
Some(match self {
Item::Fullbright => e.fullbright,
Item::DynamicLights => e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
@@ -506,6 +520,7 @@ impl Item {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
Item::DynamicLights => e.dynamic_lights = !e.dynamic_lights,
Item::OwnNameTag => e.own_name_tag = !e.own_name_tag,
Item::AutoReconnect => e.auto_reconnect = !e.auto_reconnect,
Item::MouseTweaks => e.mouse_tweaks = !e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel = !e.mouse_tweaks_wheel,
@@ -534,7 +549,7 @@ mod tests {
let d = Extras::default();
assert!(!d.fullbright && !d.dynamic_lights);
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag);
assert!(d.discord_presence);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
@@ -558,6 +573,8 @@ mod tests {
e.minimap_rotate = true;
e.minimap_corner = Corner::BottomLeft;
e.auto_reconnect = true;
Item::OwnNameTag.cycle(&mut e, false);
assert_eq!(Item::OwnNameTag.switch(&e), Some(true));
e.mouse_tweaks = true;
e.mouse_tweaks_wheel = false;
e.sort = true;
+3
View File
@@ -896,6 +896,9 @@ impl Font {
std::mem::take(&mut self.cache.borrow_mut().region.pending)
}
pub fn advance(&self, c: char, style: &Style) -> f32 {
if c == '\u{fffc}' && style.inline_object.is_some() {
return 8.0;
}
let g = self.glyph_styled(c, style);
g.advance + if style.is_bold() { g.offset } else { 0.0 }
}
+234 -22
View File
@@ -357,6 +357,14 @@ struct BorderUniform {
color: [f32; 4],
offset: [f32; 4],
}
/// A frame to save as a PNG: F2's screenshot, an agent's `game_screenshot`,
/// or the icon the Select World list shows.
pub struct ScreenshotRequest {
pub path: PathBuf,
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -384,6 +392,11 @@ pub struct Renderer {
pub last_wait: std::time::Duration,
/// CPU time of the last `render`, waits excluded.
pub last_encode: std::time::Duration,
/// What the system says about the window's display, watched only while HDR output
/// is on; before `window`, so it's dropped first.
display: DisplayWatch,
/// The watch was just made: the settings' Auto values take what it reported.
display_fresh: bool,
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
@@ -581,8 +594,8 @@ pub struct Renderer {
pub section_draw_calls: usize,
pub uploaded_bytes: u64,
started: Instant,
/// Write the next presented frame to this PNG path (control socket).
pub screenshot: Option<PathBuf>,
/// The next frame to write to a PNG path (control socket, F2, world icon).
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Brightness option (lightmap gamma), 0..1.
@@ -1174,6 +1187,7 @@ impl Renderer {
}
surface.configure(&device, &config);
let hdr_output = hdr::output(&caps);
crate::graphics::offer_hdr(hdr_output.is_some());
if previous.is_none() {
log::info!(
"Horizon renderer: HDR output {}",
@@ -2586,6 +2600,8 @@ impl Renderer {
));
}
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
instance,
adapter,
surface,
@@ -3299,6 +3315,7 @@ impl Renderer {
paper_white: f32,
peak: f32,
black: f32,
ui: f32,
marker: &std::path::Path,
) {
let output = self.hdr_output.filter(|_| on && self.surface.is_some());
@@ -3311,6 +3328,16 @@ impl Renderer {
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
// Only HDR output asks the system about the display, so with HDR off the
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) = DisplayWatch::new(&self.window);
crate::graphics::report(now);
self.display_fresh = true;
watch
} else {
DisplayWatch::default()
};
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.configure_surface();
@@ -3323,6 +3350,9 @@ impl Renderer {
None => log::info!("Horizon renderer: HDR off"),
}
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = ui;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
@@ -3332,6 +3362,20 @@ impl Renderer {
self.send_hdr_metadata();
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(now) = self.display.changed() else {
return false;
};
crate::graphics::report(now);
true
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
let taa = taa && self.world_post.supports_taa();
@@ -5219,6 +5263,10 @@ impl Renderer {
((world_quads + additive_quads) * 4) as i32,
0..1,
);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder);
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
@@ -5227,6 +5275,13 @@ impl Renderer {
&self.entity_group,
&self.indices,
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5330,22 +5385,10 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
let readback = self.screenshot.take().and_then(|path| {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
Some((
path,
copy_frame(&self.device, &mut encoder, &target_texture, size),
))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
let readback = readback.or_else(|| {
self.screenshot
.take()
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
});
self.uploads.finish(&encoder);
if has_section_copies {
@@ -5376,9 +5419,9 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
if let Some((path, buffer)) = readback {
if let Some((request, buffer)) = readback {
self.screenshot_result = Some(
self.write_screenshot(&path, &buffer)
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
@@ -5464,7 +5507,32 @@ fn copy_frame(
buffer
}
impl Renderer {
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
&mut self,
request: ScreenshotRequest,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
None
}
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
@@ -5499,8 +5567,18 @@ impl Renderer {
}
}
buffer.unmap();
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
@@ -5510,6 +5588,42 @@ impl Renderer {
Ok([width, height])
}
}
/// Centre-crop the frame to a square and average it down to `size`.
pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u32; 2]) -> Vec<u8> {
let (side, cx, cy) = if width > height {
(height, (width - height) / 2, 0)
} else {
(width, 0, (height - width) / 2)
};
let [tw, th] = size;
let mut out = vec![0u8; (tw * th * 4) as usize];
for ty in 0..th {
let y0 = cy + ty * side / th;
let y1 = cy + (ty + 1) * side / th;
for tx in 0..tw {
let x0 = cx + tx * side / tw;
let x1 = cx + (tx + 1) * side / tw;
let (mut r, mut g, mut b) = (0u32, 0u32, 0u32);
for y in y0..y1 {
for x in x0..x1 {
let p = ((y * width + x) * 4) as usize;
r += u32::from(pixels[p]);
g += u32::from(pixels[p + 1]);
b += u32::from(pixels[p + 2]);
}
}
let count = (x1 - x0) * (y1 - y0);
let o = ((ty * tw + tx) * 4) as usize;
out[o..o + 4].copy_from_slice(&[
(r / count) as u8,
(g / count) as u8,
(b / count) as u8,
255,
]);
}
}
out
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -6040,3 +6154,101 @@ mod sprite_upload_tests {
}
}
}
/// What the system says about the window's display, kept up to date: on Wayland, the
/// compositor's preferred image description of the surface.
#[derive(Default)]
struct DisplayWatch {
#[cfg(target_os = "linux")]
wayland: Option<horizon_hdr::wayland::Watch>,
}
impl DisplayWatch {
/// Starts watching, with what the system reports now.
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
#[cfg(target_os = "linux")]
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
let watch = Self {
wayland: Some(wayland),
};
return (watch, now.map(reported));
}
let _ = window;
(Self::default(), None)
}
/// What the system reports, if that changed since the last call.
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(|now| now.map(reported));
}
None
}
}
/// The log of what the system reports about the display: one line per change. The
/// renderer is made anew a few times while the game starts and each asks again, and a
/// slider dragged in the desktop's settings changes the values many times a second, so
/// a change within [`DISPLAY_LOG_QUIET`] of the last line waits until the values have
/// held that long, and only the one they settled on is logged.
struct DisplayLog {
last: String,
at: Option<std::time::Instant>,
pending: Option<(String, std::time::Instant)>,
}
static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(DisplayLog {
last: String::new(),
at: None,
pending: None,
});
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
fn log_display(line: String) {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if line == log.last {
log.pending = None;
} else if log.at.is_none_or(|at| now - at >= DISPLAY_LOG_QUIET) {
log::info!("{line}");
(log.last, log.at, log.pending) = (line, Some(now), None);
} else {
log.pending = Some((line, now));
}
}
/// Logs a change [`log_display`] held back, once the values have held for
/// [`DISPLAY_LOG_QUIET`].
fn flush_display_log() {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some((_, since)) = &log.pending
&& now - *since >= DISPLAY_LOG_QUIET
&& let Some((line, _)) = log.pending.take()
{
log::info!("{line}");
(log.last, log.at) = (line, Some(now));
}
}
/// What a compositor's luminances mean in HDR output's scale.
///
/// KWin maps content's reference white (203 nits in PQ, BT.2408) onto its own, so the
/// desktop's white is 203 there and the peak is the target's maximum over that ratio.
/// Checked on KWin. Hyprland (0.57) reports the standard 203, and shows HDR windows below
/// the SDR white of its desktop (which it doesn't report), so Auto can't match its desktop
/// there. Other compositors are unchecked. The peak is kept within the settings' lowest and
/// PQ's top, so the metadata never ends below the black level.
#[cfg(target_os = "linux")]
fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
let peak = (l.target_max * 203.0 / l.reference.max(1.0)).round() as u32;
log_display(format!(
"Horizon renderer: the compositor shows this window with reference white {} nits, \
{}..{} nits: peak {peak} nits in HDR output's scale",
l.reference, l.target_min, l.target_max,
));
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
}
}
+107 -2
View File
@@ -63,6 +63,12 @@ struct Targets {
pub struct Hdr {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
clear_alpha: wgpu::RenderPipeline,
/// Scales the frame's alpha down to 1/255 where the hand drew (`mark_hand`), so HDR
/// output keeps the hand at the world's brightness.
hand_mark: wgpu::RenderPipeline,
params: wgpu::Buffer,
pub output: Output,
/// The frame's format: what every other pipeline draws in.
@@ -75,6 +81,8 @@ pub struct Hdr {
pub peak: f32,
/// The display's black level, in nits: shadows are lifted onto it (`hdr.wgsl`).
pub black: f32,
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
}
impl Hdr {
@@ -140,6 +148,48 @@ impl Hdr {
multiview_mask: None,
cache: None,
});
let alpha_pass = |label, blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&device.create_pipeline_layout(&Default::default())),
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_clear_alpha"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend,
write_mask: wgpu::ColorWrites::ALPHA,
})],
}),
multiview_mask: None,
cache: None,
})
};
let clear_alpha = alpha_pass("Horizon HDR UI mask", None);
// Alpha times the blend constant (1/255): the hand's 1 becomes 1/255, the world's 0
// stays 0.
let scale_alpha = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::Constant,
operation: wgpu::BlendOperation::Add,
};
let hand_mark = alpha_pass(
"Horizon HDR hand mark",
Some(wgpu::BlendState {
color: scale_alpha,
alpha: scale_alpha,
}),
);
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 32,
@@ -149,6 +199,8 @@ impl Hdr {
Self {
layout,
pipeline,
clear_alpha,
hand_mark,
params,
output,
format,
@@ -157,6 +209,7 @@ impl Hdr {
paper_white: 200.0,
peak: 1000.0,
black: 0.0,
ui: 1.0,
}
}
@@ -213,7 +266,9 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// Keeps the world as drawn so far, before the hand and HUD cover it.
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
pub fn keep_world(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
@@ -223,6 +278,56 @@ impl Hdr {
t.world.as_image_copy(),
t.frame.size(),
);
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR UI mask"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.clear_alpha);
pass.draw(0..3, 0..1);
}
/// After the hand: its pixels' alpha goes down to 1/255, below anything the HUD and
/// menus leave, so HDR output tells the hand (the world's brightness, never lifted)
/// from the UI (UI Brightness).
pub fn mark_hand(&self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &self.targets else {
return;
};
let view = t.frame.create_view(&Default::default());
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon HDR hand mark"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.hand_mark);
let a = 1.0 / 255.0;
pass.set_blend_constant(wgpu::Color {
r: a,
g: a,
b: a,
a,
});
pass.draw(0..3, 0..1);
}
/// Writes the finished frame to the swapchain image `target`.
@@ -244,7 +349,7 @@ impl Hdr {
f32::from(u8::from(self.debug)),
f32::from(u8::from(self.output == Output::Pq)),
self.black.clamp(0.0, paper),
0.0,
self.ui.clamp(0.25, 1.0),
0.0,
];
queue.write_buffer(&self.params, 0, bytemuck::cast_slice(&params));
+86 -8
View File
@@ -2,13 +2,39 @@
//! `horizon-graphics.txt` next to `options.txt` so that file stays exactly
//! vanilla's. Every default reproduces vanilla's rendering; only choices the
//! player makes here depart from it.
use std::{fs, path::Path};
use std::{
fs,
path::Path,
sync::atomic::{AtomicU32, Ordering},
};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Preset {
@@ -285,6 +311,16 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
pub fn offer_hdr(on: bool) {
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -343,13 +379,18 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits.
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
/// white, as the system reports it ([`Graphics::paper_white_nits`]).
pub hdr_paper_white: u32,
/// HDR peak brightness the world's highlights reach, in nits.
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: 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.
@@ -384,9 +425,10 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
hdr: false,
hdr_paper_white: 200,
hdr_peak: 1000,
hdr_paper_white: 0,
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -449,9 +491,22 @@ impl Graphics {
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"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),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(80, 480),
}
}
"hdrPeak" => {
g.hdr_peak = match number(g.hdr_peak) {
0 => 0,
n => n.clamp(200, 2000),
}
}
"hdrBlackLevel" => g.hdr_black_level = number(g.hdr_black_level).min(100),
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -469,7 +524,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:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -500,6 +555,7 @@ impl Graphics {
self.hdr_paper_white,
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
@@ -508,6 +564,22 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
match self.hdr_paper_white {
0 => reported().map_or(203, |r| r.paper_white),
n => n,
}
}
/// The peak the world's highlights reach, in nits: the player's, or on Auto
/// what the system reports, else 1000.
pub fn peak_nits(&self) -> u32 {
match self.hdr_peak {
0 => reported().map_or(1000, |r| r.peak),
n => n,
}
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
@@ -618,12 +690,18 @@ mod tests {
hdr_paper_white: 240,
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -130,8 +130,9 @@ impl Style {
label: &Text,
value: f32,
hover: bool,
active: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
+160 -30
View File
@@ -1,4 +1,4 @@
//! Horizon's graphics settings screens, Simple and Advanced: which settings
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
@@ -14,6 +14,8 @@ use crate::{
pub enum Level {
Simple,
Advanced,
/// HDR output's own settings, opened from Advanced while HDR is on.
Hdr,
}
/// Horizon's own settings (`graphics::Graphics`).
@@ -38,9 +40,12 @@ pub enum H {
Antialiasing,
Upscaling,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
GraphicsApi,
}
@@ -121,6 +126,34 @@ pub fn rows(level: Level) -> Vec<Row> {
);
let mut rows = match level {
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
Level::Hdr => vec![
Row::Header("HDR"),
h(
H::Hdr,
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrBlackLevel,
None,
"Your display's darkest black. 0 for OLED; LCDs glow a little.",
),
h(
H::HdrPeak,
None,
"How bright highlights get. Auto: the peak your system reports.",
),
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Auto: your desktop's white.",
),
h(
H::HdrUiBrightness,
None,
"How bright the HUD and menus are, against paper white.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
@@ -231,25 +264,10 @@ pub fn rows(level: Level) -> Vec<Row> {
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::Hdr,
H::HdrSettings,
None,
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
),
h(
H::HdrBlackLevel,
None,
"HDR: your display's darkest black. 0 for OLED; LCDs glow a little.",
),
h(
H::HdrPeak,
None,
"HDR: how bright the brightest highlights get. Your display's peak.",
),
h(
H::HdrPaperWhite,
None,
"HDR: how bright white paper and menus look. Match your desktop.",
),
h(
H::PresentMode,
None,
@@ -357,9 +375,18 @@ 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),
// Auto, then 80..480 by 10.
H::HdrPaperWhite => Some(match g.hdr_paper_white {
0 => 0.0,
n => (n as f32 - 70.0) / 410.0,
}),
// Auto, then 200..2000 by 50.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
n => (n as f32 - 150.0) / 1850.0,
}),
H::HdrBlackLevel => Some(g.hdr_black_level as f32 / 100.0),
H::HdrUiBrightness => Some((g.hdr_ui_brightness as f32 - 25.0) / 75.0),
_ => None,
}
}
@@ -410,12 +437,23 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
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::HdrSettings => Text::literal("HDR Settings..."),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
format!("Auto ({} nits)", g.paper_white_nits()),
),
n => labelled("Paper White", format!("{n} nits")),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -438,9 +476,20 @@ 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,
H::HdrPaperWhite => {
g.hdr_paper_white = match (v * 41.0).round() as u32 {
0 => 0,
step => 70 + step * 10,
}
}
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
0 => 0,
step => 150 + step * 50,
}
}
H::HdrBlackLevel => g.hdr_black_level = (v * 100.0).round() as u32,
H::HdrUiBrightness => g.hdr_ui_brightness = (25.0 + (v * 15.0).round() * 5.0) as u32,
_ => {}
}
}
@@ -516,6 +565,25 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's screen opens only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
}
match self {
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
_ => None,
}
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -617,6 +685,7 @@ pub enum Action {
SliderDrag,
OpenAdvanced,
OpenSimple,
OpenHdr,
Done,
}
@@ -700,6 +769,17 @@ impl GraphicsUi {
let other = match level {
Level::Simple => Action::OpenAdvanced,
Level::Advanced => Action::OpenSimple,
// One Done, back to Advanced.
Level::Hdr => {
widgets.push(Widget {
x: cx - 100.0,
y: by,
w: 200.0,
target: Target::Button(Action::Done),
list: false,
});
return (widgets, headers, content);
}
};
widgets.push(Widget {
x: cx - 155.0,
@@ -761,12 +841,10 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -776,6 +854,7 @@ impl GraphicsUi {
&label,
value,
hover || self.dragging == Some(item),
live,
),
None => STYLE.button(
batch,
@@ -825,6 +904,7 @@ impl GraphicsUi {
let title = match level {
Level::Simple => "Graphics",
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
@@ -880,7 +960,10 @@ impl GraphicsUi {
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -912,6 +995,53 @@ impl GraphicsUi {
mod tests {
use super::*;
#[test]
fn hdr_settings_open_where_hdr_is_offered() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let at = |ui: &mut GraphicsUi, s: &Settings, level, item| {
let (widgets, _, _) = ui.layout(level, s, size.0, size.1);
let w = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
(w.x + 1.0, w.y + 1.0)
};
let button = at(&mut ui, &s, Level::Advanced, Item::Horizon(H::HdrSettings));
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
ui.click(level, s, point, size, [480, 2000], false, false)
};
crate::graphics::offer_hdr(false);
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
// Not even with HDR saved on, from another computer.
s.horizon.hdr = true;
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
s.horizon.hdr = false;
crate::graphics::offer_hdr(true);
assert_eq!(
click(&mut ui, &mut s, Level::Advanced, button),
[Action::ButtonClick, Action::OpenHdr]
);
// On HDR's screen, the sliders wait for the switch above them.
let peak = at(&mut ui, &s, Level::Hdr, Item::Horizon(H::HdrPeak));
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
s.horizon.hdr = true;
assert_eq!(
click(&mut ui, &mut s, Level::Hdr, peak),
[Action::SliderDrag]
);
let on = |level, item| {
extra_rows(level, &s)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
};
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
for item in [H::HdrBlackLevel, H::HdrPaperWhite, H::HdrUiBrightness] {
assert!(on(Level::Hdr, item) && !on(Level::Advanced, item));
}
}
#[test]
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
let mut s = Settings::default();
+21 -3
View File
@@ -18,6 +18,8 @@ struct Params {
pq: f32,
// The display's black level in nits; 0 leaves the picture as it is.
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
};
@group(0) @binding(2) var<uniform> params: Params;
@@ -80,15 +82,29 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
// Hdr::keep_world: once the world is kept, the frame's alpha starts at 0 again.
@fragment fn fs_clear_alpha() -> @location(0) vec4<f32> {
return vec4(0.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;
let f = textureLoad(frame, at, 0);
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
// menu, by how much.
let covered = f.a > 0.0;
let ui = select(f.a, 0.0, f.a < 1.5 / 255.0);
// 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) {
// (terrain.wgsl, the celestial pipeline): only those shine, and only where
// nothing was drawn over them since: grey text over the white sun still looks
// like the sun darkened, but it raised the frame's alpha.
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75 && !covered
&& shows_world(c, w.rgb) {
// Brightness of the world pixel, before the vignette darkens the frame
// toward its edges: a light source shines the same anywhere on screen.
let wl = pow(max(w.rgb, vec3(0.0)), vec3(2.2));
@@ -103,6 +119,8 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
}
}
}
// The HUD and menus at their own brightness, by how much they cover.
light *= mix(1.0, params.ui, ui);
let scrgb = light * params.paper;
if params.pq > 0.5 {
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
+188 -16
View File
@@ -59,6 +59,8 @@ pub struct HudAtlas {
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Resolved player-profile names and UUIDs for inline head components.
pub object_skins: HashMap<String, String>,
/// Sprites with a 0 < alpha < 255 texel (`SpriteContents.transparency().hasTranslucent`),
/// which puts their particles on vanilla's translucent layer.
pub translucent: HashSet<String>,
@@ -252,6 +254,24 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> Vec<PackedImage> {
out.into_iter().map(|(_, image)| image).collect()
}
impl HudAtlas {
/// Keep a ready profile skin when another source only has its default.
pub fn set_object_skin(&mut self, key: String, skin: String) {
use std::collections::hash_map::Entry;
if self.object_skins.len() >= 1024 && !self.object_skins.contains_key(&key) {
self.object_skins.clear();
}
match self.object_skins.entry(key) {
Entry::Occupied(entry)
if skin.starts_with("entity/player/") && self.sprites.contains_key(entry.get()) => {
}
Entry::Occupied(mut entry) => {
entry.insert(skin);
}
Entry::Vacant(entry) => {
entry.insert(skin);
}
}
}
/// A font glyph's atlas cell (8x8 GUI pixels) and advance.
pub fn glyph(&self, ch: char) -> Option<(Rect, f32)> {
let g = self.font.glyph(ch);
@@ -592,6 +612,7 @@ impl HudAtlas {
width,
height,
sprites,
object_skins: HashMap::new(),
translucent,
flags,
sprite_animations,
@@ -641,6 +662,8 @@ pub struct HudBatch {
pub glint_alpha: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
pub inline_profiles: Vec<serde_json::Value>,
}
/// How a run of HUD vertices blends and tests depth.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -692,6 +715,7 @@ impl HudBatch {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
}
}
/// Draw the vertices added since `start` with the inverting blend.
@@ -1042,22 +1066,43 @@ impl HudBatch {
let mut cursor = x;
let mut previous = None;
for (i, (ch, style)) in text.chars().enumerate() {
let object = (ch == '\u{fffc}')
.then_some(style.inline_object.as_ref())
.flatten();
if let Some(crate::text::InlineObject::Player { profile, .. }) = object
&& self.inline_profiles.len() < 256
&& !self.inline_profiles.contains(profile)
{
self.inline_profiles.push(profile.clone());
}
// Font.PreparedTextBuilder.accept: lines start 1 left of a
// sequence's first character. Language.getVisualOrder makes a
// sequence of each run of one style, which world text has.
let first = i == 0 || world && previous != Some(style);
previous = Some(style);
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
let mut g = if object.is_some() {
crate::font::Glyph {
rect: None,
left: 0.0,
top: 0.0,
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
}
} else {
font.glyph_styled(ch, style)
};
// Font.StringRenderOutput.accept: obfuscated characters draw a
// random glyph of the same width each frame.
if style.obfuscated == Some(true) && ch != ' ' {
if object.is_none() && style.obfuscated == Some(true) && ch != ' ' {
g = font.random_glyph(style, g.advance);
}
let c = style.color.map_or(color, |rgb| {
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
});
let bold = style.is_bold();
let bold = object.is_none() && style.is_bold();
let advance = g.advance + if bold { g.offset } else { 0.0 };
glyphs.push((
cursor,
@@ -1069,6 +1114,7 @@ impl HudBatch {
style.strikethrough == Some(true),
first,
advance,
object.cloned(),
));
cursor += advance;
}
@@ -1086,20 +1132,26 @@ impl HudBatch {
)
};
for &is_shadow in passes {
for &(gx, g, c, bold, italic, underline, strike, first, advance) in &glyphs {
for (gx, g, c, bold, italic, underline, strike, first, advance, object) in &glyphs {
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
// In the world a glyph whose shadow is 0 has none (hasShadow).
let has_shadow = shadow && !(world && shade(c) == 0);
let has_shadow = shadow && !(world && shade(*c) == 0);
if is_shadow && !has_shadow {
continue;
}
let (dx, col) = if is_shadow {
(g.offset, shade(c))
(g.offset, shade(*c))
} else {
(0.0, c)
(0.0, *c)
};
let (px, py) = (gx + dx, y + dx);
let shear = |v: f32| if italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if bold { 0.1 } else { 0.0 };
let (px, py) = (*gx + dx, y + dx);
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
Some(r) => {
let (x0, x1, y0, y1) = (
@@ -1144,24 +1196,86 @@ impl HudBatch {
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
if bold {
if *bold {
let start = self.vertices.len();
draw(self, g.offset);
lift(self, start, base + 0.001);
}
let x0 = if first { px - 1.0 } else { px };
let x0 = if *first { px - 1.0 } else { px };
let start = self.vertices.len();
if strike {
self.rect_uv(x0, py + 3.5, px + advance - x0, 1.0, white, col);
if *strike {
self.rect_uv(x0, py + 3.5, px + *advance - x0, 1.0, white, col);
}
if underline {
self.rect_uv(x0, py + 8.0, px + advance - x0, 1.0, white, col);
if *underline {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
}
}
cursor - x
}
fn draw_inline_object(
&mut self,
atlas: &HudAtlas,
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
let name = inline_profile_name(profile);
let by_id = atlas.object_skins.get(&format!("id:{id:032x}"));
let by_name = name.and_then(|name| {
atlas
.object_skins
.get(&format!("name:{}", name.to_ascii_lowercase()))
});
let resolved = if profile.get("id").is_some() {
by_id.or(by_name)
} else {
by_name.or(by_id)
};
let default = crate::tab_list::default_skin(id);
let skin = resolved
.filter(|path| atlas.sprites.contains_key(*path))
.map_or(default, String::as_str);
let Some(rect) = atlas.sprites.get(skin).copied() else {
return;
};
for u in [8.0, 40.0].into_iter().take(if *hat { 2 } else { 1 }) {
let du = (rect.u1 - rect.u0) / 64.0;
let dv = (rect.v1 - rect.v0) / 64.0;
self.rect_uv(
x,
y,
8.0,
8.0,
Rect {
u0: rect.u0 + du * u,
v0: rect.v0 + dv * 8.0,
u1: rect.u0 + du * (u + 8.0),
v1: rect.v0 + dv * 16.0,
w: 8,
h: 8,
},
tint,
);
}
}
InlineObject::Atlas { sprite, .. } => {
let key = sprite.strip_prefix("minecraft:").unwrap_or(sprite);
if let Some(rect) = atlas.sprites.get(key).copied() {
self.rect_uv(x, y, 8.0, 8.0, rect, tint);
}
}
}
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
@@ -1190,6 +1304,13 @@ impl HudBatch {
behind
}
}
fn inline_profile_name(profile: &serde_json::Value) -> Option<&str> {
profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str))
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct HudUniform {
@@ -1904,6 +2025,56 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn player_object_draws_an_inline_head() {
let root = two_glyph_font("inline-head");
write_png(&root, "entity/player/slim/alex", 64, 64);
write_png(&root, "entity/player/wide/steve", 64, 64);
let mut atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"id":"00000000-0000-0000-0000-000000000000"}}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
assert_eq!(
batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false),
8.0
);
assert_eq!(batch.vertices.len(), 12); // face and hat, six vertices each
assert_eq!(batch.inline_profiles.len(), 1);
atlas.set_object_skin("name:elyapluggin".into(), "entity/player/wide/steve".into());
let named = crate::component::to_text(
&serde_json::json!({"object":"player","player":{"name":"elyapluggin"}}),
&mut |_| crate::text::Text::empty(),
);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn atlas_object_draws_an_item_sprite_inline() {
let root = two_glyph_font("inline-item");
write_png(&root, "item/porkchop", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let text = crate::component::to_text(
&serde_json::json!({
"text": "a",
"extra": [{"object": "atlas", "sprite": "minecraft:item/porkchop"}]
}),
&mut |_| crate::text::Text::empty(),
);
let mut batch = HudBatch::new([320, 240]);
let width = batch.text_styled(&atlas, &text, 10.0, 10.0, u32::MAX, false);
assert_eq!(width, atlas.font.glyph('a').advance + 8.0);
assert_eq!(batch.vertices.len(), 12); // glyph and one sprite
let sprite = atlas.sprites["item/porkchop"];
assert_eq!(batch.vertices[6].uv, [sprite.u0, sprite.v0]);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
@@ -2149,6 +2320,7 @@ mod tests {
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
blur_at: 0,
inline_profiles: Vec::new(),
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
assert_eq!(
+61
View File
@@ -883,6 +883,24 @@ fn relight_layer(
let Some(old) = level(world, sky, p) else {
continue;
};
// `BlockLightEngine.checkNode`: a block emitting at least the light
// already there keeps it and pulls its neighbours' light in. Clearing
// it would darken and relight everything it lit (flowing lava next to
// lava: thousands of cells per block).
if !sky && !force && old > 0 {
let emission = world
.state(p.0, p.1, p.2)
.map_or(0, |s| tables.cells[s as usize].emission);
if emission >= old {
if emission > old {
set_level(world, sky, p, emission);
changed.push(p);
}
decrease.push_back((p, 0));
increase.push_back(p);
continue;
}
}
if old > 0 {
set_level(world, sky, p, 0);
changed.push(p);
@@ -1089,6 +1107,49 @@ mod tests {
assert!(same(&w, (1, 0)));
assert_eq!(block(&w, 14, 0, 8), 13);
}
/// Emitters placed in light at most as bright as theirs (flowing lava
/// next to lava) only brighten: nothing they lit is darkened and lit
/// again, and the result is the full relight's.
#[test]
fn emitters_placed_in_their_own_light_only_brighten() {
let mut w = world(|c| {
c.set(8, 0, 8, TORCH);
});
let block = |w: &HashMap<(i32, i32), Arc<Chunk>>, p: Pos| {
w[&(p.0 >> 4, p.2 >> 4)].light(p.0 & 15, p.1, p.2 & 15) & 15
};
for (x, y, z) in [(9, 0, 8), (10, 0, 8), (10, 1, 8), (11, 1, 9), (8, 0, 9)] {
let around: Vec<Pos> = (-15..=15)
.flat_map(|dx| {
(-3..=3).flat_map(move |dy| (-15..=15).map(move |dz| (x + dx, y + dy, z + dz)))
})
.collect();
let before: Vec<u8> = around.iter().map(|&p| block(&w, p)).collect();
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(x, y, z, TORCH);
let mut changed = LightChanges::default();
relight(&mut w, &tables(), true, &[(x, y, z)], false, &mut changed);
for (&p, &was) in around.iter().zip(&before) {
assert!(block(&w, p) >= was, "{p:?} darkened from {was}");
}
}
// An emitter checked again with its block light unchanged changes
// nothing, so no section is remeshed.
let mut changed = LightChanges::default();
relight(&mut w, &tables(), false, &[(8, 0, 8)], false, &mut changed);
assert!(changed.is_empty());
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| w.get(&(dx as i32 - 1, dz as i32 - 1)).map(|c| &**c))
});
for (si, s) in light_chunk(&region, &tables(), true).iter().enumerate() {
for i in 0..4096 {
assert_eq!(
s.get(i),
w[&(0, 0)].sections[si].light(i),
"section {si} index {i}"
);
}
}
}
#[test]
fn random_border_relights_match_full_relight() {
// Edits around every border of chunk (0, 0), at the top and bottom of
+1 -5
View File
@@ -36,11 +36,8 @@ pub struct Installed {
pub description: String,
/// Why it is not running, when it failed.
pub problem: Option<String>,
/// Loaded with `--dev-mod`: always on, reloaded when rebuilt.
/// Loaded with `--dev-mod`: always on.
pub dev: bool,
/// The running mod set a page of widgets (capability `ui`): its row
/// gains a Settings button.
pub has_settings: bool,
/// A native mod (`game/native_hooks.rs`): built into the game, on until
/// the player turns it off, and its settings page.
pub native: Option<NativePage>,
@@ -178,7 +175,6 @@ mod tests {
description: String::new(),
problem: None,
dev,
has_settings: false,
native: None,
};
let mut list = ModList {
@@ -87,6 +87,9 @@ const MODEL_PARTS: [&str; 7] = [
];
const GRAPHICS_BACKEND: &[&str] = &["default", "opengl", "vulkan"];
const GRAPHICS_PRESET: &[&str] = &["fast", "fancy", "fabulous", "custom"];
/// Horizon's render distance cap (vanilla stops at 32): the server protocol's
/// view distance tops out at 127 anyway.
pub const MAX_RENDER_DISTANCE: i32 = 128;
const INACTIVITY: &[&str] = &["minimized", "afk"];
const CLOUDS: &[&str] = &["false", "fast", "true"];
const MAIN_HAND: &[&str] = &["left", "right"];
@@ -159,7 +162,7 @@ static TABLE: LazyLock<Vec<Def>> = LazyLock::new(|| {
b("reducedDebugInfo", false),
def("renderClouds", O(Clouds), V::Int(2)),
i("cloudRange", 2, 128, 128),
i("renderDistance", 2, 64, 12),
i("renderDistance", 2, MAX_RENDER_DISTANCE, 12),
i("simulationDistance", 5, 32, 12),
u("screenEffectScale", 1.0),
def("soundDevice", O(AnyStr), V::Str(String::new())),
+184 -29
View File
@@ -84,6 +84,27 @@ pub(crate) const ACCESSIBILITY_OPTIONS: &[Opt] = &[
Opt::HighContrastBlockOutlines,
];
/// `ControlsScreen.options`: the controls added by Java Edition 26.3, in
/// their vanilla order. The macOS-only Control-click switch is omitted on
/// other platforms just as `InputQuirks.EMULATE_RIGHT_CLICK_WITH_CTRL_KEY`
/// does in Java.
pub(crate) fn vanilla_control_options() -> Vec<Opt> {
let mut options = vec![
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::ToggleAttack,
Opt::ToggleUse,
Opt::AutoJump,
Opt::SprintWindow,
Opt::QuitShortcuts,
];
if cfg!(target_os = "macos") {
options.push(Opt::CtrlClickEmulatesRightClick);
}
options.push(Opt::OperatorItemsTab);
options
}
/// `MouseSettingsScreen.options` in Java Edition 26.3, in vanilla order.
pub(crate) const MOUSE_OPTIONS: &[Opt] = &[
Opt::Sensitivity,
@@ -120,6 +141,11 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the creative tabs, the quit keys and the mouse
// don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
fn file_key(self) -> Option<&'static str> {
@@ -144,6 +170,10 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::SoundDevice => "soundDevice",
Opt::OperatorItemsTab => "operatorItemsTab",
Opt::QuitShortcuts => "quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "ctrlClickEmulatesRightClick",
Opt::ModelPart(i) => MODEL_PART_KEYS[i],
Opt::AllowServerListing => "allowServerListing",
Opt::RealmsNotifications => "realmsNotifications",
@@ -167,6 +197,7 @@ impl Opt {
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::SoundDevice => "options.audioDevice",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
Opt::Gamma => "options.gamma",
@@ -194,7 +225,13 @@ impl Opt {
Opt::MenuBlur => "options.accessibility.menu_background_blurriness",
Opt::ToggleCrouch => "key.sneak",
Opt::ToggleSprint => "key.sprint",
Opt::ToggleAttack => "key.attack",
Opt::ToggleUse => "key.use",
Opt::AutoJump => "options.autoJump",
Opt::SprintWindow => "options.sprintWindow",
Opt::QuitShortcuts => "options.quitShortcuts",
Opt::CtrlClickEmulatesRightClick => "options.ctrlClickEmulatesRightClick",
Opt::OperatorItemsTab => "options.operatorItemsTab",
Opt::ChatVisibility => "options.chat.visibility",
Opt::ChatColors => "options.chat.color",
Opt::ChatLinks => "options.chat.links",
@@ -278,7 +315,11 @@ impl Opt {
| Opt::GlintStrength
| Opt::PanoramaSpeed => Kind::Slider(s.file.double(self.file_key().unwrap()) as f32),
Opt::Fov => unit(s.fov, 30.0, 110.0),
Opt::RenderDistance => unit(s.render_distance as f32, 2.0, 64.0),
Opt::RenderDistance => unit(
s.render_distance as f32,
2.0,
crate::options_file::MAX_RENDER_DISTANCE as f32,
),
Opt::SimulationDistance => unit(s.simulation_distance as f32, 5.0, 32.0),
Opt::Sensitivity => Kind::Slider(s.sensitivity),
Opt::WheelSensitivity => unit(s.mouse_wheel_sensitivity, 0.01, 10.0),
@@ -308,6 +349,7 @@ impl Opt {
Opt::PadLookSensitivity => Kind::Slider(s.controller.look_sensitivity),
Opt::PadMoveDeadzone => unit(s.controller.move_deadzone, 0.0, 0.5),
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
_ => Kind::Cycle,
}
}
@@ -356,6 +398,14 @@ impl Opt {
}
}
Opt::PanoramaSpeed => percent(c, s.file.double("panoramaScrollSpeed") as f32),
Opt::SprintWindow => {
let value = if s.sprint_window == 0 {
tr("options.off")
} else {
Text::tr("options.value", &[&s.sprint_window])
};
generic(c, value)
}
Opt::Fov => match s.fov as i32 {
70 => generic(c, tr("options.fov.min")),
110 => generic(c, tr("options.fov.max")),
@@ -573,7 +623,20 @@ impl Opt {
} else {
"options.key.hold"
}),
Opt::ToggleAttack => tr(if s.toggle_attack {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::ToggleUse => tr(if s.toggle_use {
"options.key.toggle"
} else {
"options.key.hold"
}),
Opt::AutoJump => on_off(s.auto_jump),
Opt::QuitShortcuts => on_off(s.quit_shortcuts),
Opt::CtrlClickEmulatesRightClick => on_off(s.ctrl_click_emulates_right_click),
Opt::OperatorItemsTab => on_off(s.operator_items_tab),
Opt::ChatVisibility => tr([
"options.chat.visibility.full",
"options.chat.visibility.system",
@@ -586,6 +649,13 @@ impl Opt {
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
Opt::ShowSubtitles => on_off(s.show_subtitles),
Opt::SoundDevice => {
if s.sound_device.is_empty() {
tr("options.audioDevice.default")
} else {
Text::literal(&s.sound_device)
}
}
Opt::MusicFrequency => tr([
"options.music_frequency.default",
"options.music_frequency.frequent",
@@ -647,7 +717,10 @@ impl Opt {
crate::options_file::V::Double(f64::from(v)),
),
Opt::Fov => s.fov = step(30.0, 110.0),
Opt::RenderDistance => s.render_distance = step(2.0, 64.0) as i32,
Opt::RenderDistance => {
s.render_distance =
step(2.0, crate::options_file::MAX_RENDER_DISTANCE as f32) as i32
}
Opt::SimulationDistance => s.simulation_distance = step(5.0, 32.0) as i32,
Opt::Sensitivity => s.sensitivity = v,
Opt::WheelSensitivity => s.mouse_wheel_sensitivity = 0.01 + v * 9.99,
@@ -674,6 +747,7 @@ impl Opt {
Opt::PadLookSensitivity => s.controller.look_sensitivity = v,
Opt::PadMoveDeadzone => s.controller.move_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
_ => {}
}
if self.affects_preset() {
@@ -770,7 +844,14 @@ impl Opt {
Opt::ChunkBuilder => s.prioritize_chunk_updates = next(s.prioritize_chunk_updates, 3),
Opt::ToggleCrouch => s.toggle_crouch = !s.toggle_crouch,
Opt::ToggleSprint => s.toggle_sprint = !s.toggle_sprint,
Opt::ToggleAttack => s.toggle_attack = !s.toggle_attack,
Opt::ToggleUse => s.toggle_use = !s.toggle_use,
Opt::AutoJump => s.auto_jump = !s.auto_jump,
Opt::QuitShortcuts => s.quit_shortcuts = !s.quit_shortcuts,
Opt::CtrlClickEmulatesRightClick => {
s.ctrl_click_emulates_right_click = !s.ctrl_click_emulates_right_click
}
Opt::OperatorItemsTab => s.operator_items_tab = !s.operator_items_tab,
Opt::ChatVisibility => s.chat_visibility = next(s.chat_visibility, 3),
Opt::ChatColors => s.chat_colors = !s.chat_colors,
Opt::ChatLinks => s.chat_links = !s.chat_links,
@@ -779,6 +860,23 @@ impl Opt {
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
Opt::SoundDevice => {
let devices = crate::audio::output_devices();
let mut all = vec![String::new()];
for d in devices {
if !all.contains(&d) {
all.push(d);
}
}
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
let count = all.len();
let next_idx = if back {
(current_idx + count - 1) % count
} else {
(current_idx + 1) % count
};
s.sound_device = all[next_idx].clone();
}
Opt::MusicFrequency => {
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
s.file
@@ -921,6 +1019,7 @@ pub enum Page {
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -955,8 +1054,7 @@ enum Act {
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
/// The installed native mod's settings page.
ModSettings(usize),
/// A native mod's key (mod, key) waits for a key; a button of its page.
NativeKey(usize, usize),
@@ -1023,9 +1121,6 @@ pub enum Event {
/// A shader pack was picked or turned off: save and apply it.
ShaderPack,
OpenShaderPackFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1295,7 +1390,10 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
}
}
@@ -1304,6 +1402,7 @@ impl OptionsUi {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
_ => None,
}
}
@@ -1325,7 +1424,7 @@ impl OptionsUi {
Page::Mods => "Mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
@@ -1580,6 +1679,7 @@ impl OptionsUi {
rows.extend(pairs(&others));
rows.extend(pairs(&[Opt::ShowSubtitles, Opt::DirectionalAudio]));
rows.extend(pairs(&[Opt::MusicFrequency, Opt::MusicToast]));
rows.push(Row::Big(Opt::SoundDevice));
rows
}
Page::Skin => {
@@ -1615,11 +1715,8 @@ impl OptionsUi {
Act::Push(Page::KeyBinds),
)),
)];
rows.extend(pairs(&[
Opt::ToggleCrouch,
Opt::ToggleSprint,
Opt::AutoJump,
]));
let controls = vanilla_control_options();
rows.extend(pairs(&controls));
// Horizon's controller page, after vanilla's rows.
rows.push(Row::Buttons(
button(
@@ -1714,9 +1811,9 @@ impl OptionsUi {
label = label.append(" (Dev)");
}
let dead = installed.dev || installed.id.is_empty();
// A running mod with a settings page (capability
// `ui`) gets its own button beside the toggle.
if installed.has_settings || installed.native.is_some() {
// A native mod gets its settings page's button beside
// the toggle.
if installed.native.is_some() {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
@@ -2587,6 +2684,7 @@ impl OptionsUi {
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -2772,12 +2870,8 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
events.push(Event::ButtonClick);
if s.mods.installed.get(i).is_some_and(|m| m.native.is_some()) {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
} else {
events.push(Event::ModSettings(i));
}
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
@@ -3223,10 +3317,48 @@ mod tests {
)));
}
#[test]
fn controls_match_java_26_3_option_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Controls);
let options =
ui.list_rows(&Settings::default())
.into_iter()
.fold(Vec::new(), |mut options, row| {
match row {
Row::Big(opt) => options.push(opt),
Row::Pair(a, b) => {
options.push(a);
options.extend(b);
}
_ => {}
}
options
});
assert_eq!(options, vanilla_control_options());
}
#[test]
fn sprint_window_and_new_control_switches_round_trip() {
let mut settings = Settings::default();
set_slider(Opt::SprintWindow, &mut settings, 0.5);
settings.operator_items_tab = true;
settings.quit_shortcuts = false;
settings.ctrl_click_emulates_right_click = true;
settings.toggle_attack = true;
settings.toggle_use = true;
let loaded = Settings::parse(&settings.serialize());
assert_eq!(loaded.sprint_window, 5);
assert!(loaded.operator_items_tab);
assert!(!loaded.quit_shortcuts);
assert!(loaded.ctrl_click_emulates_right_click);
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn render_distance_slider_round_trips_through_extended_options_range() {
let mut s = Settings::default();
for (position, distance) in [(0.0, 2), (0.5, 33), (1.0, 64)] {
for (position, distance) in [(0.0, 2), (0.5, 65), (1.0, 128)] {
set_slider(Opt::RenderDistance, &mut s, position);
assert_eq!(s.render_distance, distance);
let reloaded = Settings::parse(&s.serialize());
@@ -3237,10 +3369,10 @@ mod tests {
);
}
let mut file = crate::options_file::OptionsFile::default();
file.set_text("renderDistance", "64").unwrap();
assert!(file.set_text("renderDistance", "65").is_err());
file.set_text("renderDistance", "128").unwrap();
assert!(file.set_text("renderDistance", "129").is_err());
assert!(file.set_text("renderDistance", "1").is_err());
assert_eq!(file.int("renderDistance"), 64);
assert_eq!(file.int("renderDistance"), 128);
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
@@ -3348,7 +3480,6 @@ mod tests {
description: "May show HUD panels.".into(),
problem: id.is_empty().then(|| "Invalid package".into()),
dev,
has_settings: false,
native: None,
});
}
@@ -3416,7 +3547,6 @@ mod tests {
description: String::new(),
problem: None,
dev: false,
has_settings: false,
native: Some(crate::mod_list::NativePage::default()),
});
ui.show(Page::Mods);
@@ -4490,4 +4620,29 @@ mod tests {
ui.stack = vec![Page::Main, Page::Controls];
assert!(pushes(&mut ui).contains(&Page::Controller));
}
#[test]
fn sound_device_button_in_sound_page_and_cycles() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let rows = ui.list_rows(&s);
assert!(
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
"Sound page should contain Row::Big(Opt::SoundDevice)"
);
assert_eq!(s.sound_device, "");
let devices = crate::audio::output_devices();
let count = 1 + devices.len();
// Cycling forward 'count' times wraps around back to the initial selection ("")
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
}
assert_eq!(s.sound_device, "");
// Cycling backward 'count' times also wraps around back to ""
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
}
assert_eq!(s.sound_device, "");
}
}
@@ -3,7 +3,6 @@
use super::{Pack, Source, build_overlay_uncached};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[cfg(any(test, not(unix)))]
use std::io::Read;
use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
@@ -60,7 +59,6 @@ fn field(hash: &mut Sha256, bytes: &[u8]) {
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
#[cfg(any(test, not(unix)))]
fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
let mut file = fs::File::open(path)?;
hash.update(file.metadata()?.len().to_le_bytes());
@@ -238,7 +236,10 @@ fn pack_signature(packs: &[Pack]) -> io::Result<String> {
}
Source::Zip(path) => {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
stamp(&mut hash, path)?;
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
// time rebuilt the whole overlay each time. One file reads in a moment.
content(&mut hash, path)?;
}
}
}
@@ -669,6 +670,38 @@ mod tests {
);
}
/// A ZIP whose bytes are the same keeps its overlay when only its time changes: a server
/// pack found in the download cache is marked used on every join
/// (`HttpUtil.updateModificationTime`), and rebuilding the overlay took most of a minute.
#[test]
fn a_zip_touched_but_unchanged_keeps_its_overlay() {
let f = Fixture::new();
f.write("vanilla/assets/minecraft/textures/b", b"vanilla");
let files = vec![
(
"pack.mcmeta",
br#"{"pack":{"description":"test","pack_format":97}}"#.to_vec(),
),
("assets/minecraft/textures/a", b"aaa".to_vec()),
];
f.write("pack.zip", &archive(&files, false));
let zip = f.0.join("pack.zip");
let pack = packs::pack("zip".into(), Text::empty(), Source::Zip(zip.clone())).unwrap();
let (vanilla, out) = (f.0.join("vanilla"), f.0.join("cached"));
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
// A rebuild empties the output first: this file survives a cache hit only.
fs::write(out.join("kept"), b"").unwrap();
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::OpenOptions::new()
.append(true)
.open(&zip)
.unwrap()
.set_modified(later)
.unwrap();
packs::build_overlay(&vanilla, std::slice::from_ref(&pack), &out).unwrap();
assert!(out.join("kept").exists(), "the overlay was rebuilt");
}
#[cfg(unix)]
#[test]
fn unrelated_directory_pack_symlink_cycle_disables_reuse() {
@@ -101,6 +101,8 @@ pub struct Settings {
pub master_volume: f32,
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
pub volumes: [f32; 11],
/// Empty for system default, or a specific audio device name.
pub sound_device: String,
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
pub gui_scale: u32,
pub view_bobbing: bool,
@@ -174,6 +176,12 @@ pub struct Settings {
pub toggle_use: bool,
/// Taps of the sprint key counted as double taps (sprintWindow).
pub sprint_window: i32,
/// Show the operator items tab in a creative inventory.
pub operator_items_tab: bool,
/// Allow platform quit shortcuts to close the game window.
pub quit_shortcuts: bool,
/// On macOS, turn Control-click into a right click.
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
@@ -195,6 +203,7 @@ impl Default for Settings {
sensitivity: 0.5,
master_volume: 1.0,
volumes: [1.0; 11],
sound_device: String::new(),
gui_scale: 0,
view_bobbing: true,
gamma: 0.5,
@@ -256,6 +265,9 @@ impl Default for Settings {
toggle_attack: false,
toggle_use: false,
sprint_window: 7,
operator_items_tab: false,
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -383,6 +395,7 @@ impl Settings {
sensitivity: f.double("mouseSensitivity") as f32,
master_volume: volumes[0],
volumes,
sound_device: f.string("soundDevice"),
gui_scale: f.int("guiScale").max(0) as u32,
view_bobbing: f.bool("bobView"),
gamma: f.double("gamma") as f32,
@@ -418,6 +431,9 @@ impl Settings {
toggle_attack: f.bool("toggleAttack"),
toggle_use: f.bool("toggleUse"),
sprint_window: f.int("sprintWindow"),
operator_items_tab: f.bool("operatorItemsTab"),
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
controller: Default::default(),
extras: Default::default(),
@@ -475,6 +491,7 @@ impl Settings {
};
put_f(&mut f, &format!("soundCategory_{source}"), v);
}
put_s(&mut f, "soundDevice", &self.sound_device);
put_i(&mut f, "guiScale", self.gui_scale as i32);
put_b(&mut f, "bobView", self.view_bobbing);
put_f(&mut f, "gamma", self.gamma);
@@ -518,6 +535,13 @@ impl Settings {
put_b(&mut f, "toggleAttack", self.toggle_attack);
put_b(&mut f, "toggleUse", self.toggle_use);
put_i(&mut f, "sprintWindow", self.sprint_window);
put_b(&mut f, "operatorItemsTab", self.operator_items_tab);
put_b(&mut f, "quitShortcuts", self.quit_shortcuts);
put_b(
&mut f,
"ctrlClickEmulatesRightClick",
self.ctrl_click_emulates_right_click,
);
put_b(&mut f, "autoJump", self.auto_jump);
put_s(&mut f, "lang", &self.language);
put_b(&mut f, "forceUnicodeFont", self.force_unicode);
@@ -637,6 +661,7 @@ pub enum Opt {
InvertY,
AllowCursorChanges,
Volume(usize),
SoundDevice,
GuiScale,
ViewBobbing,
Gamma,
@@ -664,7 +689,13 @@ pub enum Opt {
MenuBlur,
ToggleCrouch,
ToggleSprint,
ToggleAttack,
ToggleUse,
AutoJump,
SprintWindow,
QuitShortcuts,
CtrlClickEmulatesRightClick,
OperatorItemsTab,
ChatVisibility,
ChatColors,
ChatLinks,
@@ -820,4 +851,15 @@ mod tests {
1.0
);
}
#[test]
fn sound_device_option() {
let s = Settings::parse("soundDevice:\"Headphones\"\n");
assert_eq!(s.sound_device, "Headphones");
let serialized = s.serialize();
assert!(serialized.contains("soundDevice:\"Headphones\""));
let again = Settings::parse(&serialized);
assert_eq!(again.sound_device, "Headphones");
let empty = Settings::parse("");
assert_eq!(empty.sound_device, "");
}
}
+91 -24
View File
@@ -13,11 +13,10 @@ use horizon_core::biome::BiomeZoom;
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet},
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, RwLock,
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
};
@@ -220,11 +219,52 @@ struct Shared {
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
/// half way through a send, and a worker descheduled there on a busy machine
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
/// flight's 1% lows). The frame takes what is there without waiting; while a
/// worker holds the lock, it takes it the next frame.
#[derive(Default)]
struct Mailbox {
done: Mutex<VecDeque<Done>>,
arrived: Condvar,
}
impl Mailbox {
fn send(&self, done: Done) {
self.done.lock().unwrap().push_back(done);
self.arrived.notify_one();
}
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
match self.done.try_lock() {
Ok(done) => Some(done),
Err(TryLockError::WouldBlock) => None,
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
}
}
/// Every finished job, or none while a worker is sending.
fn take(&self) -> VecDeque<Done> {
self.try_lock()
.map(|mut done| std::mem::take(&mut *done))
.unwrap_or_default()
}
fn try_recv(&self) -> Option<Done> {
self.try_lock()?.pop_front()
}
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
let done = self.done.lock().unwrap();
let (mut done, _) = self
.arrived
.wait_timeout_while(done, timeout, |done| done.is_empty())
.unwrap();
done.pop_front()
}
}
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
results: mpsc::Receiver<Done>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -461,14 +501,13 @@ impl Streaming {
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
});
let (send, results) = mpsc::channel();
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let send = send.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
@@ -551,9 +590,7 @@ impl Streaming {
Done::Meshed(x, sy, z, mesh, seq)
}
};
if send.send(done).is_err() {
return;
}
shared.done.send(done);
}
})
.expect("spawn worker")
@@ -566,7 +603,6 @@ impl Streaming {
Self {
shared,
light_view,
results,
threads,
order: 0,
scan_age: 0,
@@ -930,10 +966,10 @@ impl Streaming {
self.requeue();
self.relight_edits(world);
let deferred = std::mem::take(&mut self.deferred);
let received = std::iter::from_fn(|| self.results.try_recv().ok());
let received = self.shared.done.take();
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
let mut stale = Vec::new();
for done in deferred.into_iter().chain(received.collect::<Vec<_>>()) {
for done in deferred.into_iter().chain(received) {
match done {
Done::Proto(x, z, proto, decorated) => {
self.generation_scan.exhausted = false;
@@ -1444,12 +1480,12 @@ impl Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Ok(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Ok(Done::Saved(protos)) => self.receive_saved(protos),
Ok(other) => self.deferred.push(other),
Err(_) => {
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region)) => self.receive_decorated(x, z, *region),
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
break;
}
@@ -1471,7 +1507,7 @@ impl Streaming {
/// in `save(true)`, so older generation cannot overwrite the final chunks.
pub fn poll_save(&mut self, world: &mut World) -> bool {
let started = std::time::Instant::now();
while let Ok(done) = self.results.try_recv() {
while let Some(done) = self.shared.done.try_recv() {
match done {
Done::Decorated(x, z, region) => self.receive_decorated(x, z, *region),
Done::Generated(chunk) => self.receive_generated(chunk, world),
@@ -1556,7 +1592,7 @@ impl Streaming {
/// Wait up to `timeout` for a worker to finish a job; the next `update`
/// takes it. A server pumps generation with this between its ticks.
pub fn wait(&mut self, timeout: std::time::Duration) {
if let Ok(done) = self.results.recv_timeout(timeout) {
if let Some(done) = self.shared.done.recv_timeout(timeout) {
self.deferred.push(done);
}
}
@@ -1837,10 +1873,10 @@ impl Drop for Streaming {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.saving > 0 {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.results.recv_timeout(left) {
Ok(Done::Saved(_)) => self.saving -= 1,
Ok(_) => {}
Err(_) => break,
match self.shared.done.recv_timeout(left) {
Some(Done::Saved(_)) => self.saving -= 1,
Some(_) => {}
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
@@ -1878,6 +1914,37 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
mod tests {
use super::*;
/// The frame never waits for a worker: while one holds the mailbox, the
/// frame takes nothing and finds the results, in order, the next time.
#[test]
fn the_frame_takes_finished_jobs_without_waiting() {
let mailbox = Mailbox::default();
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
mailbox.send(lit(1));
mailbox.send(lit(2));
let sending = mailbox.done.lock().unwrap();
assert!(mailbox.take().is_empty());
assert!(mailbox.try_recv().is_none());
drop(sending);
let seqs: Vec<u64> = mailbox
.take()
.into_iter()
.map(|done| match done {
Done::Lit(_, _, _, seq) => seq,
_ => unreachable!(),
})
.collect();
assert_eq!(seqs, [1, 2]);
assert!(mailbox.take().is_empty());
let waiting = std::time::Instant::now();
assert!(
mailbox
.recv_timeout(std::time::Duration::from_millis(10))
.is_none()
);
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
#[test]
fn queued_jobs_follow_the_centre_and_the_loading_screen() {
let forced = HashSet::new();
-3
View File
@@ -91,9 +91,6 @@ default = ["audio", "controllers"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
# (release.yml); the tests and probes don't need it. Content mods load without it.
mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
@@ -88,6 +88,7 @@ mod sound_event_fixture;
mod spawn_rules_fixture;
mod sprint_probe;
mod squid_client_probe;
mod storage_menu_fixture;
mod subtitle_fixture;
mod tab_list_fixture;
mod team_rules_fixture;
@@ -179,6 +180,7 @@ probes! {
sound_event_fixture,
spawn_rules_fixture,
sprint_probe,
storage_menu_fixture,
subtitle_fixture,
tab_list_fixture,
team_rules_fixture,
@@ -234,6 +234,8 @@ fn goal_name(m: &Mob, name: &str) -> String {
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
"MountPanic" => "MountPanicGoal",
"LlamaHurtBy" => "LlamaHurtByTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
@@ -0,0 +1,37 @@
//! Storage block menus for tools/test_storage_menu_parity.py: reads one
//! block id per line and prints the menu type the server opens it with
//! (`horizon_client::containers::block_menu_type`, as `lan_open_container`
//! sends it) and whether that menu's slots take a shulker box: the server
//! refuses them in a `minecraft:shulker_box` menu (`ShulkerBoxSlot.mayPlace`,
//! `lan::containers`).
//!
//! usage: storage_menu_fixture INPUT OUTPUT
use horizon_client::containers::block_menu_type;
use std::fs;
fn case(block: &str) -> Option<String> {
let menu = block_menu_type(block.trim())?;
let accepts = if menu == "minecraft:shulker_box" {
"refuses"
} else {
"accepts"
};
Some(format!("{menu} {accepts} shulker_box"))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: storage_menu_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
"shader-packs",
"graphics",
"graphics-advanced",
"graphics-hdr",
"extras",
"horizon",
"agent",
@@ -425,7 +426,7 @@ pub const COMMANDS: &[Cmd] = &[
&[BOX[0], BOX[1], BOX[2], BOX[3], BOX[4], BOX[5], p("block", Ty::Str, "block state")], mcp: true, local: false),
cmd!("container", World, "container X Y Z [ITEM COUNT]", "list a container's items, or add items to it", "", ["horizon-ctl container 3 64 1", "horizon-ctl container 3 64 1 diamond 3"],
&[BLOCK_XYZ[0], BLOCK_XYZ[1], BLOCK_XYZ[2], opt("item", Ty::Str, "item to add"), opt("count", Ty::Int, "how many")], mcp: true, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..64", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("render-distance", World, "render-distance N", "chunks, 2..128", "", ["horizon-ctl render-distance 8"], &[p("chunks", Ty::Int, "render distance")], mcp: false, local: false),
cmd!("wait", World, "wait TICKS", "wait until TICKS game ticks have passed (20 per second)",
"Keys and mouse buttons held with key ... down or mouse ... down stay held while it waits. Release them with key ... up, release or stop.",
["horizon-ctl wait 40", "horizon-ctl key w down && horizon-ctl wait 40 && horizon-ctl key w up"],
+149 -82
View File
@@ -110,14 +110,9 @@ mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_sounds;
mod mod_input;
#[cfg(feature = "mods")]
mod mod_panel;
mod mod_render;
mod mods;
mod mount_menu;
mod mounts;
mod moved_extras;
mod moving_pistons;
mod music;
mod names;
@@ -170,10 +165,10 @@ mod screens;
mod sculk;
mod sculk_fixture;
mod server_links;
mod server_mods;
mod server_packs;
mod server_travel;
mod shader_packs;
mod shelf;
mod shield;
mod shoulders;
mod sign_edit;
@@ -326,15 +321,24 @@ struct Options {
dedicated: Option<Vec<String>>,
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
/// `--dev-mod PATH` (repeatable): content mods always on.
dev_mods: Vec<PathBuf>,
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
/// DEPRECATED: never use it; it is unsupported and will be removed soon.
server_thread: bool,
/// What this Game executes; independent of its current server connection.
execution_role: execution_role::ExecutionRole,
}
/// `--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated:
/// the in-frame world is unmaintained, gets no fixes and will be removed soon.
fn warn_in_frame_deprecated(what: &str) {
eprintln!(
"Horizon: DEPRECATED: {what} runs single player's world in the window, a path that is no longer supported, \
gets no fixes and will be removed soon. Do not use it: single player runs on its server thread by default."
);
}
impl Options {
/// The name to join servers with: the one given, else the offline name kept
/// next to options.txt (created on first use).
@@ -386,7 +390,13 @@ impl Options {
dedicated: None,
benchmark: None,
dev_mods: Vec::new(),
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0"),
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| {
let on = v != "0";
if !on {
warn_in_frame_deprecated("HORIZON_SERVER_THREAD=0");
}
on
}),
execution_role: execution_role::ExecutionRole::Client,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
@@ -395,7 +405,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -411,7 +421,10 @@ impl Options {
"--offscreen" => o.offscreen = true,
"--control" => o.control = Some(horizon_client::control::default_path()),
"--server-thread" => o.server_thread = true,
"--in-frame" | "--no-server-thread" => o.server_thread = false,
"--in-frame" | "--no-server-thread" => {
warn_in_frame_deprecated(&arg);
o.server_thread = false;
}
// Horizon's own commands (/pregen), off by default as on vanilla.
"--pregen" => horizon_client::command::vanilla::set_horizon_commands(true),
_ => {
@@ -423,8 +436,9 @@ impl Options {
"--render-distance" | "--radius" => {
o.distance = value.parse()?;
o.distance_override = Some(o.distance);
if !(2..=64).contains(&o.distance) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&o.distance) {
return Err(format!("render distance must be 2..{max}").into());
}
}
"--seed" => {
@@ -535,9 +549,6 @@ enum Screen {
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// A `ui` mod's own screen, or its settings page from Options > Mods
/// (game/mods.rs).
Mod,
}
struct Mining {
pos: [i32; 3],
@@ -1419,6 +1430,14 @@ fn select_creative_tab(
clicks.skip_release();
}
/// The icon the Select World list shows for the world in play
/// (`GameRenderer.tryTakeScreenshotIfNeeded`): its path in the save, and when
/// the last frame grab was asked for.
struct WorldIcon {
path: PathBuf,
last_attempt: Option<Instant>,
}
struct Game<'a> {
/// Shared with an integrated server; `collision` and `motion` borrow from it.
res: Arc<Resources>,
@@ -1924,6 +1943,9 @@ struct Game<'a> {
/// The world was saved for the application ending (`save_for_exit`).
saved_for_exit: bool,
pending_save: Option<saving::ClosingSave>,
/// The world list's icon in progress (`GameRenderer.tryTakeScreenshotIfNeeded`):
/// `None` without a local world, or once the save has its icon.png.
world_icon: Option<WorldIcon>,
/// The block a guest is acting on (game/lan_actions.rs).
forced_hit: Option<Hit>,
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
@@ -4178,9 +4200,6 @@ impl Game<'_> {
correct_tool,
)
});
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);
@@ -4382,6 +4401,14 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if let Some(swings) = self.use_shelf(hit) {
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
if swings {
self.swing_arm();
}
return Ok(true);
}
if let Some(swings) = self.use_chiseled_bookshelf(hit, held.as_ref()) {
if swings {
self.swing_arm();
@@ -4643,7 +4670,6 @@ impl Game<'_> {
};
let target = [pos.x, pos.y, pos.z];
let state = self.apply_item_block_state(target, state, &stack)?;
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);
@@ -5285,9 +5311,8 @@ impl Game<'_> {
self.tick_totem();
self.update_totem_ready();
self.tick_command_extras();
self.tick_mods();
self.native_tick();
self.tick_server_mods();
self.tell_native_now();
let camera_eye = self.camera_eye_height();
let stick = self.pad_move();
if self.riding.is_some() && self.player.is_some() {
@@ -5309,6 +5334,7 @@ impl Game<'_> {
let riding = self.riding.is_some();
let entity_collisions = self.player_entity_collisions();
let spin_targets = self.spin_targets();
let sprint_window = self.settings.sprint_window;
self.sync_player_body();
let Some(player) = self.player.as_mut() else {
// A dedicated server has no local player: the world still runs.
@@ -5361,7 +5387,7 @@ impl Game<'_> {
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
// Player.jumpFromGround: the jump statistic.
if grounded && keys.jump && !player.flying && player.velocity.y > 0.2 {
self.stats.custom("jump", 1);
@@ -6164,6 +6190,7 @@ impl Game<'_> {
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.resolve_inline_profiles(&batch.inline_profiles);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
@@ -6177,11 +6204,6 @@ impl Game<'_> {
self.draw_options(&mut batch);
return batch;
}
// A mod's settings page opened from Options > Mods at the title.
if self.screen == Screen::Mod && self.title.is_some() {
self.draw_mod_screen(&mut batch);
return batch;
}
if let Some(title) = &mut self.title {
title.poll_backup();
if let Some(sound) = title.update_toast(self.settings.notification_display_time) {
@@ -6274,7 +6296,6 @@ impl Game<'_> {
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.draw_boss_bars(&mut batch);
self.draw_extras(&mut batch);
self.draw_mod_shapes(&mut batch);
}
if show_hud {
// Hud.nextContextualInfoState: the jump bar, the experience bar or
@@ -6453,7 +6474,6 @@ impl Game<'_> {
self.draw_creative(&mut batch);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Mod => self.draw_mod_screen(&mut batch),
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_saving_indicator(&mut batch);
@@ -6521,7 +6541,9 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
@@ -6805,7 +6827,9 @@ impl Game<'_> {
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_),
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_),
) => {}
None if self.mount_active() => self.draw_mount_background(batch, ox, oy),
None => {
@@ -6947,6 +6971,9 @@ impl Game<'_> {
self.inventory[i] = Some(stack);
}
DisplayClick::Offhand(stack) => self.offhand = Some(stack),
// handleCreativeModeItemDrop: on a server the stack goes to it (SetCreativeModeSlot
// to slot -1), which drops it; a local drop here would never reach the server.
DisplayClick::Drop(stack) if self.remote.is_some() => self.remote_creative_throw(stack),
DisplayClick::Drop(stack) => self.player_drop(stack),
_ => {}
}
@@ -8282,6 +8309,8 @@ impl Game<'_> {
// `CampfireRenderer`.
self.mark_entity(out.len(), "");
self.campfire_geometry(camera, &mut out);
self.brushable_geometry(camera, &mut out);
self.shelf_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).
@@ -9356,6 +9385,12 @@ impl Game<'_> {
book.to_be_displayed.retain(|r| index.contains(r));
self.dim = dim;
self.world_root = save_root.clone();
// LevelStorageAccess.getIconFile: this save's icon.png is what the
// Select World list shows.
self.world_icon = Some(WorldIcon {
path: save_root.join("icon.png"),
last_attempt: None,
});
self.world_flat = horizon_client::save::WorldGen::read(&save_root).is_some_and(|w| w.flat);
self.block_ticks = Ticks::new(self.level.seed as i32);
self.tick_chunks.clear();
@@ -9682,10 +9717,12 @@ impl Game<'_> {
self.music.stop_playing(None, &mut self.audio);
self.audio.stop_all();
if self.remote.is_some() {
self.world_icon = None;
return self.leave_server(None);
}
// The save screen has finished every write before releasing storage.
self.session_lock = None;
self.world_icon = None;
horizon_client::crash::clear_detail("World");
self.debug_charts.reset();
self.boss_overlay.reset();
@@ -9711,6 +9748,7 @@ impl Game<'_> {
));
if let Some(title) = &mut self.title {
title.last_server = self.settings.file.string("lastServer");
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
}
self.capture(false);
Ok(())
@@ -10692,10 +10730,13 @@ impl Game<'_> {
.unwrap_or_else(|| ItemStack::new("minecraft:arrow", 1));
let weapon = self.held().cloned();
let (power_bonus, infinity) = self.bow_enchantments(&ammo.item);
let effect = dispenser::tipped_effect(&ammo).or_else(|| {
(&*ammo.item == "minecraft:spectral_arrow").then_some(("minecraft:glowing", 200))
});
let kind = horizon_client::projectiles::ProjectileKind::Arrow {
base_damage: 2.0 + power_bonus,
critical: power >= 1.0,
effect: None,
effect,
};
let mut arrow = horizon_client::projectiles::Projectile::new(
kind,
@@ -11021,6 +11062,9 @@ impl Game<'_> {
self.all_sections_changed();
}
self.audio.master = self.settings.master_volume;
if self.settings.sound_device != self.audio.device() {
let _ = self.audio.set_device(&self.settings.sound_device);
}
horizon_client::hud::GUI_SCALE.store(
self.settings.gui_scale,
std::sync::atomic::Ordering::Relaxed,
@@ -11849,7 +11893,59 @@ impl Game<'_> {
}
Ok(())
}
/// `GameRenderer.tryTakeScreenshotIfNeeded`: while a local world without a
/// saved icon is on the screen, grab the rendered frame once a second until
/// the save has one. The Select World list shows it as the world's preview.
fn tick_world_icon(&mut self) {
let Some(icon) = self.world_icon.as_ref() else {
return;
};
if icon
.last_attempt
.is_some_and(|at| at.elapsed() < Duration::from_secs(1))
{
return;
}
if icon.path.is_file() {
// tryTakeScreenshotIfNeeded's hasWorldScreenshot: it has one, stop asking.
self.world_icon = None;
return;
}
// takeAutoScreenshot only saves with every loaded section rendered, or
// the icon would picture the holes the streamer has not filled yet.
if self.streaming.pending() != 0
|| !self.pending_uploads.is_empty()
|| self.remote_backlog() != 0
{
return;
}
let Some(renderer) = self.renderer.as_mut() else {
return;
};
// countRenderedSections() > 10: a client that joined its server
// thread has nothing pending before the first chunks arrive, and the
// icon would be only sky.
if renderer.drawn_sections <= 10 {
return;
}
if renderer.screenshot.is_some() {
// F2's or an agent's screenshot owns the frame this time; ours
// comes around again with the next attempt.
return;
}
let path = icon.path.clone();
if let Some(icon) = self.world_icon.as_mut() {
icon.last_attempt = Some(Instant::now());
}
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path,
size: Some([64, 64]),
});
}
fn render(&mut self) -> Result<bool> {
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
}
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
// server starts. Its chunk map and progress are the server's, which
// the window doesn't see yet: the title alone, over the menu background.
@@ -11942,7 +12038,6 @@ impl Game<'_> {
self.pitch,
),
};
let (view_yaw, view_pitch) = self.shaken_view(view_yaw, view_pitch);
self.audio
.set_listener([camera.x, camera.y, camera.z], view_yaw, view_pitch);
let block = [
@@ -12397,7 +12492,7 @@ impl Game<'_> {
|| self.remote_loading();
let blurred = loading
|| self.title.as_ref().is_some_and(|t| {
matches!(self.screen, Screen::Options | Screen::Mod | Screen::Win)
matches!(self.screen, Screen::Options | Screen::Win)
|| !matches!(t.mode, horizon_client::title::Mode::Main)
})
|| (self.title.is_none()
@@ -12456,9 +12551,6 @@ impl Game<'_> {
}
return Ok(());
}
if self.screen == Screen::Mod && self.mod_screen_key(key) {
return Ok(());
}
if self.sort_key_press(key) {
return Ok(());
}
@@ -12496,7 +12588,6 @@ impl Game<'_> {
Screen::CommandBlock => self.close_command_block(),
Screen::Chat => self.close_chat(),
Screen::Win => self.close_win_screen()?,
Screen::Mod => self.close_mod_screen(true),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -12626,10 +12717,6 @@ impl Game<'_> {
}
/// A mouse button while playing (window or control socket).
fn play_mouse(&mut self, button: MouseButton, pressed: bool) -> Result<()> {
// A mod's claimed item takes the use button (capability `input`).
if self.mod_use(button, pressed) {
return Ok(());
}
if self.sleeping.is_some() {
return Ok(());
}
@@ -12697,19 +12784,11 @@ impl Game<'_> {
}
Ok(())
}
/// LOD Distance and Far Detail Reduced as chosen: the player's LOD
/// Distance when it is on, else a mod's far terrain
/// (`render.set-far-terrain`).
/// LOD Distance and Far Detail Reduced as the player chose them.
fn far_terrain_choice(&self) -> (u32, bool) {
let g = &self.settings.horizon;
let reduced = g.far_detail == horizon_client::graphics::FarDetail::Reduced;
if g.lod_distance > 0 {
return (g.lod_distance, reduced);
}
match self.mod_far_terrain() {
Some(f) => (f.distance, reduced || f.reduced),
None => (0, reduced),
}
(g.lod_distance, reduced)
}
/// LOD Distance as far terrain uses it (`graphics::lod_distance_capped`).
fn lod_distance(&self) -> u32 {
@@ -13218,7 +13297,6 @@ impl ApplicationHandler for Game<'_> {
&& self.sleeping.is_none()
&& self.player.is_some()
&& !self.native_wheel_event(d)
&& !self.mod_wheel(d)
&& let Some((wheel, (_, vertical_steps))) =
self.mouse
.on_scroll(raw_x, raw_y, discrete, wheel_sensitivity)
@@ -13244,12 +13322,12 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::Chat && d != 0.0 {
self.chat_scroll(d);
}
if self.screen == Screen::CommandBlock && d != 0.0 {
self.command_block_scroll(d);
}
if self.screen == Screen::Options && d != 0.0 {
self.options_scroll(d);
}
if self.screen == Screen::Mod && d != 0.0 {
self.mod_screen_scroll(d);
}
// AbstractContainerScreen.mouseScrolled: a bundle under the
// cursor takes the wheel before the screen's own scrolling.
let bundle = bundles::takes_bundle_wheel(self.screen)
@@ -13311,13 +13389,6 @@ impl ApplicationHandler for Game<'_> {
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. }
if self.screen == Screen::Mod && self.title.is_some() =>
{
if state == ElementState::Pressed {
self.mod_screen_click(button);
}
}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
self.options_bind_mouse(button)?;
@@ -13439,7 +13510,6 @@ impl ApplicationHandler for Game<'_> {
return Ok(());
}
self.extras_mouse(button, pressed);
self.mod_mouse_key(button, pressed);
if pressed
&& self.settings.keys.get("key.smoothCamera")
== horizon_client::keybinds::mouse_name(button)
@@ -13542,7 +13612,6 @@ impl ApplicationHandler for Game<'_> {
self.stats_click(size);
}
Screen::Stats if button == MouseButton::Left => self.stats_release(),
Screen::Mod if pressed => self.mod_screen_click(button),
_ => {}
}
}
@@ -13688,16 +13757,6 @@ impl ApplicationHandler for Game<'_> {
self.options_key(key, event.text.as_deref())?;
}
}
// A mod's settings page at the title takes its keys like Options.
WindowEvent::KeyboardInput { event, .. }
if self.screen == Screen::Mod && self.title.is_some() =>
{
if event.state == ElementState::Pressed
&& let PhysicalKey::Code(key) = event.physical_key
{
self.key_pressed(key)?;
}
}
WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => {
if event.state == ElementState::Pressed {
if let Some(shortcut) = self.edit_shortcut(&event) {
@@ -13749,7 +13808,6 @@ impl ApplicationHandler for Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return Ok(());
};
self.mod_key(key, event.state == ElementState::Pressed, event.repeat);
self.native_key_event(key, event.state == ElementState::Pressed, event.repeat);
// key.smoothCamera toggles the cinematic camera.
if self.screen == Screen::Playing
@@ -13854,12 +13912,11 @@ impl ApplicationHandler for Game<'_> {
}
}
self.extras_frame(elapsed);
self.mods_frame(elapsed);
self.poll_moved_extras();
self.extras_retry()?;
self.apply_mouse_look();
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
self.tick_world_icon();
let ticked = Instant::now();
let rendered = self.render()?;
self.apply_cursor_icon();
@@ -13913,6 +13970,7 @@ impl ApplicationHandler for Game<'_> {
);
}
self.clock.end_frame(self.pause);
let mut consumed = false;
let finished = self
.renderer
.as_mut()
@@ -13921,8 +13979,9 @@ impl ApplicationHandler for Game<'_> {
&& self.ui.screenshot_message.is_some()
{
self.finish_screenshot(result);
consumed = true;
}
if let Some(result) = finished
if let Some(result) = &finished
&& let Some(reply) = self.pending_screenshot.take()
{
let _ = reply.send(
@@ -13934,6 +13993,13 @@ impl ApplicationHandler for Game<'_> {
}
.to_string(),
);
consumed = true;
}
// A result nobody asked for was the world icon's
// (`tick_world_icon`): a failure tries again a second
// later, as `tryTakeScreenshotIfNeeded` does.
if !consumed && let Some(Err(error)) = finished {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
@@ -14455,6 +14521,7 @@ impl<'a> Game<'a> {
text_selection: Default::default(),
saved_for_exit: false,
pending_save: None,
world_icon: None,
forced_hit: None,
guest_use: None,
lan_events: Vec::new(),
@@ -15334,7 +15401,6 @@ fn start_game<'a>(
// Benchmarks measure the game alone.
if game.options.benchmark.is_none() {
game.mods.dev = game.options.dev_mods.clone();
game.start_moved_extras();
game.start_mods();
}
game.start_agent_control()?;
@@ -15413,6 +15479,7 @@ fn start_game<'a>(
&game.options.resources,
);
title.last_server = game.settings.file.string("lastServer");
title.skip_multiplayer_warning = game.settings.file.bool("skipMultiplayerWarning");
game.title = Some(title);
}
}
@@ -15675,7 +15742,7 @@ impl Loading {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
Audio::with_device(&options.resources, &self.settings.sound_device)
};
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
// The game borrows its shared resources for the process's lifetime,
@@ -15950,7 +16017,7 @@ pub fn run() -> Result<()> {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
Audio::with_device(&options.resources, &early_settings.sound_device)
};
let resources = loaded.resources;
let game = start_game(
+3 -1
View File
@@ -424,7 +424,6 @@ impl Game<'_> {
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -611,6 +610,9 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
}
}
@@ -153,5 +153,6 @@ impl Game<'_> {
};
}
self.world.block_entities.insert(key, entity);
self.update_spawner_display(key);
}
}
+1
View File
@@ -801,6 +801,7 @@ impl Game<'_> {
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.swing_arm();
return true;
}
if sneaking
+94 -6
View File
@@ -1,8 +1,11 @@
//! Brushing suspicious sand and gravel: `BrushItem.onUseTick` and
//! `BrushableBlockEntity`. The block's loot table stays in its saved block
//! entity (like a decorated pot's) until the first counted stroke rolls it.
use super::{Game, Hit, decorated_pot, spill, view_vector};
use super::{Game, Hit, decorated_pot, view_vector};
use horizon_client::{
block_entity_render::vertex_light,
containers::BlockEntity,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
save,
};
@@ -114,6 +117,20 @@ impl Game<'_> {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
let direction = match brush.hit_face.unwrap() {
[0, -1, 0] => 0,
[0, 1, 0] => 1,
[0, 0, -1] => 2,
[0, 0, 1] => 3,
[-1, 0, 0] => 4,
[1, 0, 0] => 5,
_ => return false,
};
if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = Some(direction);
}
brush.resets_at = time + 40;
if time < brush.cooldown_ends_at {
self.brushing.insert(pos, brush);
@@ -139,13 +156,19 @@ impl Game<'_> {
/// `BrushableBlockEntity.unpackLootTable`: the table's roll becomes the
/// block entity's item.
fn unpack_brush_loot(&mut self, pos: [i32; 3]) {
let Some(mut tag) = spill::saved_entity(&self.world, pos) else {
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get(&key) else {
return;
};
let Some(horizon_world::nbt::Tag::String(table)) = tag.get("LootTable") else {
let Some(horizon_world::nbt::Tag::String(table)) = &b.loot_table else {
return;
};
let table = table.to_string_lossy();
let mut tag = save::encode_block_entity(
key,
&BlockEntity::Brushable(b.clone()),
"minecraft:brushable_block",
);
self.trigger_loot_table(&table);
let loot = horizon_client::structure_blocks::loot_data(&self.options.resources);
decorated_pot::unpack_loot(
@@ -155,7 +178,12 @@ impl Game<'_> {
&mut self.rand_sequences,
self.level.seed,
);
spill::put_saved_entity(&mut self.world, pos, tag);
if let Some(BlockEntity::Brushable(b)) = self.world.block_entities.get_mut(&key) {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = tag.get("item").and_then(save::item_from_nbt);
}
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
@@ -168,8 +196,10 @@ impl Game<'_> {
_ => "minecraft:air",
};
// dropContent: the item at the middle of the block beside that face.
let item = spill::saved_entity(&self.world, pos)
.and_then(|tag| tag.get("item").and_then(save::item_from_nbt));
let item = match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
Some(BlockEntity::Brushable(b)) => b.item.clone(),
_ => None,
};
if let Some(mut item) = item {
let face = face.unwrap_or([0, 1, 0]);
let take = 10 + self.world.random.next_int_bound(21).unwrap_or(0);
@@ -223,12 +253,70 @@ impl Game<'_> {
if brush.count != 0 {
self.brushing.insert(pos, brush);
self.schedule_brushable_tick(pos);
} else if let Some(BlockEntity::Brushable(b)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
b.hit_direction = None;
}
if before != after {
self.set_dusted(pos, after);
}
}
/// `BrushableBlockRenderer`: reveal the item from the first brushed face.
pub(super) fn brushable_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
use horizon_assets::item_model::DisplayContext;
for (key, entity) in &self.world.block_entities {
let BlockEntity::Brushable(b) = entity else {
continue;
};
let (Some(item), Some(face)) = (&b.item, b.hit_direction) else {
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;
}
let state = self.world.block(key.0, key.1, key.2);
let progress = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("dusted"))
.and_then(|s| s.parse::<u8>().ok())
.unwrap_or(0);
if progress == 0 {
continue;
}
let offset = f32::from(progress) * 0.075;
let (translation, adjacent, east_west) = match face {
0 => (glam::Vec3::new(0.5, -0.23 - offset, 0.5), [0, -1, 0], false),
1 => (glam::Vec3::new(0.5, 0.25 + offset, 0.5), [0, 1, 0], false),
2 => (glam::Vec3::new(0.5, 0.0, 0.25 - offset), [0, 0, -1], false),
3 => (glam::Vec3::new(0.5, 0.0, 0.73 + offset), [0, 0, 1], false),
4 => (glam::Vec3::new(0.25 - offset, 0.0, 0.5), [-1, 0, 0], true),
5 => (glam::Vec3::new(0.73 + offset, 0.0, 0.5), [1, 0, 0], true),
_ => continue,
};
let item = self.resolve_item(item, DisplayContext::Fixed, None, 0);
if item.is_empty() {
continue;
}
let pose = glam::Mat4::from_translation(
(at - camera).as_vec3() + glam::Vec3::new(0.0, 0.5, 0.0) + translation,
) * glam::Mat4::from_rotation_y(
(86.0_f32 + if east_west { 90.0 } else { 0.0 }).to_radians(),
) * glam::Mat4::from_scale(glam::Vec3::splat(0.5));
let light = self.world.light(
key.0 + adjacent[0],
key.1 + adjacent[1],
key.2 + adjacent[2],
);
self.submit_item_world(&item, pose, vertex_light(light), out);
}
}
/// Level event 3008 (`LevelEventHandler`): the finished block's sound
/// and break particles.
pub(super) fn brush_complete_event(&mut self, pos: [i32; 3], data: i32) {
+20 -27
View File
@@ -20,13 +20,12 @@ use std::{
/// The tree the chat parses and completes with: a server's, once it has
/// sent one (ClientPacketListener.commands), else the game's own.
fn chat_tree<'a>(
remote: &'a Option<super::remote::Remote>,
mods: &'a super::mods::ModsState,
) -> &'a horizon_client::command::Dispatcher {
pub(super) fn chat_tree(
remote: &Option<super::remote::Remote>,
) -> &horizon_client::command::Dispatcher {
match remote.as_ref().and_then(|r| r.commands.as_ref()) {
Some(tree) => mods.remote_dispatcher().unwrap_or(tree),
None => mods.dispatcher(),
Some(tree) => tree,
None => horizon_client::command::vanilla::game_dispatcher(),
}
}
use winit::{
@@ -479,7 +478,7 @@ impl Game<'_> {
let b = HudBatch::new(size);
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let mut screen = ChatScreen::new(
@@ -579,13 +578,12 @@ impl Game<'_> {
}
}
/// A line on the window's own chat when the Chat option lets `source`
/// through; running mods hear it.
/// through.
fn local_chat_line(&mut self, text: Text, indicator: Option<u32>, source: ChatSource) {
if !self.ui.chat.shows(source) {
return;
}
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| text.plain());
self.ui.chat.add(
&self.gui.atlas,
text,
@@ -594,16 +592,13 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
}
/// Keys while the chat screen is open.
pub(super) fn chat_key(&mut self, event: &KeyEvent) {
if let Some(shortcut) = self.edit_shortcut(event) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -649,7 +644,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
@@ -689,7 +684,7 @@ impl Game<'_> {
) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -700,7 +695,7 @@ impl Game<'_> {
pub(super) fn chat_type(&mut self, text: &str) {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
@@ -727,8 +722,8 @@ impl Game<'_> {
return;
}
if let Some(command) = message.strip_prefix('/') {
// A client mod's command first, then a server mod's, as on a server.
if !self.client_mod_command(command) && !self.local_server_mod_command(command) {
// A native mod's command first.
if !self.client_mod_command(command) {
self.run_command(command);
}
} else {
@@ -737,7 +732,6 @@ impl Game<'_> {
&[self.player_display_name(), Text::literal(message)],
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui.chat.add(
&self.gui.atlas,
line,
@@ -746,11 +740,7 @@ impl Game<'_> {
self.ticks,
focused,
);
if let Some(line) = heard {
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) {
@@ -777,7 +767,7 @@ impl Game<'_> {
});
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if self.ui.in_bed_chat && self.sleeping.is_some() {
@@ -922,7 +912,7 @@ impl Game<'_> {
}
if self.remote.is_some() {
self.send_chat(&format!("/{command}"));
} else if !self.local_server_mod_command(command) {
} else {
self.run_command(command);
}
}
@@ -942,7 +932,7 @@ impl Game<'_> {
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote, &self.mods),
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
screen.resize(&m, &c, batch.width as i32, batch.height as i32);
@@ -1203,7 +1193,10 @@ impl Game<'_> {
return;
};
let path = dir.join(name);
renderer.screenshot = Some(path.clone());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
});
self.ui.screenshot_message = Some(path);
}
/// After a frame: report a finished F2 screenshot in chat.
+180 -30
View File
@@ -4,18 +4,21 @@
//! `CommandBlockEditScreen` with what `handleSetCommandBlock` does with it.
use super::{Game, Screen, commands::execute::Source, lan::LanHost};
use horizon_client::{
chat::{ChatScreen, Commands},
command_block::{self, CommandBlock, Mode},
containers::BlockEntity,
hud::{HudBatch, rgba},
screen_draw::Measure,
text::{self, Text},
ui::{self, EditBox},
world::World,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Serverbound as S};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::{
chunk::BlockState,
level::flags,
nbt::Tag,
registry::{BlockRegistry, BlockStateId},
};
use winit::{
@@ -28,7 +31,10 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pos: Pos,
command: EditBox,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
opened: std::time::Instant,
previous: EditBox,
track_output: bool,
mode: Mode,
@@ -93,6 +99,9 @@ impl CommandBlockScreen {
Button::Cancel => tr("gui.cancel"),
}
}
pub(super) fn pos(&self) -> Pos {
self.pos
}
/// `updatePreviousOutput`.
fn show_previous(&mut self, block: &CommandBlock) {
let shown = if self.track_output {
@@ -446,10 +455,14 @@ impl Game<'_> {
let Some(state) = self.block_state_at(pos) else {
return;
};
let mut command = EditBox::new(0.0, 50.0, 300.0, 20.0);
command.max_length = 32500;
command.set_value(&block.command);
command.focused = true;
let (w, h) = self.gui_size();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
command.auto_suggestions = self.settings.auto_suggestions;
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
previous.max_length = 32500;
previous.text_color = UNEDITABLE;
@@ -457,6 +470,7 @@ impl Game<'_> {
let mut screen = CommandBlockScreen {
pos,
command,
opened: std::time::Instant::now(),
previous,
track_output: block.track_output,
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
@@ -485,7 +499,14 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
let mid = (w / 2.0).floor();
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
// Screen.extractBackground of an isInGameUi screen: the dimmed world,
// with no blur and no menu background.
batch.fill_gradient(
atlas,
(0.0, 0.0, w, h),
rgba(0x10, 0x10, 0x10, 0xC0),
rgba(0x10, 0x10, 0x10, 0xD0),
);
let title = Text::translate("advMode.setCommand", &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(
@@ -499,8 +520,6 @@ impl Game<'_> {
let grey = rgba(160, 160, 160, 255);
let label = Text::translate("advMode.command", &[]);
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
s.command.x = mid - 150.0;
s.command.draw(batch, atlas, None);
if !s.previous.value.is_empty() {
let label = Text::translate("advMode.previousOutput", &[]);
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
@@ -511,12 +530,65 @@ impl Game<'_> {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
}
// The command box, then its suggestions over the widgets.
let m = Measure(&atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.resize(&m, &c, w as i32, h as i32);
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
let blink = s.opened.elapsed().as_millis() as u64;
let draws = s.command.draw(&m, mouse, blink, 0);
horizon_client::screen_draw::paint(batch, atlas, &draws);
self.command_block_screen = Some(s);
}
/// The wheel over the suggestion list (`CommandSuggestions.mouseScrolled`).
pub(super) fn command_block_scroll(&mut self, amount: f32) {
let mouse = (self.cursor.0.floor() as i32, self.cursor.1.floor() as i32);
if let Some(s) = self.command_block_screen.as_mut() {
s.command.scroll(mouse, f64::from(amount));
}
}
/// The command box's completion request for an `ask_server` node.
pub(super) fn command_block_suggestion_request(&mut self) -> Option<(i32, String)> {
self.command_block_screen.as_mut()?.command.take_request()
}
/// The server's completions for the command box's `ask_server` request.
pub(super) fn command_block_server_suggestions(
&mut self,
id: i32,
range: (usize, usize),
list: Vec<(String, Option<String>)>,
) {
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.server_suggestions(&m, &c, id, range, list);
self.command_block_screen = Some(s);
}
pub(super) fn command_block_click(&mut self) {
let Some(mut s) = self.command_block_screen.take() else {
return;
};
// CommandSuggestions.mouseClicked first, then the command box.
{
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
let (x, y) = (f64::from(self.cursor.0), f64::from(self.cursor.1));
if s.command.click(&m, &c, x, y) {
self.command_block_screen = Some(s);
return;
}
}
let size = self
.renderer
.as_ref()
@@ -552,8 +624,8 @@ impl Game<'_> {
}
self.command_block_screen = Some(s);
}
/// `AbstractCommandBlockEditScreen.keyPressed`: Enter is Done, Escape
/// cancels, the rest edits the command.
/// `AbstractCommandBlockEditScreen.keyPressed`: the suggestions first,
/// then Enter is Done, Escape cancels, the rest edits the command.
pub(super) fn command_block_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
@@ -561,28 +633,58 @@ impl Game<'_> {
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
let shortcut = self.edit_shortcut(event);
self.command_block_key_code(key, shortcut, event.text.as_deref());
}
/// A key pressed on the command block screen, with the edit shortcut it
/// makes and the text it types.
pub(super) fn command_block_key_code(
&mut self,
key: KeyCode,
shortcut: Option<horizon_client::clipboard::EditShortcut>,
text: Option<&str>,
) {
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
// Windows' AltGr is Ctrl+Alt, and its characters still type.
let types_text = self.types_text();
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
if s.command.suggestions_key(&m, &c, key, shift) {
self.command_block_screen = Some(s);
return;
}
let typed = match key {
KeyCode::Escape | KeyCode::Enter | KeyCode::NumpadEnter => false,
_ => {
if let Some(shortcut) = shortcut {
s.command.shortcut(&m, &c, shortcut, &mut self.clipboard);
false
} else {
!s.command.field_key(&m, &c, key, shift, ctrl) && types_text
}
}
};
self.command_block_screen = Some(s);
if typed {
self.command_block_type(text.unwrap_or(""));
}
match key {
KeyCode::Escape => return self.close_command_block(),
KeyCode::Escape => self.close_command_block(),
KeyCode::Enter | KeyCode::NumpadEnter => {
if let Some(s) = self.command_block_screen.take() {
self.set_command_block(&s);
}
return self.close_command_block();
self.close_command_block();
}
_ => {}
}
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let shortcut = self.edit_shortcut(event);
let types_text = self.types_text();
let Some(s) = self.command_block_screen.as_mut() else {
return;
};
if let Some(shortcut) = shortcut {
s.command.shortcut(shortcut, &mut self.clipboard);
} else if !s.command.key(key, ctrl, shift) && types_text {
self.command_block_type(event.text.as_deref().unwrap_or(""));
}
}
/// Characters typed into the command box (`StringUtil.isAllowedChatCharacter`).
pub(super) fn command_block_type(&mut self, typed: &str) {
@@ -590,11 +692,19 @@ impl Game<'_> {
.chars()
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
.collect();
if let Some(s) = self.command_block_screen.as_mut()
&& !allowed.is_empty()
{
s.command.insert_text(&allowed);
if allowed.is_empty() {
return;
}
let Some(mut s) = self.command_block_screen.take() else {
return;
};
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
s.command.type_text(&m, &c, &allowed);
self.command_block_screen = Some(s);
}
/// The edit screen's Done: a server's client sends the packet, the
/// window's own world applies it (`handleSetCommandBlock`).
@@ -605,7 +715,7 @@ impl Game<'_> {
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value.clone(),
command: s.command.value().to_owned(),
track_output: s.track_output,
mode: s.mode,
conditional: s.conditional,
@@ -654,6 +764,46 @@ impl Game<'_> {
}
});
}
/// `ServerPlayer.openCommandBlock`: the command block's data
/// (`saveCustomOnly`) goes to the guest whose use opened it, as the
/// chunk's update tag leaves it out and the edit screen shows it.
pub(super) fn lan_open_command_block(&mut self, host: &mut LanHost, conn: u32, pos: Pos) {
if !host
.hub
.guest(conn)
.is_some_and(|g| g.creative() && g.permission >= 2)
{
return;
}
let Some(block) = self.command_block(pos) else {
return;
};
let tag = Tag::compound(block.save());
let state = self.world.block(pos.0, pos.1, pos.2).0;
let Some(kind) = host.hub.encoder.entity_type(state) else {
return;
};
host.hub.send_to(
conn,
&C::BlockEntityData(play::BlockEntityData {
pos: [pos.0, pos.1, pos.2],
kind,
tag,
}),
);
}
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
let open = self.screen == Screen::CommandBlock
&& self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == pos);
if open {
self.open_command_block(pos);
}
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
@@ -2069,6 +2069,7 @@ impl Game<'_> {
return false;
}
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
true
}
@@ -317,6 +317,7 @@ impl Game<'_> {
if let Some((_, mut new)) = save::decode_block_entity(&tag) {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
let _ = self.world.set(p[0], p[1], p[2], state);
@@ -246,9 +246,9 @@ impl Game<'_> {
c.insert("z".into(), Tag::Int(at[2]));
}
if let Some((_, entity)) = save::decode_block_entity(&tag) {
self.world
.block_entities
.insert((at[0], at[1], at[2]), entity);
let key = (at[0], at[1], at[2]);
self.world.block_entities.insert(key, entity);
self.update_spawner_display(key);
}
}
}
+19 -15
View File
@@ -22,7 +22,7 @@ const HELP: &[&str] = &[
"tp X Y Z teleport the player",
"hotbar 1-9 select a hotbar slot",
"give ITEM [COUNT] add items to the inventory",
"render-distance N chunks, 2..64",
"render-distance N chunks, 2..128",
"lod-distance N far surface chunks, 0 disables, up to1024",
"time TICKS set the time of day",
"summon KIND [X Y Z] [baby] spawn a mob (in front of the player without a position)",
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
"chat TEXT send a chat line or /command as if typed",
"type TEXT type into the open chat box (key enter tap sends)",
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
"stats [reset] frame-time summary since the last reset",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
@@ -339,17 +339,17 @@ impl Game<'_> {
self.debug_key(key, KeyAction::Release);
}
}
// Mods' keys (capability `input`) see the key as the keyboard
// hook does.
// Native mods see the key as the keyboard hook does.
let action = arg(2)?;
self.mod_key(physical, action != "up", action == "repeat");
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.mod_key(physical, false, false);
self.native_key_event(physical, false, false);
}
// Text screens consume raw presses; releases follow key binds.
let typing = matches!(self.screen, Screen::Chat | Screen::Options);
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::CommandBlock
);
let key = if typing && arg(2)? != "up" {
key
} else {
@@ -422,19 +422,15 @@ impl Game<'_> {
match arg(2)? {
"down" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.play_mouse(button, true)?
}
"up" => {
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, false)?
}
"click" => {
self.extras_mouse(button, true);
self.mod_mouse_key(button, true);
self.extras_mouse(button, false);
self.mod_mouse_key(button, false);
self.play_mouse(button, true)?;
self.play_mouse(button, false)?;
}
@@ -517,8 +513,9 @@ impl Game<'_> {
}
"render-distance" => {
let n: i32 = arg(1)?.parse()?;
if !(2..=64).contains(&n) {
return Err("render distance must be 2..64".into());
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&n) {
return Err(format!("render distance must be 2..{max}").into());
}
self.settings.render_distance = n;
self.apply_settings();
@@ -588,7 +585,10 @@ impl Game<'_> {
arg(1)?;
// The rest of the line: a path may hold spaces.
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
renderer.screenshot = Some(path.trim().into());
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
}
@@ -678,6 +678,7 @@ impl Game<'_> {
"shader-packs" => Page::ShaderPacks,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"graphics-hdr" => Page::GraphicsHdr,
"extras" => Page::Extras,
"credits" => Page::Credits,
"horizon" => Page::Horizon,
@@ -855,6 +856,10 @@ impl Game<'_> {
if self.screen == Screen::Options {
return self.options_key(key, None);
}
if self.screen == Screen::CommandBlock {
self.command_block_key_code(key, None, None);
return Ok(());
}
if self.keys.insert(key) {
self.key_pressed(key)?;
}
@@ -886,7 +891,6 @@ impl Game<'_> {
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
Screen::Mod => "mod",
}
}
+21 -2
View File
@@ -18,7 +18,10 @@ const FIREWORK_ROCKET: &str = "minecraft:firework_rocket";
/// `ProjectileWeaponItem.ARROW_ONLY`, for the arrows Horizon shoots.
fn is_arrow(stack: &ItemStack) -> bool {
&*stack.item == "minecraft:arrow"
matches!(
&*stack.item,
"minecraft:arrow" | "minecraft:spectral_arrow" | "minecraft:tipped_arrow"
)
}
/// `Player.getProjectile`: what `weapon` supports in the off hand, then in
@@ -206,10 +209,14 @@ impl Game<'_> {
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
}
if !rocket {
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
(&*ammo.item == "minecraft:spectral_arrow")
.then_some(("minecraft:glowing", 200))
});
let kind = ProjectileKind::Arrow {
base_damage: 2.0,
critical: true,
effect: None,
effect,
};
let mut arrow = Projectile::new(
kind,
@@ -277,5 +284,17 @@ mod tests {
// The main hand's rocket, with the crossbow in the off hand.
let hands = [(OFFHAND_SLOT, crossbow.as_ref()), (3, rocket.as_ref())];
assert_eq!(projectile_slot(CROSSBOW, hands, &inventory), Some(3));
// Spectral and tipped arrows in off hand or inventory are recognized.
let spectral = stack("minecraft:spectral_arrow");
let tipped = stack("minecraft:tipped_arrow");
inventory[7] = spectral.clone();
assert_eq!(slot("minecraft:bow", &None, &inventory), Some(7));
assert_eq!(
slot("minecraft:bow", &tipped, &inventory),
Some(OFFHAND_SLOT)
);
inventory[7] = None;
assert_eq!(slot("minecraft:bow", &None, &inventory), None);
}
}
+6 -1
View File
@@ -2140,6 +2140,11 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
def.effects
.iter()
.find(|(id, _, _)| !horizon_client::brewing::effect(id).is_some_and(|e| e.instant))
.map(|(id, duration, _)| (*id, (duration / 8).max(1)))
.map(|(id, duration, _)| {
(
horizon_client::brewing::namespaced(id),
(duration / 8).max(1),
)
})
})
}
+9 -27
View File
@@ -1,7 +1,7 @@
//! Horizon's extras in the game (`docs/extras.md`); a mod's minimap
//! (`render.set-minimap`: its tiles, image and HUD) and zoom (`render.set-zoom`) on the camera; clicks
//! and frame times for mods (`input.clicks-per-second`, `render.frame-stats`).
//! Each hook does nothing while its extra is off and no mod runs.
//! Horizon's extras in the game (`docs/extras.md`); a native mod's minimap
//! (`native_minimap`: its tiles, image and HUD) and zoom (`native_zoom`) on the camera; clicks
//! and frame times for mods (`native_clicks`, `native_frame_rate`).
//! Each hook does nothing while its extra is off and no mod uses it.
use super::{Dim, Game, Screen, native_hooks::NativeMinimap};
use horizon_client::{
extras::{Corner, Cps, FpsCounter, fov_target},
@@ -124,7 +124,7 @@ impl Game<'_> {
}
Ok(())
}
/// The minimap a mod shows (`render.set-minimap`): its corner, its side
/// The minimap a mod shows (`native_minimap`): its corner, its side
/// in GUI pixels and its view of the player.
fn minimap_shown(&self) -> Option<(Corner, f32, View)> {
let p = self.player.as_ref()?;
@@ -142,20 +142,9 @@ impl Game<'_> {
};
Some((m.corner, m.size, view))
}
/// The minimap shown: a native mod's (`native_minimap`), else a
/// WebAssembly mod's.
/// The minimap shown: a native mod's (`native_minimap`).
fn minimap_wanted(&self) -> Option<NativeMinimap> {
if let Some(m) = self.native_minimap_now() {
return Some(m);
}
let m = self.mod_minimap()?;
Some(NativeMinimap {
corner: m.corner,
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
self.native_minimap_now()
}
/// Refresh the minimap's tiles within the frame budget, recompose its
/// image when the view or a tile changed, and queue the upload.
@@ -209,13 +198,10 @@ impl Game<'_> {
rgba: self.extras.minimap.image.clone(),
});
}
/// A mod's zoom in effect (`render.set-zoom`): (factor, smooth, turning
/// A mod's zoom in effect (`native_zoom`): (factor, smooth, turning
/// scale); none is 1.
fn zoom_now(&self) -> (f32, bool, f64) {
if let Some(z) = self.native_zoom_now() {
return (z.factor, z.smooth, f64::from(z.turn));
}
self.mod_zoom().map_or((1.0, true, 1.0), |z| {
self.native_zoom_now().map_or((1.0, true, 1.0), |z| {
(z.factor, z.smooth, f64::from(z.turn))
})
}
@@ -240,7 +226,6 @@ impl Game<'_> {
/// The minimap and mods' corner panels, in the HUD layer (hidden with F1
/// by the caller). The top left corner gives way to the debug screen.
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
let mod_panels = self.mods.has_panels();
let map = self.minimap_shown();
let visible = |corner| !(self.debug && corner == Corner::TopLeft);
let mut corners = Corners::new(batch.width, batch.height, self.effect_rows_height());
@@ -264,9 +249,6 @@ impl Game<'_> {
};
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
}
if mod_panels {
self.draw_mod_panels(batch, &mut corners);
}
self.native_hud(batch, &mut corners);
}
/// The height the status effect icons take at the top right
+7 -1
View File
@@ -132,6 +132,12 @@ impl Game<'_> {
"minecraft:glow_item_frame" => true,
_ => return false,
};
let invisible = stack.extra.as_ref().is_some_and(|extra| {
extra.other.iter().any(|(k, v)| {
(k == "minecraft:entity_data" || k == "entity_data")
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
})
});
let pos = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
let frame = ItemFrame {
id: self.next_entity + 1,
@@ -142,7 +148,7 @@ impl Game<'_> {
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
invisible,
check_interval: 0,
};
if !self.frame_survives(&frame) {
+20 -5
View File
@@ -7,11 +7,11 @@
//! into its `IntegratedServer`.
//!
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead. That path
//! is DEPRECATED: never use it, it gets no fixes and will be removed soon.
//! LAN guests and dedicated servers use the same code either way.
use super::{
Game, Options, PackResources, Resources, UiResources,
Game, Options, PackResources, Resources, UiResources, WorldIcon,
execution_role::ExecutionRole,
lan::{LanSettings, Owner},
world_loading::WorldRequest,
@@ -177,6 +177,8 @@ pub(super) struct Starting {
handle: Integrated,
ready: Receiver<Ready>,
player: String,
/// The save's folder in `saves/`, for the world icon once joined.
folder: String,
}
/// The world to open and the player who owns it.
@@ -287,6 +289,7 @@ impl Game<'_> {
handle,
ready,
player,
folder: folder.to_owned(),
});
// Harnesses, control scripts and runs without a window go on with the world open.
if self.window.is_none() || super::synchronous_startup(&self.options) {
@@ -318,13 +321,25 @@ impl Game<'_> {
}
};
let Some(Starting {
mut handle, player, ..
mut handle,
player,
folder,
..
}) = self.integrated_starting.take()
else {
return Ok(());
};
handle.world_name = world_name;
self.connect_local(pipe, &player)?;
// IntegratedServer.getWorldScreenshotFile: the client grabs the frame
// the Select World list shows, and needs the save folder for it.
self.world_icon = Some(WorldIcon {
path: std::env::current_dir()?
.join("saves")
.join(&folder)
.join("icon.png"),
last_attempt: None,
});
self.integrated = Some(handle);
Ok(())
}
+1 -3
View File
@@ -1608,9 +1608,7 @@ 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::PlayerChat { .. } => {}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
+13 -8
View File
@@ -626,7 +626,9 @@ impl Game<'_> {
}) => Some(key),
_ => None,
};
self.command_block_screen = host_command_block;
// A command block the guest's use opened (Player.openCommandBlock).
let guest_command_block =
std::mem::replace(&mut self.command_block_screen, host_command_block).map(|s| s.pos());
// A mount inventory the guest's action opened (shift + use, or the
// inventory key while riding).
let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob();
@@ -675,9 +677,7 @@ impl Game<'_> {
let new_mode = self.mode_id();
self.mode = mode;
self.creative = creative;
self.selected = std::mem::replace(&mut g.selected, selected);
// `g.selected` now holds the host's value: swap the inventories back.
std::mem::swap(&mut self.selected, &mut g.selected);
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
@@ -779,6 +779,9 @@ impl Game<'_> {
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
if let Some(pos) = guest_command_block {
self.lan_open_command_block(host, conn, pos);
}
if let Some(pos) = opened_table {
self.lan_open_crafting(host, conn, pos);
}
@@ -892,6 +895,12 @@ impl Game<'_> {
else {
return false;
};
// The block entity's createMenu: a shulker box opens a ShulkerBoxMenu
// (not a 9x3 ChestMenu), whose slots refuse shulker boxes.
let menu = match slots {
Slots::Block(_) => horizon_client::containers::block_menu_type(name).unwrap_or(menu),
_ => menu,
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
@@ -1892,10 +1901,6 @@ 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
+11 -1
View File
@@ -154,8 +154,15 @@ impl Game<'_> {
);
e.head_yaw = a.yaw;
e.velocity = [a.velocity.x, a.velocity.y, a.velocity.z];
let mut flags = 0i8;
if a.fire_ticks > 0 {
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
flags |= 1;
}
if a.invisible {
flags |= 0x20;
}
if flags != 0 {
e.meta.push((0, serializer::BYTE, Value::Byte(flags)));
}
// LivingEntity.DATA_HEALTH_ID: 1 until the stand's max health is set.
e.meta.push((9, serializer::FLOAT, Value::Float(a.health)));
@@ -222,6 +229,9 @@ impl Game<'_> {
_ => 0.0,
};
e.data = i32::from(f.facing);
if f.invisible {
e.meta.push((0, serializer::BYTE, Value::Byte(0x20)));
}
// HangingEntity.DATA_DIRECTION, south by default.
if f.facing != 3 {
e.meta
-13
View File
@@ -112,17 +112,6 @@ pub(super) struct Levels {
}
impl Levels {
/// The chunks of each level not in place (capability `world` lends
/// them to server mods for a call).
#[cfg_attr(not(feature = "mods"), allow(dead_code))]
pub(super) fn parked_chunks(
&mut self,
) -> impl Iterator<Item = (Dim, &mut horizon_client::chunk_map::ChunkMap)> {
self.parked
.iter_mut()
.map(|(d, p)| (*d, &mut p.world.chunks))
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
@@ -359,8 +348,6 @@ impl Game<'_> {
};
self.update_dedicated_streaming();
self.update_world();
// What server mods set in this level since its last tick.
self.apply_mod_block_writes();
result = if paused {
self.tick_lan(true);
Ok(())
+19 -7
View File
@@ -151,10 +151,14 @@ impl Containers<'_> {
});
}
let entity = self.world.block_entities.get(&pos)?;
// A beehive keeps bees, not items, and a campfire cooks its own: neither
// is a `Container` (HopperBlockEntity.getContainerAt), so a dropper drops
// in front of them and a hopper leaves them alone.
if matches!(entity, BlockEntity::Beehive(_) | BlockEntity::Campfire(_)) {
// A beehive keeps bees, not items, a campfire cooks its own and
// suspicious sand or gravel hides its loot: none is a `Container`
// (HopperBlockEntity.getContainerAt), so a dropper drops in front of
// them and a hopper leaves them alone.
if matches!(
entity,
BlockEntity::Beehive(_) | BlockEntity::Campfire(_) | BlockEntity::Brushable(_)
) {
return None;
}
let kind = match (entity, name) {
@@ -422,15 +426,17 @@ pub(super) fn shelf_signal(
_ => 0,
});
}
let tag = super::spill::saved_entity(world, [pos.x, pos.y, pos.z]);
// Only read from the back: a bit for each of the three slots in use.
let facing = info.properties.get("facing").and_then(|f| dir_from_name(f));
if facing.map(Direction::opposite) != Some(direction) {
return Some(0);
}
let items = horizon_client::save::decode_items(tag.as_ref().and_then(|t| t.get("Items")), 3);
let Some(BlockEntity::Shelf(shelf)) = world.block_entities.get(&(pos.x, pos.y, pos.z)) else {
return Some(0);
};
Some(
items
shelf
.items
.iter()
.enumerate()
.map(|(slot, item)| i32::from(item.is_some()) << slot)
@@ -473,6 +479,12 @@ impl Game<'_> {
return;
}
self.chiseled_bookshelf_synced(pos);
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
self.mark_block_entity_updated(pos);
}
// A double chest's other half changes with it.
let halves: Vec<Pos> = std::iter::once(pos)
.chain(self.chest_other_half(pos))
-212
View File
@@ -1,212 +0,0 @@
//! Capability `input` (docs/mods.md): the use button on items a mod claims,
//! the mod's own keys and the mouse wheel; and `render`'s zoom. With no mod
//! claiming items, keys or the wheel, each hook here returns after one
//! check.
use super::Game;
use winit::{event::MouseButton, keyboard::KeyCode};
#[cfg(feature = "mods")]
use {super::Screen, horizon_mods::HeldItem};
/// A claimed item's use while the button is down.
#[cfg(feature = "mods")]
pub(super) struct ModUse {
item: HeldItem,
/// `Game::ticks` when it started.
since: u64,
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn mod_use(&mut self, _button: MouseButton, _pressed: bool) -> bool {
false
}
pub(super) fn mod_key(&mut self, _key: KeyCode, _pressed: bool, _repeat: bool) {}
pub(super) fn mod_mouse_key(&mut self, _button: MouseButton, _pressed: bool) {}
pub(super) fn mod_wheel(&mut self, _steps: f32) -> bool {
false
}
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
None
}
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
None
}
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
None
}
}
/// A mod's zoom (`render.set-zoom`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModZoom {
pub(super) factor: f32,
pub(super) smooth: bool,
pub(super) turn: f32,
}
/// A mod's minimap (`render.set-minimap`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModMinimap {
pub(super) corner: horizon_client::extras::Corner,
pub(super) size: f32,
pub(super) blocks_per_pixel: f32,
pub(super) rotate: bool,
pub(super) round: bool,
}
/// A mod's far terrain (`render.set-far-terrain`), clamped by the runtime.
#[derive(Clone, Copy)]
pub(super) struct ModFarTerrain {
pub(super) distance: u32,
pub(super) reduced: bool,
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The main hand's stack as `input` shows it; none when it is empty.
pub(super) fn mod_held(&self) -> Option<HeldItem> {
let stack = self.inventory.get(self.selected)?.as_ref()?;
Some(HeldItem {
id: stack.item.to_string(),
count: stack.count,
slot: self.selected as u32,
})
}
/// The use button while playing: true when a mod claimed the held item,
/// so the game does nothing else with it (no placing, eating, swing).
pub(super) fn mod_use(&mut self, button: MouseButton, pressed: bool) -> bool {
if button != MouseButton::Right || !self.mods.mods.claims_any() {
return false;
}
if !pressed {
return self.stop_mod_use();
}
let Some(item) = self.mod_held().filter(|h| self.mods.mods.claims(&h.id)) else {
return false;
};
self.mods.mods.use_start(&item);
self.mods.using = Some(ModUse {
item,
since: self.ticks,
});
true
}
/// Ends a claimed use: the button came up, the player switched slots,
/// or a screen opened. False when none was going.
pub(super) fn stop_mod_use(&mut self) -> bool {
let Some(u) = self.mods.using.take() else {
return false;
};
let ticks = self.ticks.saturating_sub(u.since).min(u64::from(u32::MAX)) as u32;
self.mods.mods.use_stop(&u.item, ticks);
true
}
/// Once a tick: a claimed use stops when its item leaves the hand or a
/// screen opens, as vanilla's `releaseUsingItem`.
pub(super) fn tick_mod_use(&mut self) {
let Some(u) = &self.mods.using else {
return;
};
let still = self
.mod_held()
.is_some_and(|h| h.id == u.item.id && h.slot == u.item.slot);
if !still || self.screen != Screen::Playing {
self.stop_mod_use();
}
}
/// A key went down or up: a mod's registered key while playing (no
/// screen) is its `key-down` / `key-up`. Vanilla's mapping on the same
/// key still runs.
pub(super) fn mod_key(&mut self, key: KeyCode, pressed: bool, repeat: bool) {
if repeat || !self.mods.mods.has_keys() {
return;
}
// Over an item on a container screen, the mods' keys are `browser`
// events (a recipe viewer's R and U).
if pressed
&& self.screen == Screen::Inventory
&& self.title.is_none()
&& !self.mod_browser_typing()
&& let Some(name) = horizon_client::keybinds::key_name(key)
&& let Some(stack) = self.mod_browser_pointed()
{
let stack = super::mod_panel::browser_stack(stack);
self.mods.mods.browser_key(name, &stack);
return;
}
if pressed && (self.screen != Screen::Playing || self.title.is_some()) {
return;
}
if let Some(name) = horizon_client::keybinds::key_name(key) {
self.mods.mods.key(name, pressed);
}
}
/// A mouse button while playing: a mod's key bound to it
/// (`key.mouse.4`...), as `mod_key`.
pub(super) fn mod_mouse_key(&mut self, button: MouseButton, pressed: bool) {
if self.mods.mods.has_keys() {
let name = horizon_client::keybinds::mouse_name(button);
self.mods.mods.key(&name, pressed);
}
}
/// Wheel steps while playing: true when a mod holds the wheel, so the
/// hotbar doesn't move.
pub(super) fn mod_wheel(&mut self, steps: f32) -> bool {
self.mods.mods.wheel(steps)
}
/// Playing, with nothing over the world: mods' keys and zoom apply.
fn mod_playing(&self) -> bool {
self.screen == Screen::Playing
&& self.title.is_none()
&& self.sign_edit.is_none()
&& self.book.is_none()
}
/// Once a frame, before the mods run: a screen that opened lets their
/// held keys up.
pub(super) fn mod_input_frame(&mut self) {
let playing = self.mod_playing();
if self.mods.playing && !playing {
self.mods.mods.release_keys();
}
self.mods.playing = playing;
}
/// The strongest zoom a mod asks for, while playing; the spyglass's
/// own view wins.
pub(super) fn mod_zoom(&self) -> Option<ModZoom> {
if !self.mod_playing() || self.scoping() {
return None;
}
let z = self.mods.mods.zoom()?;
Some(ModZoom {
factor: z.factor,
smooth: z.smooth,
turn: z.turn,
})
}
/// The minimap the first mod showing one asks for.
pub(super) fn mod_minimap(&self) -> Option<ModMinimap> {
use horizon_client::extras::Corner;
use horizon_mods::RenderCorner;
let m = self.mods.mods.minimap()?;
Some(ModMinimap {
corner: match m.corner {
RenderCorner::TopLeft => Corner::TopLeft,
RenderCorner::TopRight => Corner::TopRight,
RenderCorner::BottomLeft => Corner::BottomLeft,
RenderCorner::BottomRight => Corner::BottomRight,
},
size: m.size,
blocks_per_pixel: m.blocks_per_pixel,
rotate: m.rotate,
round: m.round,
})
}
/// The far terrain the first mod drawing one asks for.
pub(super) fn mod_far_terrain(&self) -> Option<ModFarTerrain> {
let f = self.mods.mods.far_terrain()?;
Some(ModFarTerrain {
distance: f.distance,
reduced: f.reduced,
})
}
}
-114
View File
@@ -1,114 +0,0 @@
//! Capability `ui`'s item panel of a WebAssembly mod (WIT `interface
//! browser`, docs/mods.md): its items and recipe page as the native panel's
//! (`native_panel.rs`), and the panel's events back in the runtime's types.
use super::{
Game,
native_hooks::{
NativePanelEvent as BrowserEvent, NativeRecipeLayout, NativeRecipeView,
NativeStack as BrowserStack,
},
native_panel::{Owner, Shown},
native_panel_recipes::RecipePage,
};
use horizon_mods as m;
/// A WebAssembly mod's panel, converted once per `set-items` or
/// `set-recipes`.
impl Game<'_> {
pub(super) fn panel_wasm_sync(&mut self) {
let Some((index, version, items)) = self.mods.mods.browser_items() else {
self.panel.wasm = None;
return;
};
let owner = Owner::Wasm(index);
let wasm = self.panel.wasm.as_ref();
if wasm.is_some_and(|s| s.owner == owner && s.version == version) {
return;
}
let items = items.iter().map(stack).collect();
let recipes = self.mods.mods.browser_recipes(index).map(page);
self.panel.wasm = Some(Shown {
owner,
version,
items,
recipes,
});
}
}
pub(super) fn stack(s: &m::BrowserStack) -> BrowserStack {
BrowserStack {
item: s.item.clone(),
count: s.count,
components: s.components.clone(),
}
}
/// A native stack as the runtime's (`browser_key`).
pub(super) fn browser_stack(s: BrowserStack) -> m::BrowserStack {
m::BrowserStack {
item: s.item,
count: s.count,
components: s.components,
}
}
fn layout(l: m::BrowserLayout) -> NativeRecipeLayout {
match l {
m::BrowserLayout::Grid => NativeRecipeLayout::Grid,
m::BrowserLayout::Cooking => NativeRecipeLayout::Cooking,
m::BrowserLayout::Single => NativeRecipeLayout::Single,
m::BrowserLayout::Smithing => NativeRecipeLayout::Smithing,
m::BrowserLayout::Brewing => NativeRecipeLayout::Brewing,
}
}
fn stacks(list: &[m::BrowserStack]) -> Vec<BrowserStack> {
list.iter().map(stack).collect()
}
pub(super) fn page(p: &m::RecipePage) -> RecipePage {
RecipePage {
title: p.title.clone(),
tabs: stacks(&p.tabs),
tab: p.tab,
recipes: p
.recipes
.iter()
.map(|r| NativeRecipeView {
layout: layout(r.layout),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
note: r.note.clone(),
action: r.action,
})
.collect(),
}
}
pub(super) fn event(e: BrowserEvent) -> m::BrowserEvent {
match e {
BrowserEvent::Clicked { index, right } => {
m::BrowserEvent::Clicked(m::BrowserClick { index, right })
}
BrowserEvent::Search(text) => m::BrowserEvent::Search(text),
// Not sent to WebAssembly mods: they hear their own keys
// (`browser_key`).
BrowserEvent::Key { key, stack } => m::BrowserEvent::Key(m::BrowserItemKey {
key,
stack: browser_stack(stack),
}),
BrowserEvent::SlotClicked {
recipe,
slot,
stack,
right,
} => m::BrowserEvent::SlotClicked(m::BrowserSlotClick {
recipe,
slot,
stack: browser_stack(stack),
right,
}),
BrowserEvent::Action(i) => m::BrowserEvent::Action(i),
BrowserEvent::Page(i) => m::BrowserEvent::Page(i),
BrowserEvent::Back => m::BrowserEvent::Back,
BrowserEvent::Tab(i) => m::BrowserEvent::Tab(i),
}
}
@@ -1,194 +0,0 @@
//! Capability `render` (docs/mods.md): the shapes mods keep on the HUD,
//! drawn by the game's own HUD renderer, and their camera shakes. Mods
//! never supply shader code. With no mod drawing, each hook here returns
//! after one check.
use super::Game;
use horizon_client::hud::HudBatch;
#[cfg(feature = "mods")]
use {
horizon_client::{hud::rgba, items::ItemStack, text::Text, ui},
horizon_mods::Shape,
std::time::Instant,
};
/// The camera's jolts from `render.shake`, while any is fading.
#[cfg(feature = "mods")]
#[derive(Default)]
pub(super) struct Shake {
jolts: Vec<Jolt>,
}
#[cfg(feature = "mods")]
struct Jolt {
start: Instant,
strength: f32,
seconds: f32,
}
/// The HUD batch's colour (ABGR) from a mod's 0xAARRGGBB.
#[cfg(feature = "mods")]
fn argb(color: u32) -> u32 {
let [a, r, g, b] = color.to_be_bytes();
rgba(r, g, b, a)
}
/// The view's turn at full strength, in degrees: yaw, then pitch.
#[cfg(feature = "mods")]
const SHAKE_DEGREES: (f32, f32) = (3.0, 2.0);
#[cfg(feature = "mods")]
impl Shake {
pub(super) fn add(&mut self, strength: f32, seconds: f32) {
self.jolts.push(Jolt {
start: Instant::now(),
strength,
seconds,
});
}
/// How far the view turns now (yaw, pitch degrees), `scale` being the
/// player's Distortion Effects; fades jolts out and forgets ended ones.
fn offset(&mut self, now: Instant, scale: f32) -> (f32, f32) {
self.jolts
.retain(|j| now.duration_since(j.start).as_secs_f32() < j.seconds);
let mut strength = 0.0;
for j in &self.jolts {
let left = 1.0 - now.duration_since(j.start).as_secs_f32() / j.seconds;
strength += j.strength * left * left;
}
let strength = strength.min(1.0) * scale.clamp(0.0, 1.0);
if strength <= 0.0 {
return (0.0, 0.0);
}
// Smooth noise: sines at unrelated rates, so it never repeats visibly.
let t = self
.jolts
.first()
.map_or(0.0, |j| now.duration_since(j.start).as_secs_f32());
let wave = |a: f32, b: f32, c: f32| {
((t * a).sin() * 0.5 + (t * b).sin() * 0.3 + (t * c).sin() * 0.2).clamp(-1.0, 1.0)
};
(
strength * SHAKE_DEGREES.0 * wave(23.0, 37.3, 51.7),
strength * SHAKE_DEGREES.1 * wave(29.1, 43.9, 61.3),
)
}
}
#[cfg(not(feature = "mods"))]
impl Game<'_> {
pub(super) fn draw_mod_shapes(&self, _batch: &mut HudBatch) {}
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
(yaw, pitch)
}
}
#[cfg(feature = "mods")]
impl Game<'_> {
/// The running mods' HUD shapes, in order, over the HUD.
pub(super) fn draw_mod_shapes(&self, batch: &mut HudBatch) {
for shape in self.mods.mods.hud_shapes() {
self.draw_shapes(batch, std::slice::from_ref(shape), (0.0, 0.0));
}
}
/// Shapes with their GUI coordinates moved by `(ox, oy)`.
pub(super) fn draw_shapes(&self, batch: &mut HudBatch, shapes: &[Shape], (ox, oy): (f32, f32)) {
let atlas = &self.gui.atlas;
for shape in shapes {
match shape {
Shape::Fill(f) => {
let r = f.rect;
batch.fill(atlas, ox + r.x, oy + r.y, r.w, r.h, argb(f.color));
}
Shape::Quad(q) => {
let (a, b, c, d) = q.corners;
let corners = [a, b, c, d].map(|p| [ox + p.x, oy + p.y]);
batch.fill_quad(atlas, corners, argb(q.color));
}
Shape::Sprite(s) => {
let r = s.rect;
let name = s.sprite.strip_prefix("minecraft:").unwrap_or(&s.sprite);
let name = format!("gui/sprites/{name}");
// As vanilla's blitSprite: a frame or a button keeps its border.
ui::blit_sprite(
batch,
atlas,
&name,
ox + r.x,
oy + r.y,
r.w,
r.h,
argb(s.color),
);
}
Shape::Item(i) => {
if self.res.items().get(&i.item).is_none() {
continue;
}
let start = batch.vertices.len();
let stack = ItemStack::new(i.item.as_str(), 1);
let (x, y) = (ox + i.at.x, oy + i.at.y);
self.draw_item_model(batch, &stack, x, y);
batch.scale_since(start, x, y, i.scale, i.scale);
}
Shape::Text(t) => {
let text = Text::literal(t.text.clone());
let centre = if t.centered {
(HudBatch::text_width(atlas, &t.text) / 2.0).floor()
} else {
0.0
};
let x = ox + t.at.x - centre;
batch.text_styled(
atlas,
&text,
x,
oy + t.at.y,
argb(t.color | 0xFF00_0000),
t.shadow,
);
}
}
}
}
/// The view's yaw and pitch after the mods' camera shakes; unchanged
/// while none is fading.
pub(super) fn shaken_view(&mut self, yaw: f32, pitch: f32) -> (f32, f32) {
if self.mods.shake.jolts.is_empty() {
return (yaw, pitch);
}
let scale = self.settings.file.double("screenEffectScale") as f32;
let (dy, dp) = self.mods.shake.offset(Instant::now(), scale);
(yaw + dy, (pitch + dp).clamp(-90.0, 90.0))
}
}
#[cfg(all(test, feature = "mods"))]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn a_jolt_fades_out_and_distortion_scales_it() {
let mut shake = Shake::default();
shake.add(1.0, 2.0);
let start = shake.jolts[0].start;
let at = |s: f32| start + Duration::from_secs_f32(s);
let size = |(y, p): (f32, f32)| y.abs() + p.abs();
// Off with Distortion Effects at 0, inside the limits otherwise.
assert_eq!(shake.offset(at(0.3), 0.0), (0.0, 0.0));
let (y, p) = shake.offset(at(0.3), 1.0);
assert!(y.abs() <= SHAKE_DEGREES.0 && p.abs() <= SHAKE_DEGREES.1);
// Fading: the largest turn over a window shrinks.
let mut peak = |from: f32| {
(0..50)
.map(|i| size(shake.offset(at(from + i as f32 * 0.002), 1.0)))
.fold(0.0, f32::max)
};
let early = peak(0.1);
let late = peak(1.6);
assert!(late < early, "{late} !< {early}");
// Gone once it ends.
assert_eq!(shake.offset(at(2.1), 1.0), (0.0, 0.0));
assert!(shake.jolts.is_empty());
}
}
File diff suppressed because it is too large. Load diff
@@ -1,122 +0,0 @@
//! Extras that are official mods now (`horizon_client::moved_extras`): on
//! the first start after the update, a player who had one on gets the mod
//! installed from Horizon's registry, turned on, with their settings, and
//! one line in the chat saying so. The download runs on its own thread; if
//! it fails (offline), the game tries again at the next start.
#[cfg(not(feature = "mods"))]
impl super::Game<'_> {
pub(super) fn start_moved_extras(&mut self) {}
pub(super) fn poll_moved_extras(&mut self) {}
}
#[cfg(feature = "mods")]
mod with_mods {
use super::super::Game;
use horizon_client::{
moved_extras::{self, Moved},
text::{self, Text},
};
use std::sync::mpsc::{Receiver, channel};
type Done = Vec<(&'static Moved, Result<(), String>)>;
#[derive(Default)]
pub(in crate::game) struct MovedState {
/// The installs under way.
running: Option<Receiver<Done>>,
/// Lines for the player, shown once a world is open.
notices: Vec<Text>,
}
impl Game<'_> {
/// At start: remembers the moved extras that were off, and installs
/// the ones that were on, in the background.
pub(in crate::game) fn start_moved_extras(&mut self) {
let off = moved_extras::nothing_to_do(&self.settings.extras);
let pending = moved_extras::pending(&self.settings.extras);
if off.is_empty() && pending.is_empty() {
return;
}
if !off.is_empty() {
let e = &mut self.settings.extras;
e.moved.extend(off.iter().map(|m| m.id.to_owned()));
self.settings.save(&Self::options_path());
}
if pending.is_empty() {
return;
}
let extras = self.settings.extras.clone();
let dir = Self::options_path()
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
let (tx, rx) = channel();
self.mods.moved.running = Some(rx);
let spawned = std::thread::Builder::new()
.name("Moved extras".into())
.spawn(move || {
let done = match moved_extras::fetch_index() {
Ok(index) => pending
.into_iter()
.map(|m| (m, moved_extras::install(&index, m, &extras, &dir)))
.collect(),
Err(e) => pending.into_iter().map(|m| (m, Err(e.clone()))).collect(),
};
let _ = tx.send(done);
});
if spawned.is_err() {
self.mods.moved.running = None;
}
}
/// Once a frame: the installs that finished turn their mods on;
/// their lines wait for a world.
pub(in crate::game) fn poll_moved_extras(&mut self) {
if let Some(rx) = &self.mods.moved.running
&& let Ok(done) = rx.try_recv()
{
self.mods.moved.running = None;
let mut installed = false;
for (m, result) in done {
match result {
Ok(()) => {
self.settings.extras.moved.push(m.id.to_owned());
self.settings.mods.enabled.insert(m.id.to_owned());
// The install wrote the key to horizon-mods.txt;
// the settings save below must keep it.
let keys = self.settings.mods.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(&self.settings.extras) {
keys.entry(name.to_owned()).or_insert(key);
}
self.settings.mods.keys.retain(|_, k| !k.is_empty());
self.mods.moved.notices.push(Text::literal(m.notice()));
installed = true;
}
Err(e) => {
log::warn!("Horizon: {} not installed, next start retries: {e}", m.id);
self.mods.moved.notices.push(
Text::literal(format!(
"{} moved to the mod browser; it couldn't be installed for you yet, Horizon tries again at the next start",
m.name
))
.with_color(text::GRAY),
);
}
}
}
if installed {
self.settings.save(&Self::options_path());
self.start_mods();
}
}
if !self.mods.moved.notices.is_empty() && self.player.is_some() && self.title.is_none()
{
for line in std::mem::take(&mut self.mods.moved.notices) {
self.system_message(line);
}
}
}
}
}
#[cfg(feature = "mods")]
pub(super) use with_mods::MovedState;
+27 -2
View File
@@ -4,7 +4,8 @@
//! mob is under the crosshair within 64 blocks, as its team's name tag
//! visibility allows, in the team's colours, with its score in the
//! objective displayed below names. Other players' names (on a LAN host
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows,
//! and the player's own in third person with the `ownNameTag` extra.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
@@ -68,6 +69,27 @@ impl Game<'_> {
}
out
}
/// Horizon's own name tag extra (`ownNameTag`): the player's own model and
/// name, while that model shows in third person. Vanilla never names the
/// camera entity (`LivingEntityRenderer.shouldShowName`).
fn own_name_tag(&self) -> Option<(&Mob, String)> {
if !self.settings.extras.own_name_tag
|| self.camera_type.first_person()
|| self.sleeping.is_some()
|| self.spectator()
|| self.potions.effects.get("invisibility").is_some()
{
return None;
}
let m = self.player_model.as_ref()?;
// The name others see: the profile's on a server (single player's
// server thread included), else the local player's.
let name = match self.remote.as_ref() {
Some(r) if !r.profile_name.is_empty() => r.profile_name.clone(),
_ => self.acting_player_name().to_owned(),
};
Some((m, name))
}
/// The names the name tags show this frame (the control socket's status).
pub(super) fn shown_name_tags(&self, camera: glam::DVec3) -> Vec<String> {
self.name_tags(camera, 1.0)
@@ -88,7 +110,10 @@ impl Game<'_> {
let at = glam::DVec3::new(m.position.x, m.position.y, m.position.z);
(at.distance_squared(camera) < 64.0 * 64.0).then(|| (m, name.clone(), false))
});
let players = players.into_iter().map(|(m, name)| (m, name, true));
let players = players
.into_iter()
.chain(self.own_name_tag())
.map(|(m, name)| (m, name, true));
// Who is riding whom: asked once per tag below, and rescanning every mob
// for each tag made this quadratic in the number of loaded mobs.
let ridden: std::collections::HashSet<u32> =
@@ -506,8 +506,8 @@ impl Game<'_> {
pub(super) fn list_native_mods(&mut self) {
let mut mods = Vec::new();
self.native_mods(&mut mods);
// A WebAssembly mod of the same id doesn't run (`start_mods`): the
// native row stands for it, and alone toggles its `mod.<id>` line.
// A WebAssembly mod of the same id: the native row stands for it,
// and alone toggles its `mod.<id>` line.
self.settings
.mods
.installed
@@ -525,7 +525,6 @@ impl Game<'_> {
description: m.description,
problem: None,
dev: false,
has_settings: false,
native: Some(page),
});
}
@@ -575,11 +574,11 @@ impl Game<'_> {
self.native_wheel(steps, &mut claimed);
claimed
}
/// A client mod's command: a native mod's first, then a WebAssembly mod's.
/// A native mod's command.
pub(super) fn client_mod_command(&mut self, command: &str) -> bool {
let mut ran = false;
self.native_command(command, &mut ran);
ran || self.mod_command(command)
ran
}
/// The zoom in effect while playing (a native mod's); the spyglass's own
/// view wins.
+10 -75
View File
@@ -27,21 +27,15 @@ const MARGIN: f32 = 8.0;
/// Items a panel may hold.
const ITEMS: usize = 8192;
/// Whose panel it is: a native mod's id, or a running WebAssembly mod's
/// index.
/// Whose panel it is: a native mod's id.
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Owner {
Native(String),
#[cfg(feature = "mods")]
Wasm(usize),
}
impl Owner {
fn native_id(&self) -> Option<&str> {
match self {
Owner::Native(id) => Some(id),
#[cfg(feature = "mods")]
Owner::Wasm(_) => None,
}
fn native_id(&self) -> &str {
let Owner::Native(id) = self;
id
}
}
@@ -61,9 +55,6 @@ pub(super) struct Shown {
pub(super) struct Panel {
/// A native mod's panel: the last one to show something.
native: Option<Shown>,
/// A WebAssembly mod's, converted once per change.
#[cfg(feature = "mods")]
pub(super) wasm: Option<Shown>,
/// The last native change.
versions: u64,
/// The items' owner and version they were made from.
@@ -79,16 +70,9 @@ pub(super) struct Panel {
}
impl Panel {
/// What the panel shows: a native mod's (one `on`), else a
/// WebAssembly mod's.
/// What the panel shows: a native mod's, one `on`.
pub(super) fn shown(&self, on: impl Fn(&str) -> bool) -> Option<&Shown> {
let native = self
.native
.as_ref()
.filter(|s| s.owner.native_id().is_some_and(&on));
#[cfg(feature = "mods")]
let native = native.or(self.wasm.as_ref());
native
self.native.as_ref().filter(|s| on(s.owner.native_id()))
}
/// `native_panel_items`.
pub(super) fn show_items(&mut self, mod_id: &str, mut items: Vec<BrowserStack>) {
@@ -111,7 +95,7 @@ impl Panel {
let own = self
.native
.as_ref()
.is_some_and(|s| s.owner.native_id() == Some(mod_id));
.is_some_and(|s| s.owner.native_id() == mod_id);
if !own {
if !showing {
return None;
@@ -187,7 +171,6 @@ impl Game<'_> {
if self.screen != Screen::Inventory || self.title.is_some() {
return None;
}
self.panel_wasm_sync();
let disabled = &self.settings.mods.disabled;
let Some(shown) = self.panel.shown(|id| !disabled.contains(id)) else {
self.panel.of = None;
@@ -220,14 +203,8 @@ impl Game<'_> {
}
/// The player did `event` on `owner`'s panel.
pub(super) fn panel_event(&mut self, owner: &Owner, event: BrowserEvent) {
match owner {
Owner::Native(id) => self.native_panel_event(id, &event),
#[cfg(feature = "mods")]
Owner::Wasm(i) => self
.mods
.mods
.browser_event(*i, super::mod_panel::event(event)),
}
let Owner::Native(id) = owner;
self.native_panel_event(id, &event);
}
/// The panel's parts: right of the window, from the top of the screen to
/// the search box at its foot. None when the screen has no room for it.
@@ -551,7 +528,7 @@ impl Game<'_> {
if self.screen != Screen::Inventory || self.title.is_some() || self.mod_browser_typing() {
return;
}
let Some(owner) = self.browser_sync().filter(|o| o.native_id().is_some()) else {
let Some(owner) = self.browser_sync() else {
return;
};
if let Some(stack) = self.mod_browser_pointed() {
@@ -583,12 +560,6 @@ pub(super) fn browser_stack(
})
}
/// No WebAssembly mods: no panel of theirs (`mod_panel.rs` with them).
#[cfg(not(feature = "mods"))]
impl Game<'_> {
fn panel_wasm_sync(&mut self) {}
}
#[cfg(test)]
mod tests {
use super::{BrowserStack, Owner, Panel, RecipePage};
@@ -656,40 +627,4 @@ mod tests {
panel.show_items("recipes", Vec::new());
assert!(panel.shown(|_| true).is_none());
}
/// A native mod's panel wins over a WebAssembly mod's, and events go
/// back to a WebAssembly owner in the runtime's form.
#[cfg(feature = "mods")]
#[test]
fn native_owner_wins_over_webassembly() {
use super::{BrowserEvent, Shown};
use crate::game::mod_panel as wasm;
let mut panel = Panel {
wasm: Some(Shown {
owner: Owner::Wasm(0),
version: 7,
items: vec![stack("minecraft:stone")],
recipes: None,
}),
..Panel::default()
};
let owner = |p: &Panel| p.shown(|_| true).map(|s| s.owner.clone());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
panel.show_items("recipes", vec![stack("minecraft:dirt")]);
assert_eq!(owner(&panel), Some(Owner::Native("recipes".into())));
panel.show_items("recipes", Vec::new());
assert_eq!(owner(&panel), Some(Owner::Wasm(0)));
let event = BrowserEvent::SlotClicked {
recipe: 2,
slot: 9,
stack: stack("minecraft:oak_planks"),
right: true,
};
let horizon_mods::BrowserEvent::SlotClicked(c) = wasm::event(event) else {
panic!("a slot click");
};
assert_eq!((c.recipe, c.slot, c.right), (2, 9, true));
assert_eq!(c.stack.item, "minecraft:oak_planks");
}
}
@@ -320,78 +320,3 @@ impl Game<'_> {
}
}
}
/// Capability `recipes` of WebAssembly mods: the same tables in the
/// runtime's types.
#[cfg(feature = "mods")]
impl Game<'_> {
/// Capability `recipes`'s tables and known displays for this tick's
/// snapshot; nothing while no running mod reads them.
pub(super) fn mod_recipes(&mut self) -> (Option<Arc<horizon_mods::Recipes>>, Arc<[i32]>) {
if !self.mods.mods.wants_recipes() || self.res.rest.get().is_none() {
return (None, Arc::from([]));
}
if self.mods.recipes.is_none() {
self.mods.recipes = self.made_recipes().map(|t| Arc::new(wasm::recipes(&t)));
}
(self.mods.recipes.clone(), self.known_displays())
}
}
/// The native tables as the WebAssembly runtime's.
#[cfg(feature = "mods")]
mod wasm {
use super::{Recipe, RecipeItem, RecipeKind, RecipeStack, Recipes};
use horizon_mods as m;
fn kind(k: RecipeKind) -> m::RecipeKind {
match k {
RecipeKind::CraftingShaped => m::RecipeKind::CraftingShaped,
RecipeKind::CraftingShapeless => m::RecipeKind::CraftingShapeless,
RecipeKind::Smelting => m::RecipeKind::Smelting,
RecipeKind::Blasting => m::RecipeKind::Blasting,
RecipeKind::Smoking => m::RecipeKind::Smoking,
RecipeKind::Campfire => m::RecipeKind::Campfire,
RecipeKind::Stonecutting => m::RecipeKind::Stonecutting,
RecipeKind::Smithing => m::RecipeKind::Smithing,
RecipeKind::Brewing => m::RecipeKind::Brewing,
}
}
fn stacks(list: &[RecipeStack]) -> Vec<m::RecipeStack> {
list.iter()
.map(|s| m::RecipeStack {
item: s.item,
count: s.count,
components: s.components.clone(),
})
.collect()
}
fn recipe(r: &Recipe) -> m::Recipe {
m::Recipe {
id: r.id.clone(),
kind: kind(r.kind),
category: r.category.clone(),
group: r.group.clone(),
width: r.width,
height: r.height,
slots: r.slots.iter().map(|s| stacks(s)).collect(),
results: stacks(&r.results),
cooking_time: r.cooking_time,
experience: r.experience,
book_id: r.book_id,
}
}
fn item(i: &RecipeItem) -> m::RecipeItem {
m::RecipeItem {
id: i.id.clone(),
name: i.name.clone(),
tags: i.tags.clone(),
}
}
pub(super) fn recipes(t: &Recipes) -> m::Recipes {
m::Recipes {
items: t.items.iter().map(item).collect(),
recipes: t.recipes.iter().map(recipe).collect(),
}
}
}
+10 -5
View File
@@ -19,12 +19,18 @@ pub(super) const CONTROL_HELP: &[&str] = &[
impl Game<'_> {
pub(super) fn save_title_options(&mut self) {
if let Some(title) = &self.title
&& title.last_server != self.settings.file.string("lastServer")
&& (title.last_server != self.settings.file.string("lastServer")
|| title.skip_multiplayer_warning
!= self.settings.file.bool("skipMultiplayerWarning"))
{
self.settings.file.set(
"lastServer",
horizon_client::options_file::V::Str(title.last_server.clone()),
);
self.settings.file.set(
"skipMultiplayerWarning",
horizon_client::options_file::V::Bool(title.skip_multiplayer_warning),
);
self.settings.save(&Self::options_path());
}
}
@@ -196,7 +202,6 @@ impl Game<'_> {
Event::Changed => {
self.settings.save(&Self::options_path());
self.apply_settings();
self.sync_mods();
self.sync_agent_control();
self.ui.options.agent_status = self.agent_status_lines();
}
@@ -257,7 +262,6 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::NativeSettings(i) => self.native_settings_refresh(i),
Event::NativeButton(i, b) => self.native_settings_event(i, b),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
@@ -290,9 +294,10 @@ impl Game<'_> {
);
r.set_hdr(
s.horizon.hdr,
s.horizon.hdr_paper_white as f32,
s.horizon.hdr_peak as f32,
s.horizon.paper_white_nits() as f32,
s.horizon.peak_nits() as f32,
s.horizon.hdr_black_level as f32 / 100.0,
s.horizon.hdr_ui_brightness as f32 / 100.0,
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
);
}
+130 -13
View File
@@ -122,6 +122,17 @@ impl CameraType {
}
}
}
/// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's
/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`)
/// hides it unless its team sees friendly invisibles, where vanilla draws it
/// translucent.
fn body_visible(
effects: &horizon_client::effects::PlayerEffects,
team: Option<&horizon_client::scoreboard::Team>,
) -> bool {
let invisible = effects.get("invisibility").is_some();
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
}
/// The player's model type (`PlayerModel` with the default skin).
pub(super) fn player_type(root: &Path) -> Option<Arc<MobType>> {
let models = horizon_client::mobs::EntityModelDocument::load(root).ok()?;
@@ -460,17 +471,28 @@ impl Game<'_> {
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some()
}
/// Whether the player's own model shows in the world view.
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
pub(super) fn player_model_shown(&self) -> bool {
// A shader pack's shadow pass draws the player in first person too (shadowPlayer).
(self.own_body_in_view() || self.pack_shadow_casters.1)
&& !self.spectator()
&& self.potions.effects.get("minecraft:invisibility").is_none()
&& self
.player
.as_ref()
.is_some_and(|p| !p.health.dead_or_dying())
}
/// The player's own team: a server's client is in it by its profile name.
fn own_team(&self) -> Option<&horizon_client::scoreboard::Team> {
match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
}
}
/// Whether the player's own body is drawn (`body_visible`).
pub(super) fn own_body_visible(&self) -> bool {
body_visible(&self.potions.effects, self.own_team())
}
/// The player's model for the entity pass in third person.
pub(super) fn player_model_geometry(
&self,
@@ -493,7 +515,13 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let start = out.len();
horizon_client::mobs::render::render_with(
let body = self.own_body_visible();
let render = if body {
horizon_client::mobs::render::render_with
} else {
horizon_client::mobs::render::render_layers
};
render(
m,
alpha as f32,
camera,
@@ -504,18 +532,32 @@ impl Game<'_> {
out,
);
let chest = self.armor[1].as_ref().map(|s| &*s.item);
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
if self.potions.effects.get("invisibility").is_none() {
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.held_item_geometry(m, camera, alpha, packed, true, out);
// LivingEntity.isCurrentlyGlowing: the Glowing effect. A server's client is
// in its team by its profile name.
// LivingEntity.isCurrentlyGlowing: the Glowing effect.
if self.potions.effects.get("glowing").is_some() {
let team = match &self.remote {
Some(r) => self.score.board.players_team(&r.profile_name),
None => self.player_team(),
};
self.mark_outline(&out[start..], super::glowing::team_color(team));
let color = super::glowing::team_color(self.own_team());
// LivingEntityRenderer.getRenderType: an invisible glowing body
// still draws its outline.
if !body {
let mut silhouette = Vec::new();
horizon_client::mobs::render::render(
m,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&mut silhouette,
);
self.mark_outline(&silhouette, color);
}
self.mark_outline(&out[start..], color);
}
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
@@ -563,7 +605,13 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let mut verts = Vec::new();
horizon_client::mobs::render::render_with(
// The same render state as in the world: an invisible body is not drawn.
let render = if self.own_body_visible() {
horizon_client::mobs::render::render_with
} else {
horizon_client::mobs::render::render_layers
};
render(
&m,
1.0,
camera,
@@ -574,7 +622,9 @@ impl Game<'_> {
&mut verts,
);
let chest = self.armor[1].as_ref().map(|s| &*s.item);
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
if self.potions.effects.get("invisibility").is_none() {
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
}
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
@@ -881,6 +931,73 @@ mod preview_tests {
}
}
#[test]
fn an_invisible_player_hides_its_own_body() {
use horizon_client::{effects::EffectInstance, scoreboard::Scoreboard};
// The effect as a server's UpdateMobEffect or a drunk potion adds it.
let id = horizon_client::brewing::effect("minecraft:invisibility")
.unwrap()
.id;
let mut effects = horizon_client::effects::PlayerEffects::default();
assert!(body_visible(&effects, None));
effects.add(EffectInstance::new(id, 3600, 0));
assert!(!body_visible(&effects, None), "no team: the body is hidden");
// Entity.isInvisibleTo: a team that sees friendly invisibles sees it.
let mut board = Scoreboard::default();
let team = board.add_team("blue");
assert!(body_visible(&effects, Some(team)));
team.see_friendly_invisibles = false;
assert!(!body_visible(&effects, Some(team)));
}
/// LivingEntityRenderer.submit: an invisible body is not drawn, its
/// HumanoidArmorLayer is.
#[test]
fn an_invisible_body_keeps_its_armour() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
let Some(kind) = player_type(&root) else {
return;
};
let m = Mob::new(1, kind.clone(), Vec3::ZERO, 0.0);
let armour = [(&*kind.model, [0.5, 0.5, 0.75, 0.75], u32::MAX)];
let atlas = |_: &str| Some([0.0, 0.0, 0.25, 0.25]);
let data = horizon_client::mobs::MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
pois: Default::default(),
};
let draw = |body: bool| {
let mut out = Vec::new();
let render = if body {
horizon_client::mobs::render::render_with
} else {
horizon_client::mobs::render::render_layers
};
render(
&m,
1.0,
glam::DVec3::ZERO,
&atlas,
0xF0F0,
&data,
&armour,
&mut out,
);
out
};
let (shown, hidden) = (draw(true), draw(false));
assert!(shown.iter().any(|v| v.uv[0] <= 0.25), "the body draws");
assert!(!hidden.is_empty(), "the armour still draws");
assert!(hidden.iter().all(|v| v.uv[0] >= 0.5), "only the armour");
assert_eq!(hidden.len() * 2, shown.len());
}
#[test]
fn burning_inventory_preview_keeps_visible_fullbright_flames() {
let rect = |u0| horizon_client::hud::Rect {
@@ -339,7 +339,7 @@ impl Game<'_> {
ui.client_known = Some(known.clone());
ui.client_version += 1;
}
/// Every entry of the book, in its order (capability `recipes`).
/// Every entry of the book, in its order (`native_recipes`).
pub(super) fn recipe_book_entries(&mut self) -> Vec<Entry> {
self.ensure_book();
self.recipe_ui
@@ -348,7 +348,7 @@ impl Game<'_> {
.map_or_else(Vec::new, |b| b.entries.clone())
}
/// The display ids the player's book holds, and a number that changes
/// whenever they do (capability `recipes`).
/// whenever they do (`native_known_recipes`).
pub(super) fn recipe_book_displays(&mut self) -> (u64, Vec<i32>) {
self.sync_client_book();
(
@@ -862,14 +862,14 @@ impl Game<'_> {
self.recipe_ui.ghost.clear();
self.place_recipe(book_type, entry_index, use_max);
}
/// Whether the open screen takes `recipes.place`: a crafting grid's
/// Whether the open screen takes `native_place_recipe`: a crafting grid's
/// book (the inventory's or a crafting table's).
pub(super) fn mod_recipe_screen(&self) -> bool {
self.screen == Screen::Inventory
&& self.active_book() == Some(BookType::Crafting)
&& !self.spectator()
}
/// `recipes.place`: the book's click on the recipe with this display
/// `native_place_recipe`: the book's click on the recipe with this display
/// id, refused as `handlePlaceRecipe` refuses it (no crafting screen,
/// not in the player's book; an impossible one in `place_recipe`).
pub(super) fn mod_place_recipe(&mut self, display_id: i32, all: bool) {
+200 -42
View File
@@ -39,6 +39,7 @@ use horizon_protocol::{
text::{Language, plain},
};
use horizon_world::{
level::InteractionResult,
nbt::Tag,
registry::BlockStateId,
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
@@ -325,7 +326,9 @@ impl Game<'_> {
&self.options.resources,
));
}
let skip = self.settings.file.bool("skipMultiplayerWarning");
if let Some(title) = &mut self.title {
title.skip_multiplayer_warning = skip;
title.failed(error);
}
self.capture(false);
@@ -556,9 +559,12 @@ impl Game<'_> {
self.titles.overlay_time = 0;
// GameRenderer.resetData (Minecraft.disconnect).
self.item_activation.reset();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
self.gui.atlas.object_skins.clear();
self.server_packs_disconnect();
self.code_of_conduct = None;
self.reset_tutorial();
@@ -608,6 +614,7 @@ impl Game<'_> {
let saves = std::env::current_dir()?.join("saves");
let mut title = horizon_client::title::Title::new(saves.clone(), &self.options.resources);
title.last_server = self.settings.file.string("lastServer");
title.skip_multiplayer_warning = self.settings.file.bool("skipMultiplayerWarning");
if single_player && reason.is_none() {
self.title = Some(title);
self.capture(false);
@@ -752,9 +759,6 @@ impl Game<'_> {
self.ticks,
self.screen == Screen::Chat,
);
if self.mods.running() {
self.mods_chat(&text);
}
if let Some(r) = self.remote.as_mut() {
r.chat.push((text, Instant::now()));
r.chat_received += 1;
@@ -1012,7 +1016,12 @@ impl Game<'_> {
if self.remote.is_none() {
return;
}
let Some((id, command)) = self.ui.chat_screen.as_mut().and_then(|s| s.take_request())
let Some((id, command)) = self
.ui
.chat_screen
.as_mut()
.and_then(|s| s.take_request())
.or_else(|| self.command_block_suggestion_request())
else {
return;
};
@@ -1052,7 +1061,8 @@ impl Game<'_> {
})();
if let Ok((id, start, length, list)) = decoded {
let range = (start.max(0) as usize, length.max(0) as usize);
self.chat_server_suggestions(id, range, list);
self.chat_server_suggestions(id, range, list.clone());
self.command_block_server_suggestions(id, range, list);
}
}
fn set_registries(&mut self, registries: Arc<Dynamic>) {
@@ -1247,10 +1257,15 @@ impl Game<'_> {
}
_ => {}
}
let command_block = matches!(loaded, BlockEntity::CommandBlock(_));
self.world.block_entities.insert(key, loaded);
self.update_spawner_display(key);
// The section is drawn again with what the block entity shows.
let state = self.world.block(pos[0], pos[1], pos[2]);
let _ = self.world.set(pos[0], pos[1], pos[2], state);
if command_block {
self.command_block_data_arrived(key);
}
}
}
/// `handleAnimate`: a player wakes up, or an entity gets the critical
@@ -2875,7 +2890,6 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.commands = Some(tree);
}
self.merge_remote_tree();
}
C::Cooldown(p) => {
// ClientPacketListener.handleItemCooldown: the server starts
@@ -2988,7 +3002,10 @@ impl Game<'_> {
let text = |t: &Tag| {
let json = horizon_client::scoreboard::tag_to_json(t);
let text = horizon_client::scoreboard::comp_text(&json);
(!text.plain().is_empty()).then_some(text)
text.spans
.iter()
.any(|span| !span.text.is_empty())
.then_some(text)
};
self.tab_list.header = text(&p.header);
self.tab_list.footer = text(&p.footer);
@@ -3234,6 +3251,13 @@ impl Game<'_> {
.as_ref()
.and_then(|r| r.skins.get(&p.uuid).cloned())
.unwrap_or_else(|| horizon_client::tab_list::default_skin(p.uuid).to_owned());
self.gui
.atlas
.set_object_skin(format!("id:{:032x}", p.uuid), p.skin.clone());
self.gui.atlas.set_object_skin(
format!("name:{}", p.name.to_ascii_lowercase()),
p.skin.clone(),
);
}
let r = self.remote.as_ref()?;
let upside_down = players
@@ -3246,6 +3270,32 @@ impl Game<'_> {
.collect();
Some((players, r.online_mode, upside_down))
}
/// Resolve inline `object:player` profiles through the same skin cache as
/// player heads on items. The next frame uses the uploaded atlas texture.
pub(super) fn resolve_inline_profiles(&mut self, profiles: &[serde_json::Value]) {
use horizon_client::skins;
for profile in profiles {
let skin = skins::head_skin(
profile,
&mut self.skin_profiles.borrow_mut(),
&mut self.skin_downloads.borrow_mut(),
|location| self.gui.atlas.sprites.contains_key(location),
);
let id = skins::parse_profile(profile).id;
self.gui
.atlas
.set_object_skin(format!("id:{id:032x}"), skin.clone());
let name = profile
.as_str()
.or_else(|| profile.get("name").and_then(serde_json::Value::as_str));
if let Some(name) = name {
self.gui
.atlas
.set_object_skin(format!("name:{}", name.to_ascii_lowercase()), skin);
}
}
}
/// The signed-in account's `textures`, once the services answered.
fn own_textures(&self) -> Option<horizon_client::skins::Textures> {
use horizon_client::skins::{self, NameOrId, Resolve};
@@ -3725,8 +3775,8 @@ impl Game<'_> {
pub(super) fn tick_remote(&mut self) -> Result<()> {
self.tick_remote_level()?;
// Client mods tick on any server, single player's own included.
self.tick_mods();
self.native_tick();
self.tell_native_now();
// Minecraft.tick sends this even when the player cannot tick yet,
// including while waiting for chunks. Keep it outside early returns.
if self.remote.as_ref().is_some_and(|r| r.joined) {
@@ -3802,6 +3852,7 @@ impl Game<'_> {
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.tick_remote_spawners();
self.client_tick_beacons();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
@@ -3820,6 +3871,7 @@ impl Game<'_> {
self.tick_subtitles();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let sprint_window = self.settings.sprint_window;
let Some(player) = self.player.as_mut() else {
return Ok(());
};
@@ -3918,7 +3970,7 @@ impl Game<'_> {
collision_binding: self.collision,
resources: self.motion,
}
.controlled_simulation_tick(keys)?;
.controlled_simulation_tick_with_sprint_window(keys, sprint_window)?;
} else {
// LivingEntity.baseTick counts hurtTime down for a dead or riding
// player too (controlled_simulation_tick does it otherwise): left
@@ -4898,18 +4950,32 @@ impl Game<'_> {
.and_then(|h| self.res.items().get(&h.item))
.is_some_and(|i| i.block.is_some());
if let Some(hit) = self.pick() {
// ShelfBlock.useItemOn consumes a nonempty main-hand click client-side
// without predicting the inventory swap or swinging.
let shelf_reacts = hand == Hand::Main
&& held.is_some()
&& !(self.keys.contains(&KeyCode::ShiftLeft)
&& (self.stack_in(self.selected).is_some() || self.offhand.is_some()))
&& self.shelf_hit(&hit).is_some();
let spectator = self.spectator();
let (reacts, changes) = if hand == Hand::Main && !spectator {
let (result, changes) = if hand == Hand::Main && !spectator {
self.remote_block_reacts(&hit, held.as_ref())
} else {
// useItemOn runs for the off hand too (useWithoutItem doesn't):
// RespawnAnchorBlock.useItemOn charges an anchor with its glowstone.
// RespawnAnchorBlock.useItemOn charges an anchor with its
// glowstone, SUCCESS.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
let charges =
!(sneaking && hands_full) && self.anchor_takes(hit.pos, held.as_ref());
(charges, DryUse::default())
let result = if charges {
InteractionResult::Success
} else {
InteractionResult::Pass
};
(result, DryUse::default())
};
let reacts = result.consumes_action();
let seq = self.next_sequence();
let cursor = [
(hit.point[0] - f64::from(hit.pos[0])) as f32,
@@ -4937,9 +5003,17 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.use_target = Some(hit.pos);
}
if shelf_reacts {
return true;
}
if reacts {
self.predict_use(&changes, seq);
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
if result == InteractionResult::Success {
self.with_hand(hand, Game::swing_arm);
}
} else if self.predict_campfire_douse(hit.pos, held.as_ref(), seq) {
// CampfireBlock.useItemOn succeeded: this hand swings and the
// click ends, so a shield in the other hand is not raised.
@@ -4978,7 +5052,7 @@ impl Game<'_> {
// server's resend of the target it refused is no placement.
r.placing = None;
}
if reacts || placed.is_some() {
if placed.is_some() {
self.with_hand(hand, Game::swing_arm);
}
// BlockItem.place returns FAIL when placement is rejected:
@@ -5070,20 +5144,20 @@ impl Game<'_> {
&mut self,
hit: &super::Hit,
held: Option<&ItemStack>,
) -> (bool, DryUse) {
) -> (InteractionResult, DryUse) {
// Player.isSecondaryUseActive with something in either hand.
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if self.keys.contains(&KeyCode::ShiftLeft) && hands_full {
return (false, DryUse::default());
return (InteractionResult::Pass, DryUse::default());
}
if self.jukebox_takes(hit.pos, held) {
return (true, DryUse::default());
return (InteractionResult::Success, DryUse::default());
}
// RespawnAnchorBlock.useItemOn: PASS when the off hand's glowstone is to
// charge the anchor, so useWithoutItem doesn't run and the off hand's turn
// comes (glowstone in this hand charges it in `use_item_on`).
if !self.anchor_takes(hit.pos, held) && self.anchor_takes(hit.pos, self.offhand.as_ref()) {
return (false, DryUse::default());
return (InteractionResult::Pass, DryUse::default());
}
// BeehiveBlock.useItemOn: shears or a glass bottle on a full hive
// (honey level 5) is SUCCESS, so the click ends after UseItemOn and
@@ -5093,7 +5167,7 @@ impl Game<'_> {
.hive_state(hit.pos)
.is_some_and(|(honey, _)| honey == Some(5))
{
return (true, DryUse::default());
return (InteractionResult::Success, DryUse::default());
}
use horizon_core::geometry::Direction;
let face = match hit.face {
@@ -5107,18 +5181,16 @@ impl Game<'_> {
let player = self.use_context();
self.with_level(|b, level, _| {
let mut level = DryLevel::new(level);
let reacts = b
.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
)
.consumes_action();
(reacts, (level.changed.into_iter().collect(), level.sounds))
let result = b.use_item_on(
&mut level,
&mut horizon_world::level::Ticks::default(),
horizon_core::position::BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
face,
horizon_core::geometry::Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
player,
held.map(|s| &*s.item),
);
(result, (level.changed.into_iter().collect(), level.sounds))
})
}
@@ -5883,22 +5955,23 @@ impl Game<'_> {
.iter()
.map(|&(slot, button, input)| (slot, button, input, Vec::new(), self.carried.clone()))
.collect();
let mut threw = false;
// The engine's slot indices are the menu's own, as on the network.
self.menu_session(|m, env, _| {
let mut predicted = Vec::with_capacity(clicks.len());
for &(slot, button, input) in &clicks {
let before = menu_items(m);
if m.click(env, i32::from(slot), button, input).is_err() {
// The server throws on it too: nothing is predicted.
return Some(false);
}
let changed = changed_slots(&before, m);
predicted.push((slot, button, input, changed, m.carried.clone()));
}
let Some((predicted, dropped)) = predict_clicks(m, env, &clicks) else {
// The server throws on it too: nothing is predicted.
return Some(false);
};
sent = predicted;
threw = dropped;
Some(true)
});
self.send_clicks(container, sent);
// LivingEntity.drop(.., Prediction.PREDICTED): the client swings its own
// main hand; the server shows the swing only to the other players.
if threw {
self.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
}
true
}
/// A Shift double click on a server (`AbstractContainerScreen.mouseReleased`):
@@ -6447,8 +6520,9 @@ impl Game<'_> {
.collect::<Vec<_>>(),
// The player's own model exists (third person and the inventory draw it).
"player_model": self.player_model.is_some(),
// The world view draws it now (third person, or lying in a bed).
"own_body": self.player_model_shown(),
// The world view draws its body now (third person, or lying in a bed;
// not while invisible).
"own_body": self.player_model_shown() && self.own_body_visible(),
"text_display_vertices": self.text_display_vertices,
"text_displays": self.text_displays.len(),
"health": r.health.0,
@@ -6550,6 +6624,30 @@ fn creative_sync_slot(i: usize) -> i16 {
}) as i16
}
/// `MultiPlayerGameMode.handleContainerInput` for each click on the engine's
/// copy of the menu: the click with the slots it changed and the carried
/// stack after it, and whether a click dropped a stack. Every drop of
/// `AbstractContainerMenu.doClick` (a THROW, a click outside the window, a
/// swap or a result move with no room left) is `Prediction.PREDICTED`.
/// None when the engine refuses a click.
fn predict_clicks(
m: &mut horizon_client::menu::Menu,
env: &horizon_client::menu::Env,
clicks: &[(i16, i32, Input)],
) -> Option<(Vec<Predicted>, bool)> {
let mut predicted = Vec::with_capacity(clicks.len());
let mut dropped = false;
for &(slot, button, input) in clicks {
let before = menu_items(m);
let thrown = m.dropped.len();
m.click(env, i32::from(slot), button, input).ok()?;
dropped |= m.dropped.len() > thrown;
let changed = changed_slots(&before, m);
predicted.push((slot, button, input, changed, m.carried.clone()));
}
Some((predicted, dropped))
}
fn menu_items(m: &horizon_client::menu::Menu) -> Vec<Option<ItemStack>> {
(0..m.slot_count()).map(|i| m.slot(i).cloned()).collect()
}
@@ -6845,6 +6943,66 @@ impl Remote {
mod tests {
use super::Game;
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
#[test]
fn a_predicted_throw_from_a_slot_swings_the_hand() {
use horizon_client::{
banner::Banners,
brewing::Brewing,
crafting::Recipes,
creative::Names,
enchant::Enchantments,
items::{ItemStack, Items},
menu::{Env, Input, Kind, Menu},
stations::StationRecipes,
};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let items = Items::catalog(&root).unwrap();
let recipes = Recipes::load(&root, &items).unwrap();
let brewing = Brewing::load(&root, &items);
let banners = Banners::load(&root);
let station_recipes = StationRecipes::load(&root, &items);
let enchantments = Enchantments::default();
let names = Names::default();
let env = Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
let mut menu = Menu::new(Kind::Inventory);
let cobblestone = ItemStack {
item: "minecraft:cobblestone".into(),
count: 16,
damage: 0,
extra: None,
};
// The first hotbar slot of InventoryMenu.
menu.set_slot(&env, 36, Some(cobblestone));
let (sent, threw) =
super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Throw)]).unwrap();
assert!(threw, "Q over a slot drops one and swings");
assert_eq!(menu.slot(36).map(|s| s.count), Some(15));
assert_eq!(sent.len(), 1);
let (_, threw) = super::predict_clicks(&mut menu, &env, &[(36, 0, Input::Pickup)]).unwrap();
assert!(!threw, "picking the stack up drops nothing");
let (_, threw) =
super::predict_clicks(&mut menu, &env, &[(-999, 1, Input::Pickup)]).unwrap();
assert!(threw, "one of the carried stack outside the window");
}
#[test]
fn placement_preview_checks_obstruction_without_changing_the_level() {
use super::DryLevel;
@@ -135,6 +135,7 @@ impl Game<'_> {
continue;
};
match (index, value) {
(0, Value::Byte(flags)) => f.invisible = (*flags as u8 & 0x20) != 0,
(8, Value::Id(d)) => f.facing = (*d).clamp(0, 5) as u8,
(10, Value::Int(r)) => f.rotation = r.rem_euclid(8) as u8,
_ => {}
+1 -2
View File
@@ -22,8 +22,7 @@ impl Screen {
| Self::Friends
| Self::ServerLinks
| Self::ConfirmLink
| Self::DialogWarning
| Self::Mod => true,
| Self::DialogWarning => true,
Self::Playing
| Self::GameModeSwitcher
| Self::Chat
@@ -1,576 +0,0 @@
//! 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
}
pub(super) fn apply_mod_block_writes(&mut self) {}
}
#[cfg(feature = "mods")]
mod run {
use super::{Game, LanHost, ModEvent, Who};
use horizon_client::chunk_map::ChunkMap;
use horizon_client::{
command::args::GameMode as Mode,
lan::data::component_nbt,
text::{self, Text},
};
use horizon_mods::{
ArgumentKind, BlockPos, GameMode, Output, ServerCommand, ServerPlayer, WorldView,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
/// Blocks queued for levels that don't tick (another dimension, in-frame)
/// are kept up to this many; the oldest go first.
const MAX_QUEUED_WRITES: usize = 1 << 16;
/// Capability `world`'s view during a call: the levels' chunks, moved out
/// of the game for the call and back after it (no copy).
struct LentWorld {
registry: &'static BlockRegistry,
biomes: std::sync::Arc<horizon_client::biome::Biomes>,
dims: Vec<(&'static str, ChunkMap)>,
}
impl WorldView for LentWorld {
fn block(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(state_string(self.registry, chunk.block(x & 15, y, z & 15)))
}
fn biome(&self, dimension: &str, x: i32, y: i32, z: i32) -> Option<String> {
let (_, chunks) = self.dims.iter().find(|(d, _)| *d == dimension)?;
let chunk = chunks.get(&(x >> 4, z >> 4))?;
Some(self.biomes.get(chunk.biome(x, y, z)).name.clone())
}
fn has_dimension(&self, dimension: &str) -> bool {
matches!(
dimension,
"minecraft:overworld" | "minecraft:the_nether" | "minecraft:the_end"
)
}
fn check_state(&self, state: &str) -> Result<(), String> {
crate::game::render_control::parse_state(self.registry, state)
.map(drop)
.map_err(|e| format!("{state}: {e}"))
}
}
/// Server mods' commands as a root of the commands report
/// (`lan::commands`): a literal per command, then its arguments in a
/// chain, each node runnable where only optional arguments follow.
fn command_tree<'a>(commands: impl Iterator<Item = &'a ServerCommand>) -> Value {
let mut root = serde_json::Map::new();
for c in commands {
let mut next: Option<(String, Value)> = None;
for (i, a) in c.arguments.iter().enumerate().rev() {
let executable = c.arguments.get(i + 1).is_none_or(|b| b.optional);
let (parser, properties) = match a.kind {
ArgumentKind::Word => ("brigadier:string", json!({"type": "word"})),
ArgumentKind::Text => ("brigadier:string", json!({"type": "greedy"})),
ArgumentKind::Integer((min, max)) => {
let mut p = serde_json::Map::new();
if let Some(min) = min {
p.insert("min".into(), min.into());
}
if let Some(max) = max {
p.insert("max".into(), max.into());
}
("brigadier:integer", Value::Object(p))
}
ArgumentKind::Number => ("brigadier:double", Value::Null),
ArgumentKind::Boolean => ("brigadier:bool", Value::Null),
ArgumentKind::Player => (
"minecraft:entity",
json!({"amount": "single", "type": "players"}),
),
ArgumentKind::BlockPos => ("minecraft:block_pos", Value::Null),
};
let mut node = json!({
"type": "argument",
"parser": parser,
"properties": properties,
"executable": executable,
});
if let Some((name, child)) = next.take() {
node["children"] = json!({ name: child });
}
next = Some((a.name.clone(), node));
}
let executable = c.arguments.first().is_none_or(|a| a.optional);
let mut literal = json!({"type": "literal", "executable": executable});
if let Some((name, child)) = next {
literal["children"] = json!({ name: child });
}
root.insert(c.name.clone(), literal);
}
if root.is_empty() {
return Value::Null;
}
json!({"type": "root", "children": root})
}
/// `minecraft:oak_log[axis=y]`.
fn state_string(registry: &BlockRegistry, state: BlockStateId) -> String {
let Some(s) = registry.state(state) else {
return "minecraft:air".into();
};
if s.properties.is_empty() {
return s.name.clone();
}
let props: Vec<String> = s
.properties
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
format!("{}[{}]", s.name, props.join(","))
}
/// `UUID.toString()`.
fn uuid_string(u: u128) -> String {
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: uuid_string(self.player_uuid()),
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();
}
self.send_server_mod_commands();
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);
self.lend_world();
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 => uuid_string(self.player_uuid()),
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.return_world();
self.mods.players = players;
self.show_mod_output();
self.apply_mod_block_writes();
}
/// Players' command trees list the running server mods' commands
/// after vanilla's, whenever they change (or a hub opens).
fn send_server_mod_commands(&mut self) {
let key = self.mods.mods.generation();
let Some(host) = self.lan.as_mut() else {
return;
};
if host.hub.mod_commands_key() == Some(key) {
return;
}
let tree = command_tree(self.mods.mods.server_commands());
host.hub.set_mod_commands(key, tree);
}
/// Lends the levels' chunks to capability `world` for the calls up to
/// [`Game::return_world`], when a running server mod has it.
fn lend_world(&mut self) {
if !self.mods.world {
return;
}
let mut dims = vec![(self.dim.id(), std::mem::take(&mut self.world.chunks))];
for (dim, chunks) in self.levels.parked_chunks() {
dims.push((dim.id(), std::mem::take(chunks)));
}
let registry = self.pack.assets.registry;
let biomes = self.res.biomes().clone();
self.mods.mods.lend_world(Box::new(LentWorld {
registry,
biomes,
dims,
}));
}
/// Puts back the chunks [`Game::lend_world`] lent.
fn return_world(&mut self) {
let Some(view) = self.mods.mods.take_world() else {
return;
};
let view: Box<dyn std::any::Any> = view;
let Ok(lent) = view.downcast::<LentWorld>() else {
return;
};
let mut dims = lent.dims;
if let Some(i) = dims.iter().position(|(d, _)| *d == self.dim.id()) {
self.world.chunks = dims.swap_remove(i).1;
}
for (dim, chunks) in self.levels.parked_chunks() {
if let Some(i) = dims.iter().position(|(d, _)| *d == dim.id()) {
*chunks = dims.swap_remove(i).1;
}
}
}
/// Makes the blocks server mods set in the level in place, in order,
/// as `/setblock` does. Those of other dimensions wait for their level.
pub(in crate::game) fn apply_mod_block_writes(&mut self) {
if self.mods.block_writes.is_empty() {
return;
}
let dim = self.dim.id();
let (now, later): (Vec<_>, Vec<_>) = std::mem::take(&mut self.mods.block_writes)
.into_iter()
.partition(|(d, _)| d == dim);
self.mods.block_writes = later;
let registry = self.pack.assets.registry;
for (_, b) in now {
let state = match crate::game::render_control::parse_state(registry, &b.state) {
Ok(state) => state,
Err(e) => {
log::warn!("Horizon: a server mod set {}: {e}", b.state);
continue;
}
};
if let Err(e) = self.place_state([b.x, b.y, b.z], state, false) {
log::debug!(
"Horizon: a server mod's block at {} {} {}: {e:?}",
b.x,
b.y,
b.z
);
}
}
}
/// A player broke (or placed) `state` at `pos`: the player acting now.
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);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
self.show_mod_output();
self.apply_mod_block_writes();
owned
}
/// `/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);
self.lend_world();
let owned = self.mods.mods.server_command(&me, line);
self.return_world();
if !owned {
return false;
}
self.route_mod_output(Some(host));
self.apply_mod_block_writes();
true
}
/// 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 && uuid_string(self.player_uuid()) == 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);
}
}
Output::SetBlocks {
name,
dimension,
blocks,
} => {
let queued = &mut self.mods.block_writes;
queued.extend(blocks.into_iter().map(|b| (dimension.clone(), b)));
if queued.len() > MAX_QUEUED_WRITES {
let over = queued.len() - MAX_QUEUED_WRITES;
queued.drain(..over);
log::warn!(
"Horizon: mod {name} set more blocks than wait for their level: {over} dropped"
);
}
}
_ => {}
}
}
}
}
+260
View File
@@ -0,0 +1,260 @@
//! The displayed items of `ShelfBlockEntity`.
use super::{Game, Hit, machines::dir_from_name};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{block_entity_render::vertex_light, containers::BlockEntity, mesher::Vertex};
use horizon_core::{
geometry::{Direction, Vec3},
position::BlockPos,
};
use horizon_world::level::hit_slot;
impl Game<'_> {
/// Slot and facing for a click on a shelf's front face.
pub(super) fn shelf_hit(&self, hit: &Hit) -> Option<(usize, Direction, bool)> {
let p = hit.pos;
if !matches!(
self.world.block_entities.get(&(p[0], p[1], p[2])),
Some(BlockEntity::Shelf(_))
) {
return None;
}
let state = self.world.block(p[0], p[1], p[2]);
let data = self.pack.assets.registry.state(state)?;
let facing = data
.properties
.get("facing")
.and_then(|s| dir_from_name(s))?;
let face = Direction::ALL
.into_iter()
.find(|d| d.normal() == hit.face)?;
let slot = hit_slot(
facing,
face,
BlockPos::new(p[0], p[1], p[2]),
Vec3::new(hit.point[0], hit.point[1], hit.point[2]),
1,
3,
)?;
Some((
slot,
facing,
data.properties.get("powered").is_some_and(|s| s == "true"),
))
}
/// `SideChainPartBlock.getAllBlocksConnectedTo`: left to right, at most three.
fn shelf_chain(&self, pos: [i32; 3], facing: Direction) -> Vec<[i32; 3]> {
let left = match facing {
Direction::North => Direction::East,
Direction::East => Direction::South,
Direction::South => Direction::West,
Direction::West => Direction::North,
_ => return Vec::new(),
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let behaviours = self.res.behaviours();
if !behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
{
return Vec::new();
}
let mut result = vec![pos];
for (dir, end, prepend) in [(left, "left", true), (left.opposite(), "right", false)] {
let normal = dir.normal();
for step in 1..3 {
let p = [pos[0] + normal[0] * step, pos[1], pos[2] + normal[2] * step];
let state = self.world.block(p[0], p[1], p[2]);
let Some(info) = self.pack.assets.registry.state(state) else {
break;
};
let behaviours = self.res.behaviours();
let connectable = behaviours
.tag("minecraft:wooden_shelves")
.is_some_and(|tag| tag.contains(behaviours.block(state)))
&& info.properties.get("powered").is_some_and(|s| s == "true")
&& info.properties.get("facing").and_then(|s| dir_from_name(s)) == Some(facing);
if !connectable {
break;
}
let part = info
.properties
.get("side_chain")
.map(String::as_str)
.unwrap_or("unconnected");
if part == "center" || part == end {
if prepend {
result.insert(0, p);
} else {
result.push(p);
}
}
if part != "center" {
break;
}
}
}
result
}
/// `ShelfBlock.useItemOn`: a single swap, or a connected hotbar swap while powered.
pub(super) fn use_shelf(&mut self, hit: &Hit) -> Option<bool> {
use horizon_client::first_person::Hand;
if self.hand != Hand::Main {
return None;
}
let (slot, facing, powered) = self.shelf_hit(hit)?;
let pos = hit.pos;
let state = self.world.block(pos[0], pos[1], pos[2]);
if powered {
let chain = self.shelf_chain(pos, facing);
let mut swapped = false;
let count = chain.len();
for (i, p) in chain.into_iter().enumerate() {
let key = (p[0], p[1], p[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key)
else {
continue;
};
for n in 0..3 {
let index = 9_i32 - (count as i32 - i as i32) * 3 + n as i32;
if !(0..9).contains(&index) {
continue;
}
let index = index as usize;
swapped |= self.inventory[index].is_some() || shelf.items[n].is_some();
std::mem::swap(&mut self.inventory[index], &mut shelf.items[n]);
}
self.container_changed(key);
let s = self.world.block(p[0], p[1], p[2]);
self.post_block_game_event("minecraft:entity_interact", p, None, Some(s));
}
if !swapped {
return Some(false);
}
self.audio.play(
"minecraft:block.shelf.multi_swap",
Some(pos.map(|n| f64::from(n) + 0.5)),
1.0,
1.0,
);
Some(true)
} else {
let held = self.inventory[self.selected].clone();
let key = (pos[0], pos[1], pos[2]);
let Some(BlockEntity::Shelf(shelf)) = self.world.block_entities.get_mut(&key) else {
return None;
};
let removed = std::mem::replace(&mut shelf.items[slot], held.clone());
self.inventory[self.selected] = if self.creative && removed.is_none() {
held.clone()
} else {
removed.clone()
};
self.container_changed(key);
let vibrations = self.inventory[self.selected].as_ref().is_none_or(|stack| {
let default = self
.res
.items()
.get(&stack.item)
.is_none_or(|item| item.interact_vibrations);
let Some(data) = stack.data() else {
return default;
};
if data
.other
.iter()
.any(|(key, _)| key == "!minecraft:use_effects")
{
return true;
}
data.other
.iter()
.find(|(key, _)| key == "minecraft:use_effects")
.map_or(default, |(_, tag)| {
horizon_client::save::number(tag.get("interact_vibrations")).unwrap_or(1)
!= 0
})
});
if vibrations {
self.post_block_game_event(
"minecraft:item_interact_finish",
pos,
None,
Some(state),
);
}
let sound = match (held.is_some(), removed.is_some()) {
(false, false) => return None,
(true, false) => "minecraft:block.shelf.place_item",
(false, true) => "minecraft:block.shelf.take_item",
(true, true) => "minecraft:block.shelf.single_swap",
};
self.audio
.play(sound, Some(pos.map(|n| f64::from(n) + 0.5)), 1.0, 1.0);
Some(true)
}
}
/// `ShelfRenderer`: three items face out, aligned to the bottom or centre.
pub(super) fn shelf_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
for (key, entity) in &self.world.block_entities {
let BlockEntity::Shelf(shelf) = entity else {
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;
}
let block = self.world.block(key.0, key.1, key.2);
let facing = self
.pack
.assets
.registry
.state(block)
.and_then(|s| s.properties.get("facing"))
.map(String::as_str)
.unwrap_or("north");
let yaw: f32 = match facing {
"south" => 0.0,
"west" => -90.0,
"north" => -180.0,
"east" => -270.0,
_ => 180.0,
};
let packed = BlockPos::new(key.0, key.1, key.2).pack() as u64;
let seed = (packed ^ (packed >> 32)) as i32;
let light = vertex_light(self.world.light(key.0, key.1, key.2));
for (slot, stack) in shelf.items.iter().enumerate() {
let Some(stack) = stack else { continue };
let item = self.resolve_item(
stack,
DisplayContext::OnShelf,
None,
seed.wrapping_add(slot as i32),
);
if item.is_empty() {
continue;
}
let (lo, hi) = item.bounding_box();
let offset_y = -lo.y
- if shelf.align_to_bottom {
0.0
} else {
(hi.y - lo.y) / 2.0
};
let pose =
glam::Mat4::from_translation((at - camera).as_vec3() + glam::Vec3::splat(0.5))
* glam::Mat4::from_rotation_y(yaw.to_radians())
* glam::Mat4::from_translation(glam::Vec3::new(
(slot as f32 - 1.0) * 0.3125,
if shelf.align_to_bottom { -0.25 } else { 0.0 },
-0.25,
))
* glam::Mat4::from_scale(glam::Vec3::splat(0.25))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, offset_y, 0.0));
self.submit_item_world(&item, pose, light, out);
}
}
}
}
+31
View File
@@ -407,9 +407,40 @@ impl Game<'_> {
}
// sendBlockUpdated(3) and the player's block_change game event.
self.spawner_block_updated(key);
self.update_spawner_display(key);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
Some(true)
}
/// Ticks spawners and trial spawners near the player on a remote client (`tick_remote`).
pub(super) fn tick_remote_spawners(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let center = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let mut keys = Vec::new();
let mut trial_keys = Vec::new();
for (&pos, be) in &self.world.block_entities {
let d = (pos.0 - center[0])
.abs()
.max((pos.1 - center[1]).abs())
.max((pos.2 - center[2]).abs());
if d <= 64 {
match be {
BlockEntity::Spawner(_) => keys.push(pos),
BlockEntity::TrialSpawner(_) => trial_keys.push(pos),
_ => {}
}
}
}
self.client_tick_spawners(&keys);
for key in trial_keys {
self.client_tick_trial_spawner(key);
}
}
/// `SpawnerRenderer` and `TrialSpawnerRenderer`: the next mob of every
/// spawner near the camera, small and turning.
pub(super) fn spawner_geometry(
+11 -7
View File
@@ -76,6 +76,7 @@ impl StartupMenu {
&self.root,
);
title.last_server = settings.file.string("lastServer");
title.skip_multiplayer_warning = settings.file.bool("skipMultiplayerWarning");
self.title = Some(title);
}
}
@@ -320,12 +321,11 @@ impl Loading {
}
fn save_menu_address(&mut self) {
if self
.menu
.title
.as_ref()
.is_some_and(|title| title.last_server != self.settings.file.string("lastServer"))
{
if self.menu.title.as_ref().is_some_and(|title| {
title.last_server != self.settings.file.string("lastServer")
|| title.skip_multiplayer_warning
!= self.settings.file.bool("skipMultiplayerWarning")
}) {
self.save_menu_settings();
}
}
@@ -336,6 +336,10 @@ impl Loading {
"lastServer",
horizon_client::options_file::V::Str(title.last_server.clone()),
);
self.settings.file.set(
"skipMultiplayerWarning",
horizon_client::options_file::V::Bool(title.skip_multiplayer_warning),
);
}
self.settings.save(&Game::options_path());
}
@@ -421,7 +425,7 @@ impl Loading {
}
}
// Mods' settings pages open only in a running game.
Event::ModSettings(_) | Event::NativeSettings(_) | Event::NativeButton(..) => {}
Event::NativeSettings(_) | Event::NativeButton(..) => {}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
+109 -11
View File
@@ -556,11 +556,14 @@ pub(super) fn submit(
} else {
"entity/shield/shield_base_nopattern"
};
let foil = if has_foil && !has_patterns {
Foil::Standard
} else {
Foil::None
};
// Without patterns: `entitySolidGlint`. With them, vanilla draws
// `patternedShieldGlint` last, the glint alone over the finished
// plate. A foil quad here is its texture with the glint over it
// (the GUI adds the glint masked by the layer's alpha, the world
// shader composites it), so every layer carries the foil: each
// pixel ends with the glint once, over the layers below it.
// Redrawing the base with the foil would cover the patterns.
let foil = if has_foil { Foil::Standard } else { Foil::None };
part("shield", &none, base, white, foil);
if has_patterns {
part(
@@ -568,17 +571,13 @@ pub(super) fn submit(
&none,
"entity/shield/base",
dye(base_color.unwrap_or("white")),
Foil::None,
foil,
);
for (pattern, color) in patterns.into_iter().take(16) {
if let Some(t) = pattern_texture(host, false, &pattern) {
part("shield", &none, &t, dye(&color), Foil::None);
part("shield", &none, &t, dye(&color), foil);
}
}
if has_foil {
// `patternedShieldGlint`: the glint alone over the model.
part("shield", &none, base, white, Foil::Standard);
}
}
}
SpecialKind::DecoratedPot => {
@@ -688,6 +687,105 @@ mod tests {
);
}
}
/// `ShieldSpecialRenderer.submit` of an enchanted shield with a banner:
/// the glint goes over the patterns, and nothing covers them after.
#[test]
fn an_enchanted_banner_shield_keeps_its_patterns_under_the_glint() {
let root =
std::env::temp_dir().join(format!("horizon-shield-glint-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("items.json"), "{}").unwrap();
std::fs::write(
root.join("entity-models.json"),
serde_json::json!({"minecraft:shield#main": {
"pose": [0, 0, 0, 0, 0, 0, 1, 1, 1],
"polygons": [[[0, 0, 0, 0, 0], [1, 0, 0, 1, 0],
[1, 1, 0, 1, 1], [0, 1, 0, 0, 1]]],
"children": {}
}})
.to_string(),
)
.unwrap();
let library = crate::model_library::ParsedModels::default().into_item_library();
let renderer = ItemRenderer::load_with_library(&root, library).unwrap();
let _ = std::fs::remove_dir_all(&root);
let textures = [
"entity/shield/shield_base",
"entity/shield/shield_base_nopattern",
"entity/shield/base",
"entity/shield/stripe_bottom",
];
// A sprite per texture, told apart by its u0.
let sprites: HashMap<String, Rect> = textures
.iter()
.enumerate()
.map(|(i, t)| {
let u0 = i as f32 / 8.0;
let rect = Rect {
u0,
v0: 0.0,
u1: u0 + 0.1,
v1: 0.1,
w: 16,
h: 16,
};
((*t).to_owned(), rect)
})
.collect();
let drawn = |argument: Value, has_foil: bool| {
let mut quads: Vec<ItemQuad> = Vec::new();
submit(
&renderer,
&SpecialModel {
kind: SpecialKind::Shield,
},
&argument,
has_foil,
Mat4::IDENTITY,
&sprites,
&super::super::NoHost,
&mut quads,
);
quads
.iter()
.map(|q| {
let texture = textures
.iter()
.find(|t| sprites[**t].u0 == q.uv[0][0].min(q.uv[1][0]))
.unwrap();
(*texture, q.foil)
})
.collect::<Vec<_>>()
};
let banner = serde_json::json!({
"minecraft:base_color": "red",
"minecraft:banner_patterns": [{
"pattern": {"asset_id": "minecraft:stripe_bottom",
"translation_key": "block.minecraft.banner.stripe_bottom"},
"color": "blue"
}]
});
assert_eq!(
drawn(banner.clone(), true),
[
("entity/shield/shield_base", Foil::Standard),
("entity/shield/base", Foil::Standard),
("entity/shield/stripe_bottom", Foil::Standard),
]
);
assert_eq!(
drawn(banner, false),
[
("entity/shield/shield_base", Foil::None),
("entity/shield/base", Foil::None),
("entity/shield/stripe_bottom", Foil::None),
]
);
assert_eq!(
drawn(Value::Null, true),
[("entity/shield/shield_base_nopattern", Foil::Standard)]
);
}
/// `DecoratedPotRenderer` draws every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces: the pot's flat `top` and `bottom`
/// planes would otherwise show both sides and z-fight in the hand too.
+17
View File
@@ -940,6 +940,23 @@ pub fn entity_tag(entity: &BlockEntity) -> Option<Tag> {
match entity {
BlockEntity::Sign(sign) => Some(Tag::compound(sign.encode())),
BlockEntity::Spawner(s) => Some(s.update_tag()),
BlockEntity::Brushable(b) => {
let mut entries = Vec::new();
if let Some(face) = b.hit_direction {
entries.push(("hit_direction", Tag::Byte(face as i8)));
}
if let Some(item) = &b.item {
entries.push(("item", crate::save::item_to_nbt(item)));
}
Some(Tag::compound(entries))
}
BlockEntity::Shelf(s) => Some(Tag::compound([
("Items", crate::save::encode_items(&s.items)),
(
"align_items_to_bottom",
Tag::Byte(i8::from(s.align_to_bottom)),
),
])),
// BannerBlockEntity.getUpdateTag: saveWithoutMetadata, so the patterns,
// the custom name and the components.
BlockEntity::Banner(b) => {
-1
View File
@@ -74,7 +74,6 @@ pub mod merchant;
pub mod mobs;
pub mod mod_content;
pub mod motion;
pub mod moved_extras;
pub mod multiplayer;
pub mod music;
pub mod net;
+5
View File
@@ -2313,6 +2313,10 @@ impl Mob {
{
aquatic::spawn_ink(self);
}
// AbstractHorse.hurtServer: the rear roll after the hurt.
if equine::is_equine(self.species()) {
equine::after_hurt_server(self);
}
true
}
/// A splash or cloud's harming effect on this mob: the potion's
@@ -2448,6 +2452,7 @@ impl Mob {
self.invisible || self.has_effect("minecraft:invisibility")
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
let effect = horizon_client_core::potions::namespaced(effect);
if let Some(e) = self.extra.effects.iter_mut().find(|e| e.0 == effect) {
// MobEffectInstance.update: a stronger effect replaces with its
// own duration, an equal one only extends it; a weaker one is
+67 -3
View File
@@ -475,7 +475,14 @@ pub fn tick(
if m.species() == Species::ZombieVillager {
// ZombieVillager.tick counts the cure down before super.tick, with
// or without AI (NoAI only skips serverAiStep).
super::zombie_villager::step(m, &mut Cx { w, events });
super::zombie_villager::step(
m,
&mut Cx {
w,
lr: level_random,
events,
},
);
}
super::warden::pre_tick(m, w, events);
if m.species() == Species::Strider {
@@ -500,6 +507,13 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
&& !super::llama::is_llama(m.species())
{
super::equine::stand_tick(m);
}
} else {
ai_step(m, w, level_random, events);
}
@@ -1205,7 +1219,11 @@ fn server_ai_step(
let mut targets = std::mem::take(&mut m.ai.targets);
let mut goals = std::mem::take(&mut m.ai.goals);
{
let mut cx = Cx { w, events };
let mut cx = Cx {
w,
lr: level_random,
events,
};
if id_based % 2 != 0 && tick_count > 1 {
targets.tick_running(m, &mut cx, false);
goals.tick_running(m, &mut cx, false);
@@ -1246,7 +1264,11 @@ fn server_ai_step(
super::animal_brain::tick(m, w, level_random, events);
}
{
let mut cx = Cx { w, events };
let mut cx = Cx {
w,
lr: level_random,
events,
};
custom_server_ai_step(m, &mut cx);
}
if frozen {
@@ -2287,3 +2309,45 @@ mod frog_tests {
);
}
}
#[cfg(test)]
mod poison_effect_tests {
use super::*;
use crate::mobs::MobTypes;
use std::sync::Arc;
#[test]
fn poison_effect_damages_living_mobs_but_not_undead() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists() {
return;
}
let data = MobTypes::load(&root).unwrap();
// Pig is not undead: should take damage from poison.
let pig_kind = Arc::new(data.types.get("minecraft:pig").unwrap().clone());
let mut pig = Mob::new(1, pig_kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
pig.add_effect("poison", 100, 0);
assert_eq!(pig.extra.effects[0].0, "minecraft:poison");
let initial_pig_health = pig.health;
tick_effects(&mut pig);
assert_eq!(
pig.health,
initial_pig_health - 1.0,
"living mob should take 1.0 damage from poison"
);
// Zombie is undead: immune to poison damage.
let zombie_kind = Arc::new(data.types.get("minecraft:zombie").unwrap().clone());
let mut zombie = Mob::new(2, zombie_kind, Vec3::new(0.0, 4.0, 0.0), 0.0);
zombie.add_effect("poison", 100, 0);
let initial_zombie_health = zombie.health;
tick_effects(&mut zombie);
assert_eq!(
zombie.health, initial_zombie_health,
"undead mobs are immune to poison"
);
}
}
@@ -2396,6 +2396,7 @@ fn axolotl_action(m: &mut Mob, cx: &mut Bx, action: AxolotlAction, until: &mut i
target,
&mut super::goals::Cx {
w: cx.w,
lr: cx.lr,
events: cx.events,
},
);
+43 -3
View File
@@ -103,8 +103,15 @@ pub(super) fn dangerous_block(name: &str) -> bool {
| "minecraft:powder_snow"
)
}
/// `LongJumpUtil.calculateJumpVectorForAngle` without the collision check.
fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<Vec3> {
/// `LongJumpUtil.calculateJumpVectorForAngle`; with a world, the arc is
/// checked for collisions (`checkCollision`).
pub(super) fn jump_vector(
m: &Mob,
target: Vec3,
max_velocity: f32,
angle: i32,
collide: Option<&MobWorld>,
) -> Option<Vec3> {
let pos = m.position;
let plane = Vec3::new(target.x - pos.x, 0.0, target.z - pos.z)
.normalize()
@@ -130,8 +137,41 @@ fn jump_vector(m: &Mob, target: Vec3, max_velocity: f32, angle: i32) -> Option<V
return None;
}
let (v0r, v0y) = (v0 * cos, v0 * sin);
if let Some(w) = collide {
// `ceil(r / v0r) * 2` points along the arc, each step clear of blocks.
let samples = (r / v0r).ceil() as i32 * 2;
let (sin_xz, cos_xz) = (jmath::sin(xz), jmath::cos(xz));
let mut ri = 0.0;
let mut previous: Option<Vec3> = None;
for _ in 0..samples - 1 {
ri += r / f64::from(samples);
let yi = sin / cos * ri - jmath::pow(ri, 2.0) * g / (2.0 * v0sq * jmath::pow(cos, 2.0));
let p = Vec3::new(pos.x + ri * cos_xz, pos.y + yi, pos.z + ri * sin_xz);
if previous.is_some_and(|q| !clear_transition(m, w, q, p)) {
return None;
}
previous = Some(p);
}
}
Some(Vec3::new(v0r * jmath::cos(xz), v0y, v0r * jmath::sin(xz)).scale(f64::from(0.95_f32)))
}
/// `LongJumpUtil.isClearTransition`: the box fits at points no further apart
/// than nine tenths of its smaller side.
fn clear_transition(m: &Mob, w: &MobWorld, from: Vec3, to: Vec3) -> bool {
let d = to.subtract(from);
let min = f64::from(m.width().min(m.height()));
let checks = (d.length() / min).ceil() as i32;
let step = d.normalize().scale(min * f64::from(0.9_f32));
let mut p = from;
(0..checks).all(|i| {
p = if i == checks - 1 {
to
} else {
p.add_vector(step)
};
w.no_collision(m.bounds_at(p))
})
}
/// `LongJump.calculateOptimalJumpVector`: the allowed angles shuffled, the
/// first that reaches the target.
pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
@@ -143,7 +183,7 @@ pub(super) fn optimal_jump_vector(m: &mut Mob, target: Vec3) -> Option<Vec3> {
let max = 0.058_333_334_f32 * m.attrs.follow_range as f32;
let v = angles
.iter()
.find_map(|&a| jump_vector(m, target, max, a))?;
.find_map(|&a| jump_vector(m, target, max, a, None))?;
if m.has_effect("minecraft:jump_boost") {
let amp = m
.extra
+71 -5
View File
@@ -304,15 +304,27 @@ impl Goal for RunAroundLikeCrazy {
make_mad(m, cx.events);
}
}
/// `AbstractHorse.standIfPossible`: `setStanding(20)` (which also stops the
/// eating) when `canPerformRearing`.
fn stand_if_possible(m: &mut Mob) {
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
}
/// `AbstractHorse.hurtServer`, after the hurt went through: one hit in three
/// rears the horse. Llamas and camels make the draw too, but never rear.
pub fn after_hurt_server(m: &mut Mob) {
if m.random.next_int_bound(3).unwrap_or(1) == 0 {
stand_if_possible(m);
}
}
/// `AbstractHorse.makeMad`: rear with the angry sound (`makeSound`).
pub fn make_mad(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.counter > 0 {
return;
}
if can_rear(m.species()) {
m.extra.counter = 20;
m.extra.flag = false;
}
stand_if_possible(m);
// getAngrySound: null (silent) for the skeleton horse.
let sound = match m.species() {
Species::SkeletonHorse => return,
@@ -663,10 +675,15 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
m.extra.flag = false;
}
}
stand_tick(m);
animate(m);
}
/// `AbstractHorse.tick`: the stand counter runs down to `clearStanding`. It is
/// in `tick`, not `aiStep`, so a horse with no AI stops rearing too.
pub fn stand_tick(m: &mut Mob) {
if m.extra.counter > 0 {
m.extra.counter -= 1;
}
animate(m);
}
/// `AbstractHorse.tick`'s eat, stand and mouth animations, which follow the
/// eating and standing flags (`flag` and `counter > 0`).
@@ -856,10 +873,13 @@ pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mobs::Damage;
use crate::mobs::finalize::base_value;
use crate::mobs::sensor_scan_tests::kind;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
@@ -889,4 +909,50 @@ mod tests {
finalize(&mut m);
assert_eq!(base_value(&m, Attr::MovementSpeed), m.attrs.speed);
}
fn equine(id: u32, species: Species, name: &str) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = name.into();
k.species = species;
k.max_health = 30.0;
Mob::new(id, kind, Vec3::new(f64::from(id), 64.0, 0.0), 0.0)
}
/// Hits `n` fresh mobs of a species once each; how many reared.
fn rears(species: Species, name: &str, n: u32) -> u32 {
(1..=n)
.filter(|&id| {
let mut m = equine(id, species, name);
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 0.0])));
m.extra.counter == 20
})
.count() as u32
}
#[test]
fn a_hurt_horse_rears_one_hit_in_three() {
// AbstractHorse.hurtServer: nextInt(3) == 0 -> standIfPossible (#3432).
let n = rears(Species::Horse, "minecraft:horse", 300);
assert!((60..=140).contains(&n), "{n} of 300 hits reared");
}
#[test]
fn the_rear_ends_after_twenty_ticks() {
// AbstractHorse.tick: standCounter runs down, then clearStanding.
let mut m = equine(1, Species::Horse, "minecraft:horse");
stand_if_possible(&mut m);
for _ in 0..19 {
stand_tick(&mut m);
}
assert!(m.extra.counter > 0);
stand_tick(&mut m);
assert_eq!(m.extra.counter, 0);
}
#[test]
fn llamas_and_camels_never_rear_when_hurt() {
// Llama and Camel: canPerformRearing is false.
assert_eq!(rears(Species::Llama, "minecraft:llama", 60), 0);
assert_eq!(rears(Species::Camel, "minecraft:camel", 60), 0);
}
}
+398 -3
View File
@@ -1,5 +1,5 @@
//! Frogs (`Frog`, `FrogAi`): the `Croak` and `ShootTongue` behaviours as
//! goals, the poses they set and the client's animation states.
//! Frogs (`Frog`, `FrogAi`): the `Croak`, `ShootTongue` and long jump
//! behaviours as goals, the poses they set and the client's animation states.
//! AnimState states: jump, croak, tongue, swim idle.
//!
//! The brain's idle `RunOne` picks a croak three times as often as a stroll
@@ -13,7 +13,10 @@ use super::{
},
sync,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
const JUMP: usize = 0;
const CROAK: usize = 1;
@@ -30,6 +33,25 @@ const EATING_DISTANCE: f64 = 1.75;
const DETECTION_DISTANCE: f64 = 10.0;
const SCAN_RATE: i32 = 20;
/// `FrogAi.TIME_BETWEEN_LONG_JUMPS` and the brain memory
/// `LONG_JUMP_COOLDOWN_TICKS` (saved with the others, as `finalize` sets it).
const TIME_BETWEEN_LONG_JUMPS: (i32, i32) = (100, 140);
const LONG_JUMP_COOLDOWN: &str = "long_jump_cooldown";
/// `FrogAi`'s `LongJumpToPreferredBlock`: `MAX_LONG_JUMP_WIDTH`,
/// `MAX_LONG_JUMP_HEIGHT`, `MAX_JUMP_VELOCITY_MULTIPLIER` and the chance to
/// look for a lily pad or big dripleaf first.
const JUMP_WIDTH: i32 = 4;
const JUMP_HEIGHT: i32 = 2;
const JUMP_VELOCITY_MULTIPLIER: f32 = 3.571_428_8;
const PREFERRED_BLOCKS_CHANCE: f32 = 0.5;
/// `LongJumpToRandomPos.ALLOWED_ANGLES`, `FIND_JUMP_TRIES` and
/// `PREPARE_JUMP_DURATION`.
const ALLOWED_ANGLES: [i32; 4] = [65, 70, 75, 80];
const FIND_JUMP_TRIES: i32 = 20;
const PREPARE_JUMP_DURATION: i32 = 40;
/// `LongJumpMidJump`'s duration (`TIME_OUT_DURATION`).
const MID_JUMP_DURATION: i32 = 100;
/// `ShootTongue.State`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum Phase {
@@ -56,6 +78,8 @@ pub struct State {
eat_timer: i32,
path_counter: i32,
time: i32,
/// `LivingEntity.discardFriction`, set through the long jump.
pub discard_friction: bool,
}
pub fn register(m: &mut Mob) {
@@ -74,6 +98,7 @@ pub fn register(m: &mut Mob) {
g.add(0, Float);
g.add(1, Panic::new(1.25));
g.add(2, ShootTongue);
g.add(3, LongJump::default());
g.add(4, Croak);
g.add(5, Stroll::avoiding_water(1.0).interval(1));
g.add(6, LookAtPlayer::new(6.0));
@@ -119,6 +144,13 @@ fn can_eat(species: Species, size: i32) -> bool {
/// `FrogAttackablesSensor` (every 20 ticks) and the memory timers.
pub fn server_ai_step(m: &mut Mob, w: &MobWorld) {
// `CountDownCooldownTicks(LONG_JUMP_COOLDOWN_TICKS)`: one less each tick,
// erased the tick after it reaches 0.
match jump_cooldown(m) {
Some(t) if t > 0 => set_jump_cooldown(m, Some(t - 1)),
Some(_) => set_jump_cooldown(m, None),
None => {}
}
let f = &mut m.extra.frog;
f.unreachable.retain_mut(|(_, ttl)| {
*ttl -= 1;
@@ -310,3 +342,366 @@ impl Goal for ShootTongue {
}
}
}
/// `LONG_JUMP_COOLDOWN_TICKS`.
fn jump_cooldown(m: &Mob) -> Option<i64> {
m.extra
.memories
.iter()
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
.map(|x| x.1)
}
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
let memories = &mut m.extra.memories;
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
match (at, ticks) {
(Some(i), Some(t)) => memories[i].1 = t,
(Some(i), None) => {
memories.remove(i);
}
(None, Some(t)) => memories.push((LONG_JUMP_COOLDOWN, t, i64::MAX)),
(None, None) => {}
}
}
/// `TIME_BETWEEN_LONG_JUMPS.sample` from the level's random, divided as the
/// caller asks.
fn sample_cooldown(m: &mut Mob, lr: &mut LegacyRandom, div: i32) {
let (lo, hi) = TIME_BETWEEN_LONG_JUMPS;
let t = lr.next_int_between_inclusive(lo, hi).unwrap_or(lo) / div;
set_jump_cooldown(m, Some(i64::from(t)));
}
/// `FrogAi.isAcceptableLandingSpot`: no fluid in the target, below or above
/// it; a lily pad or big dripleaf (`frog_prefer_jump_to`) or a trapdoor; else
/// `LongJumpToRandomPos.defaultAcceptableLandingSpot`: a solid block below
/// and a path type the frog walks without a malus.
fn acceptable_landing(m: &Mob, w: &MobWorld, [x, y, z]: [i32; 3]) -> bool {
use super::path::PathType;
if (y - 1..=y + 1).any(|y| w.fluid(x, y, z) != crate::blocks::Fluid::None) {
return false;
}
let preferred = |y| w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y, z));
if preferred(y) || preferred(y - 1) {
return true;
}
let here = w.path_type_static(x, y, z);
if here == PathType::Trapdoor
|| w.facts.is_air(w.world.block(x, y, z))
&& w.path_type_static(x, y - 1, z) == PathType::Trapdoor
{
return true;
}
w.facts.get(w.world.block(x, y - 1, z)).solid_render && m.malus(here) == 0.0
}
/// `LongJumpToRandomPos.PossibleJump`: a block and its weight.
type Candidate = ([i32; 3], i32);
/// `FrogAi`'s long jump activity: `LongJumpToPreferredBlock` looks for a spot
/// it cannot walk to, faces it for 40 ticks and leaps; `LongJumpMidJump`
/// holds the pose until it lands. One goal here, like the brain activity
/// that keeps the idle croak and stroll from starting meanwhile.
#[derive(Clone, Debug, Default)]
struct LongJump {
candidates: Vec<Candidate>,
not_preferred: Vec<Candidate>,
wanting_preferred: bool,
initial: Option<Vec3>,
chosen: Option<Vec3>,
/// The chosen spot, looked at while preparing.
look: [f64; 3],
tries: i32,
/// Ticks since the jump was chosen.
prepare: i32,
/// `LONG_JUMP_MID_JUMP`, and the ticks `LongJumpMidJump` has run.
mid_jump: bool,
in_air: Option<i32>,
}
impl LongJump {
/// `WeightedRandom.getRandomItem` with the level's random over the
/// candidates, removed once drawn.
fn draw(&mut self, lr: &mut LegacyRandom) -> Option<Candidate> {
let total: i32 = self.candidates.iter().map(|c| c.1).sum();
if total == 0 {
return None;
}
let mut pick = lr.next_int_bound(total).unwrap_or(0);
let i = self.candidates.iter().position(|c| {
pick -= c.1;
pick < 0
})?;
Some(self.candidates.remove(i))
}
/// `LongJumpToPreferredBlock.getJumpCandidate`: while it wants them, the
/// first drawn spot over a preferred block, else the first one passed over.
fn candidate(&mut self, w: &MobWorld, lr: &mut LegacyRandom) -> Option<Candidate> {
if !self.wanting_preferred {
return self.draw(lr);
}
while !self.candidates.is_empty() {
if let Some(c) = self.draw(lr) {
let [x, y, z] = c.0;
if w.has_tag("minecraft:frog_prefer_jump_to", w.block_name(x, y - 1, z)) {
return Some(c);
}
self.not_preferred.push(c);
}
}
(!self.not_preferred.is_empty()).then(|| self.not_preferred.remove(0))
}
/// `LongJumpToRandomPos.pickCandidate`: the first acceptable spot it can
/// reach in an arc but not by an 8 block path.
fn pick(&mut self, m: &mut Mob, w: &MobWorld, lr: &mut LegacyRandom) {
while !self.candidates.is_empty() {
let Some((target, _)) = self.candidate(w, lr) else {
continue;
};
let here = [m.position.x.floor() as i32, m.position.z.floor() as i32];
if here == [target[0], target[2]] || !acceptable_landing(m, w, target) {
continue;
}
let centre = Vec3::new(
f64::from(target[0]) + 0.5,
f64::from(target[1]) + 0.5,
f64::from(target[2]) + 0.5,
);
let Some(v) = optimal_jump_vector(m, w, centre) else {
continue;
};
self.look = [centre.x, centre.y, centre.z];
let walkable = m
.create_path_raw(w, &[target], 0, 8.0)
.is_some_and(|p| p.reached);
if !walkable {
self.chosen = Some(v);
self.prepare = 0;
return;
}
}
}
/// `LongJumpMidJump.stop`, or the end of a search.
fn end(&mut self, m: &mut Mob, cx: &mut Cx) {
if self.mid_jump {
if m.on_ground {
m.velocity = m.velocity.multiply(Vec3::new(0.1, 1.0, 0.1));
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.frog.step", at, 2.0, 1.0));
}
set_pose(m, sync::STANDING);
sample_cooldown(m, cx.lr, 1);
}
m.extra.frog.discard_friction = false;
*self = Self::default();
}
}
/// `LongJumpToRandomPos.start`'s candidates: every block of the box but the
/// frog's own, weighted by the squared distance, in `betweenClosed` order.
fn candidates([x, y, z]: [i32; 3]) -> Vec<Candidate> {
let mut out = Vec::new();
for dz in -JUMP_WIDTH..=JUMP_WIDTH {
for dy in -JUMP_HEIGHT..=JUMP_HEIGHT {
for dx in -JUMP_WIDTH..=JUMP_WIDTH {
if (dx, dy, dz) != (0, 0, 0) {
out.push(([x + dx, y + dy, z + dz], dx * dx + dy * dy + dz * dz));
}
}
}
}
out
}
/// `LongJumpToRandomPos.calculateOptimalJumpVector`: the angles shuffled, the
/// first arc that reaches the target clear of blocks. `Collections.shuffle`
/// without a random draws from an unseeded one, not the mob's or the level's.
fn optimal_jump_vector(m: &Mob, w: &MobWorld, target: Vec3) -> Option<Vec3> {
let mut random = crate::sound_engine::SoundEngine::unseeded();
let mut angles = ALLOWED_ANGLES;
for i in (2..=angles.len()).rev() {
let j = random.next_int_bound(i as i32).unwrap_or(0) as usize;
angles.swap(i - 1, j);
}
let max = (m.attrs.jump_strength * f64::from(JUMP_VELOCITY_MULTIPLIER)) as f32;
angles
.iter()
.find_map(|&a| super::breeze::jump_vector(m, target, max, a, Some(w)))
}
impl Goal for LongJump {
goal_clone!();
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn interruptable(&self) -> bool {
!self.mid_jump
}
fn every_tick(&self) -> bool {
true
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// The activity: no cooldown and out of water. FrogAi.initJumpActivity
// also wants TEMPTING_PLAYER and BREED_TARGET absent, and the
// LAY_SPAWN activity (IS_PREGNANT) goes before LONG_JUMP.
// TODO: gate on them once frogs follow frog_food, breed and lay
// frogspawn here; none of the three exists for frogs yet.
if jump_cooldown(m).is_some() || m.fluids.in_water {
return false;
}
// checkExtraStartConditions: on the ground, not in lava or on honey.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let can =
m.on_ground && !m.fluids.in_lava && cx.w.block_name(x, y, z) != "minecraft:honey_block";
if !can {
sample_cooldown(m, cx.lr, 2);
}
can
}
fn start(&mut self, m: &mut Mob, _cx: &mut Cx) {
// LongJumpToRandomPos.start.
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
*self = Self {
candidates: candidates([x, y, z]),
tries: FIND_JUMP_TRIES,
initial: Some(m.position),
..Self::default()
};
// LongJumpToPreferredBlock.start.
self.wanting_preferred = m.random.next_float() < PREFERRED_BLOCKS_CHANCE;
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if self.mid_jump {
// LongJumpMidJump starts the tick after the leap and runs until
// the frog is on the ground again, for 100 ticks at most; water
// switches the brain to the swim activity, which stops it too.
match self.in_air {
None => {
self.in_air = Some(0);
m.extra.frog.discard_friction = true;
set_pose(m, sync::LONG_JUMPING);
}
Some(t) => self.in_air = Some(t + 1),
}
return !m.on_ground
&& !m.fluids.in_water
&& self.in_air.is_some_and(|t| t <= MID_JUMP_DURATION);
}
let valid = self.initial == Some(m.position)
&& self.tries > 0
&& !m.fluids.in_water
&& !(self.chosen.is_none() && self.candidates.is_empty());
if !valid {
sample_cooldown(m, cx.lr, 2);
}
valid
}
fn stop(&mut self, m: &mut Mob, cx: &mut Cx) {
self.end(m, cx);
}
fn tick(&mut self, m: &mut Mob, cx: &mut Cx) {
if self.mid_jump {
return;
}
let Some(jump) = self.chosen else {
self.tries -= 1;
self.pick(m, cx.w, cx.lr);
return;
};
m.look_at(self.look);
self.prepare += 1;
if self.prepare < PREPARE_JUMP_DURATION {
return;
}
// The leap: the body's facing, no friction, the arc's velocity
// lengthened by jump boost.
m.yaw = m.body_yaw;
m.extra.frog.discard_friction = true;
let len = jump.length();
let boost = m
.extra
.effects
.iter()
.find(|e| e.0 == "minecraft:jump_boost")
.map_or(0.0, |e| f64::from(0.1_f32 * (e.2 + 1) as f32));
m.velocity = jump.scale((len + boost) / len);
self.mid_jump = true;
let at = [m.position.x, m.position.y, m.position.z];
cx.events
.push(MobEvent::Sound("entity.frog.long_jump", at, 1.0, 1.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
fn frog() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:frog".into();
k.species = Species::Frog;
Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
}
/// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own
/// block, x fastest, then y, then z, each weighted by its squared distance.
#[test]
fn the_candidates_cover_the_jump_box_in_vanilla_order() {
let c = candidates([10, 64, -3]);
assert_eq!(c.len(), 9 * 5 * 9 - 1);
assert_eq!(c[0], ([6, 62, -7], 16 + 4 + 16));
assert_eq!(c[1], ([7, 62, -7], 9 + 4 + 16));
assert_eq!(c[9], ([6, 63, -7], 16 + 1 + 16));
assert_eq!(c[45], ([6, 62, -6], 16 + 4 + 9));
assert!(!c.iter().any(|(p, _)| *p == [10, 64, -3]));
assert_eq!(c.last().unwrap().0, [14, 66, 1]);
}
/// `WeightedRandom.getRandomItem`: `nextInt(total)` walked down the
/// list, and the drawn candidate is removed.
#[test]
fn a_draw_takes_the_weighted_candidate_out() {
let mut j = LongJump {
candidates: vec![([0, 0, 0], 1), ([1, 0, 0], 4), ([2, 0, 0], 9)],
..LongJump::default()
};
let mut r = LegacyRandom::new(7);
let mut lr = r.clone();
let pick = r.next_int_bound(14).unwrap();
let want = if pick < 1 {
0
} else if pick < 5 {
1
} else {
2
};
assert_eq!(j.draw(&mut lr).unwrap().0, [want, 0, 0]);
assert_eq!(j.candidates.len(), 2);
assert!(!j.candidates.iter().any(|c| c.0 == [want, 0, 0]));
}
/// `CountDownCooldownTicks`: the memory falls by one a tick and is gone
/// the tick after it reached 0; the sample is 100..=140, halved after a
/// failed search, drawn from the level's random and not the frog's.
#[test]
fn the_long_jump_cooldown_counts_down_and_is_erased() {
let mut m = frog();
let own = m.random.state();
sample_cooldown(&mut m, &mut LegacyRandom::new(3), 2);
let t = jump_cooldown(&m).unwrap();
assert!((50..=70).contains(&t));
assert_eq!(m.random.state(), own);
set_jump_cooldown(&mut m, Some(1));
set_jump_cooldown(&mut m, Some(0));
assert_eq!(jump_cooldown(&m), Some(0));
set_jump_cooldown(&mut m, None);
assert_eq!(jump_cooldown(&m), None);
assert!(!m.extra.memories.iter().any(|x| x.0 == LONG_JUMP_COOLDOWN));
}
}
+4 -1
View File
@@ -4,7 +4,7 @@ use super::{Mob, MobEvent, MobWorld, Seen, Species, Who, world::TargetInfo};
use horizon_core::{
geometry::{Aabb, Vec3},
jmath,
random::RandomSource,
random::{LegacyRandom, RandomSource},
};
use std::sync::Arc;
@@ -16,6 +16,9 @@ pub const TARGET: u8 = 8;
/// What goals may read and emit during a tick.
pub struct Cx<'a, 'w> {
pub w: &'a MobWorld<'w>,
/// The level's random (`level.getRandom()`), for the draws vanilla's
/// behaviours make from it rather than from the mob's own.
pub lr: &'a mut LegacyRandom,
pub events: &'a mut Vec<MobEvent>,
}
pub trait Goal: std::fmt::Debug {
+2 -2
View File
@@ -1045,8 +1045,8 @@ impl Mob {
} else {
y -= self.gravity();
}
// LivingEntity.shouldDiscardFriction (a breeze's long jump).
if super::breeze::discards_friction(self) {
// LivingEntity.shouldDiscardFriction (a breeze's or a frog's long jump).
if super::breeze::discards_friction(self) || self.extra.frog.discard_friction {
self.velocity = Vec3::new(movement.x, y, movement.z);
return;
}
@@ -783,6 +783,7 @@ impl B {
m.swing();
let mut gcx = Cx {
w,
lr: cx.lr,
events: cx.events,
};
super::ai::do_hurt_target(m, target, &mut gcx);
+1 -1
View File
@@ -232,7 +232,7 @@ impl Mob {
}
q
}
fn create_path_raw(
pub(super) fn create_path_raw(
&mut self,
w: &MobWorld,
targets: &[[i32; 3]],
+23 -3
View File
@@ -1233,7 +1233,7 @@ pub fn render_with(
let shifted =
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
let base = base_transform(m, alpha, shifted);
render_visible(m, alpha, base, atlas, light, data, extra, out);
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
}
}
return;
@@ -1246,6 +1246,7 @@ pub fn render_with(
light,
data,
extra,
true,
out,
);
}
@@ -1291,6 +1292,24 @@ pub fn cape(
// RenderTypes.entitySolid with OverlayTexture.NO_OVERLAY.
draw(model, base, &poses, uv, 0xFFFF_FFFF, light, 0, out);
}
/// `render_with` without the body, for an avatar the viewer can't see:
/// `LivingEntityRenderer.submit` gets no render type for an invisible body
/// (`isBodyVisible`, `isInvisibleTo`), but its layers still draw
/// (`HumanoidArmorLayer`: the armour in `extra`).
#[allow(clippy::too_many_arguments)]
pub fn render_layers(
m: &Mob,
alpha: f32,
camera: glam::DVec3,
atlas: Atlas,
light: u32,
data: &MobData,
extra: &[(&Part, [f32; 4], u32)],
out: &mut Vec<Vertex>,
) {
let base = base_transform(m, alpha, camera);
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
}
/// A mob drawn under `origin` instead of at its position (the spinning mob
/// of `SpawnerRenderer.submitEntityInSpawner`).
pub fn render_posed(
@@ -1303,7 +1322,7 @@ pub fn render_posed(
out: &mut Vec<Vertex>,
) {
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], out);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
}
#[allow(clippy::too_many_arguments)]
fn render_visible(
@@ -1314,6 +1333,7 @@ fn render_visible(
light: u32,
data: &MobData,
extra: &[(&Part, [f32; 4], u32)],
body_visible: bool,
out: &mut Vec<Vertex>,
) {
let overlay = u32::from(red_overlay(m));
@@ -1327,7 +1347,7 @@ fn render_visible(
let mut poses = Poses::new(&model);
anim::setup(m, &s, &mut poses, &data.animations);
let draw_body = |out: &mut Vec<Vertex>| {
if let Some(uv) = atlas(&texture) {
if body_visible && let Some(uv) = atlas(&texture) {
draw(
&model,
base,
@@ -766,4 +766,11 @@ mod tests {
assert_eq!(light_limit(Some(&tag("[I;9,2]"))), None);
assert_eq!(encode_range((3, 3)), Tag::Int(3));
}
#[test]
fn spawner_defaults_empty() {
let s = Spawner::default();
assert_eq!(s.next_data(), None);
assert!(s.potentials.is_empty());
}
}
+8 -2
View File
@@ -454,7 +454,13 @@ pub fn do_push(m: &mut Mob, w: &MobWorld, who: Who, events: &mut Vec<MobEvent>)
}
/// `Warden.doHurtTarget`: the attack keyframes (entity event 4), the impact
/// sound, a sonic boom cooldown, then `Mob.doHurtTarget`.
pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mut Vec<MobEvent>) {
pub(super) fn do_hurt_target(
m: &mut Mob,
target: Who,
w: &MobWorld,
lr: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
m.anim.states[ROAR].stop();
m.anim.states[ATTACK].start(m.age as f32);
events.push(MobEvent::EntityEvent {
@@ -465,7 +471,7 @@ pub(super) fn do_hurt_target(m: &mut Mob, target: Who, w: &MobWorld, events: &mu
let pitch = voice_pitch(m);
sound(m, "entity.warden.attack_impact", 10.0, pitch, events);
warden_brain::mem(m).sonic_boom_cooldown.set_ttl((), 40);
let mut cx = warden_brain::cx(w, events);
let mut cx = warden_brain::cx(w, lr, events);
super::ai::do_hurt_target(m, target, &mut cx);
}
@@ -962,7 +962,7 @@ impl B {
}
mem(m).look_target.set(Tracker::Entity(target, true));
m.swing();
warden::do_hurt_target(m, target, w, cx.events);
warden::do_hurt_target(m, target, w, cx.lr, cx.events);
mem(m)
.attack_cooling_down
.set_ttl(true, MELEE_ATTACK_COOLDOWN);
@@ -1188,8 +1188,12 @@ fn swim_condition(m: &Mob) -> bool {
m.fluids.water > m.fluid_jump_threshold() || m.fluids.in_lava
}
/// The `Cx` the mob library's attacks take.
pub(super) fn cx<'a, 'w>(w: &'a MobWorld<'w>, events: &'a mut Vec<MobEvent>) -> Cx<'a, 'w> {
Cx { w, events }
pub(super) fn cx<'a, 'w>(
w: &'a MobWorld<'w>,
lr: &'a mut LegacyRandom,
events: &'a mut Vec<MobEvent>,
) -> Cx<'a, 'w> {
Cx { w, lr, events }
}
// ---- Printing ---------------------------------------------------------------
+21 -2
View File
@@ -705,6 +705,14 @@ impl PlayerMotion {
/// Vehicle, item-use and effect bindings remain separate; hunger, flight,
/// water and collision conditions use this actor's actual state.
pub fn sprint_input_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.sprint_input_tick_with_window(keys, 7)
}
/// The same phase with Java's `Options.sprintWindow` value.
pub fn sprint_input_tick_with_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
let conditions = crate::sprint::Conditions {
sprinting: self.sprinting,
enough_food: self.health.food.has_enough_food(),
@@ -725,7 +733,9 @@ impl PlayerMotion {
// The sprint setter edits attributes and flags, independent of local
// keyboard history. Retain the input prefix even if that setter fails.
let mut state = self.sprint_input;
let result = state.tick(keys, conditions, 7, |value| self.set_sprinting(value));
let result = state.tick(keys, conditions, sprint_window.clamp(0, 10), |value| {
self.set_sprinting(value)
});
self.sprint_input = state;
result
}
@@ -992,6 +1002,14 @@ impl MotionWorld<'_, '_, '_> {
.food_tick(&self.resources.health, self.scene.health_rules)
}
pub fn controlled_simulation_tick(&mut self, keys: crate::sprint::Keys) -> Result<()> {
self.controlled_simulation_tick_with_sprint_window(keys, 7)
}
/// Local simulation with the configured double-tap sprint window.
pub fn controlled_simulation_tick_with_sprint_window(
&mut self,
keys: crate::sprint::Keys,
sprint_window: i32,
) -> Result<()> {
// The base tick (fluids, swimming) precedes LocalPlayer.aiStep.
self.player.health.advance_clock();
self.fluid_base_tick()?;
@@ -1004,7 +1022,8 @@ impl MotionWorld<'_, '_, '_> {
self.player.auto_jump_time -= 1;
keys.jump = true;
}
self.player.sprint_input_tick(keys)?;
self.player
.sprint_input_tick_with_window(keys, sprint_window)?;
self.player.shift_key_down = keys.shift;
let direction = keys.direction();
self.player.move_vector = direction;
-286
View File
@@ -1,286 +0,0 @@
//! Extras that became official mods (docs/mods.md, "Horizon's official
//! mods"). For a player who had one on, the game installs the mod from
//! Horizon's registry once, checked as the launcher checks it, turns it on
//! and carries the extra's settings over into the mod's storage. A player
//! who had it off gets nothing: the mod is in the mod browser.
use crate::{extras::Extras, mod_list::ModList};
use horizon_mods::index::{self, Index};
use std::{
fs,
io::Read,
path::{Path, PathBuf},
};
/// Horizon's registry; `HORIZON_MOD_INDEX` points elsewhere (a local file
/// for trials, whose files may then be `file://`), as for the launcher.
pub const INDEX_URL: &str = "https://horizon-rust.github.io/horizon-mods/index.json";
const MAX_INDEX: u64 = 16 << 20;
/// An extra that is an official mod now.
#[derive(Debug, PartialEq)]
pub struct Moved {
/// The mod's id in the registry.
pub id: &'static str,
/// The extra's name, for the line the player reads.
pub name: &'static str,
}
pub const MOVED: [Moved; 4] = [
Moved {
id: "fps_overlay",
name: "FPS counter",
},
Moved {
id: "keystrokes",
name: "Keystrokes",
},
Moved {
id: "zoom",
name: "Zoom",
},
Moved {
id: "minimap",
name: "Minimap",
},
];
impl Moved {
fn was_on(&self, e: &Extras) -> bool {
match self.id {
"fps_overlay" => e.fps,
"keystrokes" => e.keystrokes,
"zoom" => e.zoom,
_ => e.minimap,
}
}
/// The extra's settings as the mod keeps them (its storage keys).
pub fn settings(&self, e: &Extras) -> Vec<(&'static str, String)> {
let flag = |b: bool| if b { "true" } else { "false" }.to_owned();
match self.id {
"fps_overlay" => vec![
("corner", e.fps_corner.name().into()),
("lows", flag(e.fps_lows)),
],
"keystrokes" => vec![
("corner", e.keystrokes_corner.name().into()),
("cps", flag(e.keystrokes_cps)),
],
"zoom" => vec![
("level", e.zoom_level.to_string()),
("smooth", flag(e.zoom_smooth)),
("sensitivity", flag(e.zoom_sensitivity)),
],
_ => vec![
("corner", e.minimap_corner.name().into()),
("size", e.minimap_size.to_string()),
(
"shape",
if e.minimap_round { "round" } else { "square" }.into(),
),
("zoom", e.minimap_zoom.to_string()),
("rotate", flag(e.minimap_rotate)),
],
}
}
/// The extra's key as the mod's key (`horizon-mods.txt`), when the
/// player had changed it from the mod's default.
pub fn keys(&self, e: &Extras) -> Vec<(&'static str, String)> {
match self.id {
"zoom" if e.zoom_key != "key.keyboard.z" => vec![("zoom", e.zoom_key.clone())],
_ => Vec::new(),
}
}
/// The line the player reads once it is installed.
pub fn notice(&self) -> String {
format!("{} moved to the mod browser: installed for you", self.name)
}
}
/// The moved extras still to install: on, and not done yet.
pub fn pending(e: &Extras) -> Vec<&'static Moved> {
MOVED
.iter()
.filter(|m| m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
.collect()
}
/// The moved extras that were off and not marked yet: nothing to install,
/// only to remember.
pub fn nothing_to_do(e: &Extras) -> Vec<&'static Moved> {
MOVED
.iter()
.filter(|m| !m.was_on(e) && !e.moved.iter().any(|d| d == m.id))
.collect()
}
fn location() -> String {
std::env::var("HORIZON_MOD_INDEX")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| INDEX_URL.to_owned())
}
/// `url`'s bytes, at most `max`: `https://` from the network; `file://` only
/// with a local index (a trial registry).
fn get(url: &str, local: bool, max: u64) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
if url.starts_with("https://") {
let response = ureq::AgentBuilder::new()
.timeout(std::time::Duration::from_secs(60))
.build()
.get(url)
.call()
.map_err(|e| format!("{url}: {e}"))?;
response
.into_reader()
.take(max + 1)
.read_to_end(&mut out)
.map_err(|e| e.to_string())?;
} else {
let path = match url.strip_prefix("file://") {
Some(p) if local => p,
_ if local && !url.contains("://") => url,
_ => return Err(format!("{url}: not an https address")),
};
out = fs::read(path).map_err(|e| format!("{path}: {e}"))?;
}
if out.len() as u64 > max {
return Err(format!("{url}: over {max} bytes"));
}
Ok(out)
}
/// The registry's index, from the network (or `HORIZON_MOD_INDEX`).
pub fn fetch_index() -> Result<Index, String> {
let at = location();
let local = !at.starts_with("https://");
let text = String::from_utf8(get(&at, local, MAX_INDEX)?).map_err(|e| e.to_string())?;
if local {
Index::parse_local(&text)
} else {
Index::parse(&text)
}
}
/// Installs `m` into the game folder `dir` (where `options.txt` is), for a
/// player whose extras were `e`: its newest version for this game that
/// wasn't taken down and that this Horizon's mod API level runs, checked
/// against the index (size, SHA-256, signature, manifest), as
/// `horizon-mods/<id>.hmod`, turned on, with the extra's settings in its
/// storage unless the mod has its own already.
pub fn install(index: &Index, m: &Moved, e: &Extras, dir: &Path) -> Result<(), String> {
let project = index
.project(m.id)
.ok_or_else(|| format!("{} isn't in Horizon's registry yet", m.id))?;
let version = index
.installable(
project,
horizon_core::version::VERSION_ID,
horizon_mods::api_level::LEVEL,
)
.ok_or_else(|| format!("{} has no version this Horizon can run", m.id))?;
let mods = dir.join(crate::mod_list::DIR);
fs::create_dir_all(&mods).map_err(|e| format!("{}: {e}", mods.display()))?;
let target = mods.join(format!("{}.hmod", m.id));
let current = index::sha256_file(&target).ok();
if current.as_deref() != Some(version.file.sha256.as_str()) {
let local = !location().starts_with("https://");
let bytes = get(&version.file.url, local, index::MAX_FILE)?;
let partial: PathBuf = mods.join(format!(".{}.hmod.download", m.id));
fs::write(&partial, bytes).map_err(|e| format!("{}: {e}", partial.display()))?;
if let Err(e) = index::check_file(index, project, version, &partial) {
let _ = fs::remove_file(&partial);
return Err(format!("{}: {e}", m.id));
}
fs::rename(&partial, &target).map_err(|e| format!("{}: {e}", target.display()))?;
}
let storage = mods.join(horizon_mods::manifest::STORAGE_DIR).join(m.id);
fs::create_dir_all(&storage).map_err(|e| format!("{}: {e}", storage.display()))?;
for (key, value) in m.settings(e) {
let path = storage.join(key);
if !path.exists() {
fs::write(&path, value).map_err(|e| format!("{}: {e}", path.display()))?;
}
}
let mut list = ModList::load(dir);
list.enabled.insert(m.id.to_owned());
// A key the player already chose for the mod stays.
let keys = list.keys.entry(m.id.to_owned()).or_default();
for (name, key) in m.keys(e) {
keys.entry(name.to_owned()).or_insert(key);
}
list.keys.retain(|_, k| !k.is_empty());
list.save(dir);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extras::Corner;
#[test]
fn only_extras_that_were_on_are_installed_and_each_once() {
let mut e = Extras::default();
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e).len(), 4);
e.fps = true;
e.fps_corner = Corner::BottomRight;
e.fps_lows = true;
assert_eq!(pending(&e), [&MOVED[0]]);
assert_eq!(
MOVED[0].settings(&e),
[("corner", "bottom_right".into()), ("lows", "true".into())]
);
assert_eq!(
MOVED[1].settings(&e),
[("corner", "top_left".into()), ("cps", "true".into())]
);
e.moved.push("fps_overlay".into());
assert!(pending(&e).is_empty());
assert_eq!(nothing_to_do(&e), [&MOVED[1], &MOVED[2], &MOVED[3]]);
// Zoom: its settings, and its key only when the player changed it.
e.zoom = true;
e.zoom_level = 8;
e.zoom_smooth = false;
assert_eq!(pending(&e), [&MOVED[2]]);
assert_eq!(
MOVED[2].settings(&e),
[
("level", "8".into()),
("smooth", "false".into()),
("sensitivity", "true".into())
]
);
assert!(MOVED[2].keys(&e).is_empty());
e.zoom_key = "key.mouse.4".into();
assert_eq!(MOVED[2].keys(&e), [("zoom", "key.mouse.4".into())]);
// The minimap: its five settings, in the extra's values.
e.minimap = true;
e.minimap_corner = Corner::BottomLeft;
e.minimap_size = 128;
e.minimap_round = true;
e.minimap_zoom = 0.5;
assert_eq!(pending(&e), [&MOVED[2], &MOVED[3]]);
assert_eq!(
MOVED[3].settings(&e),
[
("corner", "bottom_left".into()),
("size", "128".into()),
("shape", "round".into()),
("zoom", "0.5".into()),
("rotate", "false".into())
]
);
assert_eq!(
MOVED[0].notice(),
"FPS counter moved to the mod browser: installed for you"
);
}
#[test]
fn a_file_url_needs_a_local_index() {
assert!(get("file:///etc/hosts", false, 1 << 20).is_err());
assert!(get("http://example.com/x", true, 1 << 20).is_err());
}
}
+33
View File
@@ -483,6 +483,13 @@ pub fn encode_block_entity(
}
}
BlockEntity::Sculk(s) => entries.extend(s.to_nbt()),
BlockEntity::Shelf(shelf) => {
entries.push(("Items", encode_items(&shelf.items)));
entries.push((
"align_items_to_bottom",
Tag::Byte(i8::from(shelf.align_to_bottom)),
));
}
// ChiseledBookShelfBlockEntity.saveAdditional.
BlockEntity::ChiseledBookshelf(b) => {
entries.push(("Items", encode_items(&b.items)));
@@ -547,6 +554,16 @@ pub fn encode_block_entity(
BlockEntity::Vault(v) => entries.extend(v.save()),
BlockEntity::Beehive(h) => entries.extend(h.to_nbt()),
BlockEntity::CommandBlock(c) => entries.extend(c.save()),
BlockEntity::Brushable(b) => {
if let Some(table) = &b.loot_table {
entries.push(("LootTable", table.clone()));
if let Some(seed) = &b.loot_seed {
entries.push(("LootTableSeed", seed.clone()));
}
} else if let Some(item) = &b.item {
entries.push(("item", item_to_nbt(item)));
}
}
BlockEntity::DaylightDetector => {}
}
Tag::compound(entries)
@@ -724,8 +741,24 @@ pub fn decode_block_entity(tag: &Tag) -> Option<((i32, i32, i32), crate::contain
_ => 1.0,
};
}
BlockEntity::Brushable(b) => {
b.loot_table = tag.get("LootTable").cloned();
b.loot_seed = tag.get("LootTableSeed").cloned();
b.item = if b.loot_table.is_none() {
tag.get("item").and_then(item_from_nbt)
} else {
None
};
b.hit_direction = int("hit_direction").filter(|v| (0..=5).contains(v));
}
BlockEntity::Sign(sign) => sign.decode(tag),
BlockEntity::Sculk(s) => s.load(tag),
BlockEntity::Shelf(shelf) => {
for (slot, item) in decode_items(tag.get("Items"), 3).into_iter().enumerate() {
shelf.items[slot] = item;
}
shelf.align_to_bottom = int("align_items_to_bottom").unwrap_or(0) != 0;
}
// ChiseledBookShelfBlockEntity.loadAdditional.
BlockEntity::ChiseledBookshelf(b) => {
for (slot, item) in decode_items(tag.get("Items"), 6).into_iter().enumerate() {
+133
View File
@@ -1,6 +1,7 @@
//! Title, world selection and world creation screens (TitleScreen,
//! SelectWorldScreen, CreateWorldScreen layouts), drawn as HUD quads.
use crate::{
dialog_screen::BOX,
game_rules_screen::{Exit, RulesScreen},
hud::{HudAtlas, HudBatch, rgba},
lang,
@@ -33,6 +34,12 @@ fn edit_top(h: f32) -> f32 {
pub(crate) fn confirm_top(h: f32, lines: usize) -> f32 {
((h.ceil() - 61.0 - lines as f32 * 9.0) / 2.0).floor()
}
/// The SafetyScreen's column (WarningScreen: title, 12 px around the message
/// box of its lines and 4 px padding, the checkbox, then the buttons, 8 px
/// apart): the title's top.
fn warning_top(h: f32, lines: usize) -> f32 {
((h.ceil() - 102.0 - lines as f32 * 9.0) / 2.0).floor()
}
fn translate(key: &str, fallback: &str, args: &[&dyn std::fmt::Display]) -> String {
let args: Vec<Text> = args
@@ -119,6 +126,11 @@ pub enum Mode {
return_to: Box<Mode>,
},
Multiplayer(crate::multiplayer::Screen),
/// SafetyScreen: the warning before the first Multiplayer click, with its
/// "Do not show this screen again" box.
MultiplayerWarning {
check: bool,
},
/// CreateFlatWorldScreen over Create New World: the fields that screen
/// keeps and the superflat editor on top of them.
CreateFlat {
@@ -160,6 +172,8 @@ pub struct Title {
pub mode: Mode,
/// Direct Connection's address, synchronized with `options.txt`'s `lastServer`.
pub last_server: String,
/// `skipMultiplayerWarning`, synchronized with `options.txt` the same way.
pub skip_multiplayer_warning: bool,
pub saves: PathBuf,
/// The prepared resource root: the flat preset list and block registry.
resources: PathBuf,
@@ -171,6 +185,8 @@ pub struct Title {
scroll: Cell<crate::ui::Scroll>,
create_return_to_main: bool,
message_lines: Cell<usize>,
/// The SafetyScreen's checkbox as last drawn: x, y, width, height.
warning_check: Cell<[f32; 4]>,
backup: Option<mpsc::Receiver<Result<u64, String>>>,
backup_shown: Cell<bool>,
/// The backup a `BackupBeforeUpgrade` asked for opens its world when done.
@@ -297,6 +313,7 @@ impl Title {
Self {
mode,
last_server: String::new(),
skip_multiplayer_warning: false,
saves,
resources: resources.to_owned(),
started: now,
@@ -306,6 +323,7 @@ impl Title {
scroll: Cell::default(),
create_return_to_main: false,
message_lines: Cell::new(1),
warning_check: Cell::new([0.0; 4]),
backup: None,
backup_shown: Cell::new(false),
backup_then_open: false,
@@ -1034,6 +1052,28 @@ impl Title {
),
]
}
Mode::MultiplayerWarning { .. } => {
let lines = self.message_lines.get();
let y = warning_top(h, lines) + 82.0 + lines as f32 * 9.0;
vec![
b(
(w / 2.0).floor() - 154.0,
y,
150.0,
&lang::get_or("gui.proceed", "Proceed"),
true,
70,
),
b(
(w / 2.0).floor() + 4.0,
y,
150.0,
&lang::get_or("gui.back", "Back"),
true,
71,
),
]
}
Mode::Backup { .. } => Vec::new(),
Mode::Multiplayer(screen) => screen
.buttons(w, h)
@@ -1365,6 +1405,74 @@ impl Title {
centered(atlas, batch, line, top + 17.0 + i as f32 * 9.0, u32::MAX);
}
}
Mode::MultiplayerWarning { check } => {
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let message = lang::get_or(
"multiplayerWarning.message",
"Caution: Online play is offered by third-party servers that are not owned, operated, or supervised by Mojang Studios or Microsoft. During online play, you may be exposed to unmoderated chat messages or other types of user-generated content that may not be suitable for everyone.",
);
// The message sits in a text box `width - 100` wide with 4 px
// inside, left aligned (FittingMultiLineTextWidget).
let box_w = w - 100.0;
let lines = wrapped(atlas, &message, box_w - 8.0);
self.message_lines.set(lines.len());
let top = warning_top(h, lines.len());
let header = Text::translate("multiplayerWarning.header", &[]).bold();
batch.text_styled(
atlas,
&header,
(w / 2.0).floor() - (atlas.font.width_text(&header) / 2.0).floor(),
top,
u32::MAX,
true,
);
let box_x = ((w - box_w) / 2.0).floor();
let box_y = top + 29.0;
let box_h = lines.len() as f32 * 9.0 + 8.0;
crate::ui::nine_slice(
batch,
atlas,
"gui/sprites/widget/text_field",
box_x,
box_y,
box_w,
box_h,
u32::MAX,
);
for (i, line) in lines.iter().enumerate() {
batch.text(
atlas,
line,
box_x + 4.0,
box_y + 4.0 + i as f32 * 9.0,
u32::MAX,
true,
);
}
// Checkbox: the box, its label right of it (as the dialogs draw theirs).
let label =
lang::get_or("multiplayerWarning.check", "Do not show this screen again");
let width = BOX + 4.0 + HudBatch::text_width(atlas, &label).ceil();
let x = (w / 2.0).floor() - (width / 2.0).floor();
let y = top + 57.0 + lines.len() as f32 * 9.0;
self.warning_check.set([x, y, width, BOX]);
let hover = (x..x + width).contains(&cursor.0) && (y..y + BOX).contains(&cursor.1);
let sprite = match (*check, hover) {
(true, true) => "gui/sprites/widget/checkbox_selected_highlighted",
(true, false) => "gui/sprites/widget/checkbox_selected",
(false, true) => "gui/sprites/widget/checkbox_highlighted",
(false, false) => "gui/sprites/widget/checkbox",
};
batch.sprite_scaled(atlas, sprite, x, y, BOX, BOX, u32::MAX);
batch.text(
atlas,
&label,
x + BOX + 4.0,
y + 4.0,
rgba(224, 224, 224, 255),
true,
);
}
Mode::Multiplayer(screen) => screen.draw(
atlas,
batch,
@@ -1471,6 +1579,14 @@ impl Title {
}
return (Action::None, changed);
}
// The SafetyScreen's box toggles where it was last drawn.
if let Mode::MultiplayerWarning { check } = &mut self.mode {
let [bx, by, bw, bh] = self.warning_check.get();
if (bx..bx + bw).contains(&x) && (by..by + bh).contains(&y) {
*check = !*check;
return (Action::None, true);
}
}
let hit = self
.buttons(w, h)
.into_iter()
@@ -1493,9 +1609,26 @@ impl Title {
}
(Mode::Main, Some(2)) => Action::Quit,
(Mode::Main, Some(4)) => {
// TitleScreen.init: SafetyScreen first, unless it was turned off.
self.mode = if self.skip_multiplayer_warning {
Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves))
} else {
Mode::MultiplayerWarning { check: false }
};
Action::None
}
(Mode::MultiplayerWarning { check }, Some(70)) => {
// SafetyScreen Proceed: the box saves the choice.
if *check {
self.skip_multiplayer_warning = true;
}
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::list(&self.saves));
Action::None
}
(Mode::MultiplayerWarning { .. }, Some(71)) => {
self.mode = Mode::Main;
Action::None
}
(Mode::Multiplayer(_), hit) => match self.multiplayer_click(hit, x, y, (w, h)) {
crate::multiplayer::Outcome::Back => {
self.mode = Mode::Main;
+5
View File
@@ -88,6 +88,9 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
let mut f = Fixture::new();
f.title.last_server = "localhost:25566".into();
f.click("Multiplayer");
// SafetyScreen comes first while its box was never ticked.
assert!(matches!(f.title.mode, Mode::MultiplayerWarning { .. }));
f.click("Proceed");
f.click("Direct Connection");
assert_eq!(f.title.focused_field().unwrap().1, "localhost:25566");
@@ -100,6 +103,7 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
f.title.escape();
f.title.escape();
f.click("Multiplayer");
f.click("Proceed");
f.click("Direct Connection");
assert_eq!(f.title.focused_field().unwrap().1, "[::1]:25568");
@@ -122,6 +126,7 @@ fn direct_connection_remembers_the_address_on_cancel_escape_and_join() {
fn f5_reloads_the_server_list() {
let mut f = Fixture::new();
f.click("Multiplayer");
f.click("Proceed");
let file = crate::multiplayer::servers_file(&f.title.saves);
horizon_protocol::servers::save(
&file,
+1 -1
View File
@@ -694,7 +694,7 @@ impl ChunkGenerator for VanillaTerrain {
crate::structure_blocks::block_entities(&chunk, self.loot.as_deref());
out.block_entities = block_entities;
// The block entities Horizon does not model (decorated pots with their
// sherds and loot table, brushable blocks) stay as placed, like a
// sherds and loot table) stay as placed, like a
// loaded chunk's.
let mut unmodelled: Vec<Tag> = chunk
.block_entities
+91 -1
View File
@@ -91,7 +91,7 @@ pub fn cbrt(x: f64) -> f64 {
/// `Math.atan2`.
pub fn atan2(y: f64, x: f64) -> f64 {
libm::atan2(y, x)
crate::geometry::trig::fdlibm_atan2(y, x)
}
/// `Math.atan`.
@@ -614,4 +614,94 @@ mod tests {
check1(libm::tan, (0xc00e37e234490564, 0xbfe79ca6eee66726));
check1(libm::tan, (0xc075c085c04800b5, 0x3fea18b4a92fcf22));
}
#[test]
fn atan2_matches_strictmath() {
// (y bits, x bits, StrictMath.atan2 bits) from HotSpot 25: x < 0 and
// |y/x| > 2^60, where libm::atan2 rounds one ulp lower,
check2(
super::atan2,
(0x440e8aff5d1e293c, 0xc007e9768e83b37e, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0x440c610b44a22b37, 0xbfb07327bcb4c280, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0x43eb71600e035874, 0xbfeaff77e5a9ea38, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0x440edb2112965d8f, 0xbffaec4345ac33b1, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0x3ff0000000000000, 0x8000000000000001, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0xbff0000000000000, 0x8000000000000001, 0xbff921fb54442d19),
);
check2(
super::atan2,
(0x7fefffffffffffff, 0xbff0000000000000, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0xffefffffffffffff, 0xbff0000000000000, 0xbff921fb54442d19),
);
check2(
super::atan2,
(0x38fd955bf292fbac, 0xb4f940da66879af2, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0xf8954a6a9b403072, 0xf49292c2e2d50436, 0xbff921fb54442d19),
);
check2(
super::atan2,
(0x07e155d47eb507f2, 0x83d22629ee8e8471, 0x3ff921fb54442d19),
);
check2(
super::atan2,
(0xd2471cd599853356, 0xce403e2e72061985, 0xbff921fb54442d19),
);
// and the other quadrants and special cases, which it already matched
check2(
super::atan2,
(0x440e8aff5d1e293c, 0x4007e9768e83b37e, 0x3ff921fb54442d18),
);
check2(
super::atan2,
(0xc40e8aff5d1e293c, 0x4007e9768e83b37e, 0xbff921fb54442d18),
);
check2(
super::atan2,
(0x0000000000000000, 0xbff0000000000000, 0x400921fb54442d18),
);
check2(
super::atan2,
(0x8000000000000000, 0xbff0000000000000, 0xc00921fb54442d18),
);
check2(
super::atan2,
(0x3ff0000000000000, 0x3ff0000000000000, 0x3fe921fb54442d18),
);
check2(
super::atan2,
(0x3ff0000000000000, 0xc410000000000000, 0x400921fb54442d18),
);
check2(
super::atan2,
(0x3e7ad7f29abcaf48, 0xc0f86a0000000000, 0x400921fb5444244c),
);
check2(
super::atan2,
(0x7ff0000000000000, 0xfff0000000000000, 0x4002d97c7f3321d2),
);
// NaN payloads may differ, so only NaN-ness is checked.
assert!(super::atan2(f64::NAN, 1.0).is_nan());
assert!(super::atan2(1.0, f64::NAN).is_nan());
}
}
+6
View File
@@ -13,5 +13,11 @@ wgpu = { version = "30.0.1", default-features = false, features = ["vulkan"] }
# The version wgpu-hal already uses.
ash = { version = "0.38", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The versions winit already uses, with the color-management protocol (staging).
wayland-backend = { version = "0.3.10", default-features = false, features = ["client_system"] }
wayland-client = { version = "0.31.10", default-features = false }
wayland-protocols = { version = "0.32.8", default-features = false, features = ["client", "staging"] }
[lints]
workspace = true
+3
View File
@@ -3,6 +3,9 @@
//! without it a compositor may clip or tone-map everything above 1.0 (80 nits)
//! of an scRGB swapchain.
#[cfg(target_os = "linux")]
pub mod wayland;
/// Opens a Vulkan device with `VK_EXT_hdr_metadata` too, when the GPU offers
/// it, so [`set_metadata`] can tell the compositor the real peak brightness.
/// `None` leaves opening to wgpu.
+231
View File
@@ -0,0 +1,231 @@
//! What a Wayland compositor says it shows a surface with (`wp_color_management_v1`): the
//! luminances of the surface's preferred image description. A compositor that manages
//! colour (KWin, Mutter, wlroots) maps HDR content onto the display relative to a
//! reference white and tone-maps above what the display reaches, so the game can't
//! measure the display through it: it asks instead.
//!
//! The watch runs on winit's own connection, on an event queue of its own, so winit's
//! events are left alone; winit's loop reads the socket, the watch only looks at what
//! landed on its queue.
use std::ffi::c_void;
use wayland_backend::client::{Backend, ObjectId};
use wayland_client::{
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
protocol::{wl_registry, wl_surface::WlSurface},
};
use wayland_protocols::wp::color_management::v1::client::{
wp_color_management_surface_feedback_v1::{self, WpColorManagementSurfaceFeedbackV1},
wp_color_manager_v1::{self, WpColorManagerV1},
wp_image_description_info_v1::{self, WpImageDescriptionInfoV1},
wp_image_description_v1::{self, WpImageDescriptionV1},
};
/// A surface's preferred luminances, in cd/m² (nits).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Luminances {
/// The reference white: where SDR white sits.
pub reference: f32,
/// The darkest and brightest the target display shows (`target_luminance`).
pub target_min: f32,
pub target_max: f32,
}
#[derive(Default)]
struct State {
manager: Option<(u32, u32)>,
/// The compositor changed the surface's preferred description since the last query.
changed: bool,
ready: Option<bool>,
reference: Option<f32>,
target: Option<(f32, f32)>,
done: bool,
}
/// What a compositor says about one window, kept up to date: [`Watch::changed`] says
/// when it changes (another output, the player recalibrating the desktop).
///
/// Drop it before the window: its objects live on the window's connection.
pub struct Watch {
queue: EventQueue<State>,
state: State,
manager: WpColorManagerV1,
feedback: WpColorManagementSurfaceFeedbackV1,
}
impl Watch {
/// Starts watching `window`'s surface, with what the compositor says now. None off
/// Wayland, or where the compositor has no `wp_color_management_v1`.
pub fn new(
window: &(impl wgpu::rwh::HasDisplayHandle + wgpu::rwh::HasWindowHandle),
) -> Option<(Self, Option<Luminances>)> {
use wgpu::rwh::{RawDisplayHandle, RawWindowHandle};
let (RawDisplayHandle::Wayland(d), RawWindowHandle::Wayland(w)) = (
window.display_handle().ok()?.as_raw(),
window.window_handle().ok()?.as_raw(),
) else {
return None;
};
// Safety: the window's live display and surface, borrowed for the call.
let mut watch = unsafe { Self::on(d.display.as_ptr(), w.surface.as_ptr()) }?;
let now = watch.query();
Some((watch, now))
}
/// [`Watch::new`] on raw handles: `surface` (a `wl_surface*`) on `display` (a
/// `wl_display*`).
///
/// # Safety
///
/// `display` and `surface` must be live libwayland objects of the same connection.
unsafe fn on(display: *mut c_void, surface: *mut c_void) -> Option<Self> {
// Safety: the caller's live display; a foreign one is never closed from here.
let backend = unsafe { Backend::from_foreign_display(display.cast()) };
let conn = Connection::from_backend(backend);
// Safety: the caller's live wl_surface on this connection.
let id = unsafe { ObjectId::from_ptr(WlSurface::interface(), surface.cast()) }.ok()?;
let surface = WlSurface::from_id(&conn, id).ok()?;
let mut queue: EventQueue<State> = conn.new_event_queue();
let qh = queue.handle();
let mut state = State::default();
let registry = conn.display().get_registry(&qh, ());
queue.roundtrip(&mut state).ok()?;
let (name, version) = state.manager?;
let manager: WpColorManagerV1 = registry.bind(name, version.min(1), &qh, ());
let feedback = manager.get_surface_feedback(&surface, &qh, ());
Some(Self {
queue,
state,
manager,
feedback,
})
}
/// What the compositor says now, if it changed since the last call. Cheap: it only
/// looks at events the window's own loop already read.
pub fn changed(&mut self) -> Option<Option<Luminances>> {
self.queue.dispatch_pending(&mut self.state).ok()?;
if !std::mem::take(&mut self.state.changed) {
return None;
}
Some(self.query())
}
/// The surface's preferred description now.
fn query(&mut self) -> Option<Luminances> {
let qh = self.queue.handle();
let state = &mut self.state;
(state.ready, state.reference, state.target, state.done) = (None, None, None, false);
let description = self.feedback.get_preferred(&qh, ());
let found = (|| {
while state.ready.is_none() {
self.queue.blocking_dispatch(state).ok()?;
}
if state.ready != Some(true) {
return None;
}
let _info = description.get_information(&qh, ());
while !state.done {
self.queue.blocking_dispatch(state).ok()?;
}
let (target_min, target_max) = state.target?;
Some(Luminances {
reference: state.reference?,
target_min,
target_max,
})
})();
description.destroy();
found
}
}
impl Drop for Watch {
fn drop(&mut self) {
self.feedback.destroy();
self.manager.destroy();
let _ = self.queue.flush();
}
}
impl Dispatch<wl_registry::WlRegistry, ()> for State {
fn event(
state: &mut Self,
_: &wl_registry::WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
&& interface == WpColorManagerV1::interface().name
{
state.manager = Some((name, version));
}
}
}
impl Dispatch<WpColorManagerV1, ()> for State {
fn event(
_: &mut Self,
_: &WpColorManagerV1,
_: wp_color_manager_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpColorManagementSurfaceFeedbackV1, ()> for State {
fn event(
state: &mut Self,
_: &WpColorManagementSurfaceFeedbackV1,
event: wp_color_management_surface_feedback_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wp_color_management_surface_feedback_v1::Event::PreferredChanged { .. } = event {
state.changed = true;
}
}
}
impl Dispatch<WpImageDescriptionV1, ()> for State {
fn event(
state: &mut Self,
_: &WpImageDescriptionV1,
event: wp_image_description_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wp_image_description_v1::Event::Ready { .. } => state.ready = Some(true),
wp_image_description_v1::Event::Failed { .. } => state.ready = Some(false),
_ => {}
}
}
}
impl Dispatch<WpImageDescriptionInfoV1, ()> for State {
fn event(
state: &mut Self,
_: &WpImageDescriptionInfoV1,
event: wp_image_description_info_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
wp_image_description_info_v1::Event::Luminances { reference_lum, .. } => {
state.reference = Some(reference_lum as f32);
}
wp_image_description_info_v1::Event::TargetLuminance { min_lum, max_lum } => {
state.target = Some((min_lum as f32 / 10_000.0, max_lum as f32));
}
wp_image_description_info_v1::Event::Done => state.done = true,
_ => {}
}
}
}
+5
View File
@@ -107,6 +107,8 @@ pub struct ItemInfo {
pub weapon_damage: Option<u32>,
/// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2).
pub use_speed: f32,
/// `use_effects.interact_vibrations` (default true).
pub interact_vibrations: bool,
/// `glider` (elytra).
pub glider: bool,
/// `equippable.swappable` and `equip_sound`.
@@ -663,6 +665,9 @@ impl Items {
use_speed: c["minecraft:use_effects"]["speed_multiplier"]
.as_f64()
.unwrap_or(0.2) as f32,
interact_vibrations: c["minecraft:use_effects"]["interact_vibrations"]
.as_bool()
.unwrap_or(true),
blocks_attacks: BlocksAttacks::parse(&c["minecraft:blocks_attacks"]),
disable_blocking_seconds: c["minecraft:weapon"]["disable_blocking_for_seconds"]
.as_f64()
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more