Compare commits

..
Author SHA1 Message Date
Ludovic Marie 1453e0e614 Merge pull request #13606 from martipops/controller/dial-4-editor
🤖 Agent: the editor builds on #13605's dial; main and #13605's menu_style colours merged in, clippy + 213 radial/menu/options/settings tests pass.
2026-11-10 00:05:14 +01:00
Ludovic Marie 9b4258fecc Merge pull request #13605 from martipops/controller/dial-3-play
🤖 Agent: main merged after #13958: the dial's colours now come from menu_style (page, backdrop_muted, and new lit/lit_edge that Horizon's look fills with the night accent), so the renderer keeps no horizon-theme dependency and the dial looks the same; its curves are cubic-bezier points pinned to horizon_theme by a test. split-controllers waits on this before moving controller files.
2026-11-10 00:05:08 +01:00
Ludovic Marie 2d610e9482 Merge pull request #14140 from MSedgeMC/fix/piglin-guarded-anger
🤖 Agent: ANGRY_AT's UUID and long TTL load back (clamped, not wrapped, past i32), and the test loads a whole saved piglin; the new head only merges the test-fix commit the branch already held (same tree as da800dff).
2026-11-09 23:57:55 +01:00
Ludovic Marie e6eccbd3b9 Merge pull request #13920 from MSedgeMC/port_map/item-activation-file
🤖 Agent: confirmed on main: tick_item_activation is at game/totem.rs:175, called from game.rs:6092 and remote.rs:6613.
2026-11-09 23:57:51 +01:00
Ludovic Marie 537539fe58 Merge pull request #14291 from horizon-rust/split/cosmetics-fx
🤖 Agent: step 2b-iii of spec #14091 moves companions, trails and auras onto the hooks; stacked on #14282.
2026-11-09 23:37:32 +01:00
Ludovic Marie cbbd362eed Merge pull request #14011 from v0ntr3n/fix/stand-command-hurt-events-freeze-immunity
🤖 Agent: the explosion/ignite/burn branches now attribute their game events to the cause in place and ignition clears TicksFrozen; damage_immunity checks the stand's active slots first. Reworked so no dead copy remains; 15/15 stand tests pass with resources.
2026-11-09 23:37:28 +01:00
Ludovic Marie 1430b9990a Merge pull request #14381 from horizon-rust/split/discord-setting
🤖 Agent: the Discord switch keeps its discordPresence line in horizon-extras.txt through horizon-discord's settings store, so players keep their choice while the row moves to Extras > Multiplayer; e2e 4/4 and verify green on this head.
2026-11-09 23:37:24 +01:00
Horizon agent 64a6b82dd0 Merge controller/dial-3-play (radial colours from menu_style) into controller/dial-4-editor 2026-11-09 23:34:37 +01:00
Ludovic Marie a84f637dd6 Merge pull request #14282 from horizon-rust/split/cosmetics-fx-api
🤖 Agent: step 2b-ii of spec #14091 gives PlayerDraw motion, view, looks, particles and first_person so the cosmetics need nothing else from the engine; stacked on #14274.
2026-11-09 23:32:09 +01:00
Horizon agentandClaude Opus 5.5 f2d50d4a75 Radial menu: its colours from menu_style, its curves written out
#13958 (with #14027) took horizon-theme out of horizon-client-render: the
dial's dark page and muted text now come from menu_style's overlay colours,
and OverlayColours gains lit and lit_edge for its sector, which Horizon's
look fills with the night accent and its bright shade, so the dial looks as
before. The easing curves are written as cubic-bezier points like hud_kit's,
and a test pins them to horizon_theme's QUICK and MOVE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:29:07 +01:00
Ludovic Marie efab4d1bbf Merge pull request #14274 from horizon-rust/split/cosmetics-own
🤖 Agent: step 2b-i of spec #14091 draws the player's own body and the inventory picture through PlayerDraw; stacked on #14265.
2026-11-09 23:23:44 +01:00
Horizon agent 604fb90bf1 Merge controller/dial-3-play (with origin/main) into controller/dial-4-editor 2026-11-09 23:21:18 +01:00
Horizon agent b2d72cea9e Merge origin/main into controller/dial-3-play (graphics_style colours are functions now) 2026-11-09 23:20:59 +01:00
Ludovic Marie 7b50bae250 Merge pull request #13958 from horizon-rust/layers/window-icon-hook
🤖 Agent: the engine takes the window icon from the layer above, and entry.rs keeps main's pack_formats::install() after horizon_look::give(), so the icon is still given before any window opens. The head now also carries the menu_style stack (#14026, #14027, #14029): graphics_style's and ui_theme's colours are one OnceLock read per call from menu_style, Horizon's BLOCKS_DARK values come from horizon_look.rs at startup so the menus look the same, and the renderer and client lib no longer depend on horizon-theme. Merges cleanly with main.
2026-11-09 23:19:07 +01:00
Ludovic Marie 3780c215a7 Merge pull request #14265 from horizon-rust/split/cosmetics-layers
🤖 Agent: step 2a-ii of spec #14091 draws the cosmetic items on the body through PlayerDraw instead of calling the cosmetics from the engine; stacked on #14214.
2026-11-09 23:19:02 +01:00
Ludovic Marie 3c853b46eb Merge pull request #14155 from horizon-rust/split/discord-crate
🤖 Agent: the presence now starts only from horizon-official's client_started hook, so horizon-client's own executable (plain builds, harnesses) never opens Discord's socket; the e2e passes 4/4 on the official binary and verify is green on this head.
2026-11-09 23:13:59 +01:00
Ludovic Marie e64af82d00 Merge pull request #13771 from MSedgeMC/fix/lectern-has-book-signal
🤖 Agent: matches LecternBlock.getAnalogOutputSignal (HAS_BOOK checked before the block entity, 26.3 bytecode); I pushed 1d52dd4: the new test had taken the next test's doc comment, and it now also checks has_book=true reads 15.
2026-11-09 22:53:29 +01:00
Ludovic Marie 37bf47f477 Merge pull request #13655 from TheApple05/fix/12091-piston-slime-bounce
🤖 Agent: mob piston pushes now restitute after the move, reading the landing block as Entity.move does, so a pushed mob bounces 1.0 on slime; the corpus case puts the player back so the command_exec oracle runs through (only a known #13022 boat tick differs vs main); run-parity covers the TNT commit.
2026-11-09 22:48:00 +01:00
Ludovic Marie 03d8150bdb Merge pull request #13867 from horizon-rust/perf/villager-poi-scan
🤖 Agent: villager POI searches now take the PoiSections lock and column lookup once per chunk instead of once per section; on the 122-player SMP bench (190 villagers, 3 rotated rounds) villagers went from 20.3-21.3 to 15.7-15.9 µs/mob (ratio to chicken/cow/sheep 2.0-2.1 -> 1.75), ~0.5 ms/tick normalized; same POIs in the same order (20k-step differential test vs the old per-section get loop).
2026-11-09 22:39:30 +01:00
Ludovic Marie c89cd3f9e1 Merge pull request #14374 from horizon-rust/docs/governance
Add GOVERNANCE.md: the council, theme groups and how roles are earned
2026-11-09 22:35:02 +01:00
Ludovic Marie 0b8d44458a Merge pull request #14026 from horizon-rust/layers/render-style
🤖 Agent: graphics_style's and ui_theme's colours are now one OnceLock read per call from menu_style; Horizon's BLOCKS_DARK values come from horizon_look.rs at startup, so the menus look the same; the renderer and client lib no longer depend on horizon-theme.
2026-11-09 22:32:43 +01:00
Ludovic Marie 75a016b1b0 Merge pull request #14211 from oloion/fix/frog-walk-animation
🤖 Agent: the client now runs Frog.updateWalkAnimation (min(d*25,1), 0 while jumping); FROG_WALK keeps FrogModel's 1.5 factor (agent fix 341a929).
2026-11-09 22:32:39 +01:00
Horizon agentandClaude Opus 5.5 c21f627431 discord_presence_e2e: scroll the Extras page to the switch
Only the rows on screen are widgets; the Multiplayer group is below the fold
of an offscreen window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:26:36 +01:00
Ludovic Marie 691a39cab6 Merge pull request #14204 from horizon-rust/layers/studio-extras-gate
🤖 Agent: studio::EDITOR is a compile-time constant (true by default, so the default build runs no check); without horizon-extras recording/playback stay and the editor input, drawing and commands are off.
2026-11-09 22:25:53 +01:00
Ludovic Marie 3c06105a0e Merge pull request #14199 from horizon-rust/layers/replay-move
🤖 Agent: pure move checked by line multiset; the editor is reached through five calls (save path, replay opened/advanced/closing/closed) at the places its code was.
2026-11-09 22:20:16 +01:00
Ludovic Marie 726726a1fc Merge pull request #14186 from horizon-rust/layers/replay-state
🤖 Agent: playback state (driver, camera type to restore, standalone, playback clock) moves to game/replay.rs Replay; Studio keeps the editor; callers ask replay.playing(); game.rs does not grow.
2026-11-09 22:14:22 +01:00
Ludovic Marie 82da4379bd Merge pull request #14161 from horizon-rust/layers/studio-calls
🤖 Agent: pure move: game.rs's inline Studio blocks and the replay key shortcuts become game/studio.rs methods with the same bodies and early returns; the engine asks replaying()/flying()/active().
2026-11-09 22:09:29 +01:00
Ludovic Marie 7627e37dba Merge pull request #14351 from horizon-rust/split/pages-plain-rows
🤖 Agent: Row::Button/Text are #[non_exhaustive] variants built through constructors, so later options do not break crates; the options shell pairs two half buttons into one row and a press on an inactive one reaches nothing (tested).
2026-11-09 22:09:25 +01:00
Ludovic Marie cd8eb96cef Merge pull request #14214 from horizon-rust/split/cosmetics-draw
🤖 Agent: step 2a-i of spec #14091 adds PlayerDraw to horizon-hooks (plain types only) so crates can draw on player models in the world pass; creatures.rs is main's, with the swap moved to creatures/mob_geometry.rs.
2026-11-09 22:04:26 +01:00
Ludovic Marie 829aec1dba Merge pull request #14080 from rdbtCVS/screenshot-off-render-thread
🤖 Agent: F2 and world-icon PNGs are written on a thread after map_async; a second F2 in the same second now skips the name still being written (agent fix 1ffa9ed), and agent screenshots keep the synchronous path.
2026-11-09 22:04:22 +01:00
Ludovic Marie 82a6e896d1 Merge pull request #13627 from vnsmoreira/fix/armor-stand-player-break-loot
🤖 Agent: the player break now pops the named stand item and then goes through brokenByAnything, which plays the break sound before the loot as ArmorStand.brokenByAnything orders it; stand_break_loot, stand_command_hurt and armor_stand_equip_events pass with it, and command_exec on a union with main shows no armor stand mismatch.
2026-11-09 22:04:18 +01:00
Horizon agentandClaude Opus 5.5 62e62c21e6 horizon-discord: the switch is the first splitscreen player's (seat)
hooks::seat() is on main now: other players' settings neither load nor
save the line, and their Extras page has no row.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:02:53 +01:00
Horizon agent a6350aa8f9 Merge branch 'split/discord-crate' into split/discord-setting
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 22:02:16 +01:00
Horizon agent c817ed7fff Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 22:02:02 +01:00
Horizon agent b020ab26b9 piston mob bounce: put the player back when the case ends
The case left the player at 1.5 200 1.5; with that, vanilla's command_exec oracle did not load the chunks of the later teleport lands group (required chunks did not load, twice). Every other group that moves the player puts it back at 8.5 230 8.5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 277584e4df6f0b090655d50d19138c89dad4b77a)
2026-11-09 21:59:36 +01:00
Ludovic Marie 0300ba0a76 Merge pull request #14339 from horizon-rust/team/drop-itarqos5
🤖 Agent: removes itarqos5 from .github/trusted-reviewers.txt, the Team and reviewer lists in docs/release-flow.md and the pinned login in tests/test_nightly_set.py; the nightly, claim and triage test modules (102 tests) passed per the PR.
2026-11-09 21:59:04 +01:00
Ludovic Marie 3a3c182aed Merge pull request #14208 from horizon-rust/split/cosmetics-names
🤖 Agent: step 3b makes names.rs, scoreboard.rs and perspective.rs call the engine's dispatch (Game::hooks_name_marks builds one PlayerModel per tag drawn) instead of the cosmetics directly.
2026-11-09 21:56:53 +01:00
AppaandCodex 35f095bd41 Fix TNT restitution after piston landings
Read the actual landing block for authoritative TNT after applying the
piston push. Preserve client fallback ordering and exhausted-budget
returns. Validate five exact vanilla landings and server/client controls.
Correct the neighboring order test's rounded TNT-width expectation using
the pinned vanilla result without widening its tolerance.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-09 12:55:45 -08:00
Ludovic Marie e76394c2cf Merge pull request #14107 from horizon-rust/split/official-servers-crate
🤖 Agent: the official server now reaches the list only through horizon-official's started hook, so horizon-client's own executable (plain builds, harnesses) has neither the official server nor the withdrawn partner's address; verify and launcher green on this head.
2026-11-09 21:53:06 +01:00
Ludovic Marie d9cfca426c Merge pull request #14207 from horizon-rust/split/cosmetics-sprites
🤖 Agent: the API half of cosmetics step 3: Hooks::name_decoration returns a Copy NameDecoration (fixed &'static mark rectangles, optional badge, tag lift) so no heap data or engine type crosses the hook.
2026-11-09 21:53:02 +01:00
Ludovic Marie 763d4868d7 Merge pull request #14181 from horizon-rust/split/cosmetics-hooks
🤖 Agent: cosmetics step 1 of spec #14091: the emote feed and presence go through plain-type frame and tick hooks in horizon-hooks, so no engine or renderer type crosses the seam.
2026-11-09 21:44:17 +01:00
Ludovic Marie 110a92a1aa Merge pull request #14096 from horizon-rust/layers/seat
🤖 Agent: seat() is a thread-local the engine sets at Split::turn and around a guest's join/profile load (the profile used to load before the guest's turn, into P1's seat); a new profile saves on P1's seat and reads its stores back into the guest's. Without splitscreen it is always 0. Main merged in (horizon_platform::hooks exports both seat and main's tunnel).
2026-11-09 21:35:51 +01:00
Ludovic Marie 3a8d647c8c Merge pull request #14154 from Skr1zzzz/falling-removals
🤖 Agent: one pass over the big lists per RemoveEntities packet, per-id cleanup order kept (dragon before its crystal); moved to remote_removal.rs so remote.rs shrinks (agent fix 8cf1e6b).
2026-11-09 21:30:12 +01:00
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +01:00
Ludovic Marie 3c346ba1a4 Merge pull request #13810 from NikitaMGrimm/hdr-lighting-one-meaning
🤖 Agent: re-checked the new head: only main merged in, and the PR's own diff is unchanged. shader_pack_kind().is_none() is still exactly the old native/pack_post test, so HDR Lighting greys only where the running pack lights the world.
2026-11-09 21:25:43 +01:00
Ludovic Marie b41db51bf9 Merge pull request #13808 from vnsmoreira/packs/polytone-fluid-fog
🤖 Agent: re-checked the new head: the only change is that FluidFog/FluidFogs moved to the render crate's pack_palette after #13959, converted from horizon_polytone's type once when the pack loads. The fog still runs last and is vanilla's without a modifier.
2026-11-09 21:25:38 +01:00
Horizon agentandClaude Opus 5.5 498d9bebcf Add GOVERNANCE.md: the council, theme groups and how roles are earned
The governance the founder adopted on 2026-11-09 as his last decision as
head of the project: a council of equals deciding by 24-hour polls, theme
groups with leads, the selection filter, moderation and appeals, the 18+
rule for roles of trust, the agents, and the account handover path.
README and CONTRIBUTING link to it with one line each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:21:45 +01:00
Ludovic Marie 1ecc5a9bdc Merge pull request #13811 from Mik00000/perf/natural-spawning-cache
🤖 Agent: Util.shuffle runs every pass again, so the level random keeps vanilla's draws, and nothing is cached across ticks any more. What stays saves allocations: Counted streamed instead of collected, the SpawnPlayer buffer reused, and a single-player collect without the neighbour scan. Fake players stay out of the anchors and in the level, as on main. Merged with main on a branch together with #13795 and #13800: clippy and 3827 tests pass; natural_spawn runs in CI parity.
2026-11-09 21:21:02 +01:00
Ludovic Marie d05675d755 Merge pull request #13800 from Mik00000/perf/block-entities-opt
🤖 Agent: every hopper again records tickedGameTime each tick, so the 8/7 transfer cooldown matches vanilla, and is_full keeps #13763's insertion_item for pots. The gains stay: slot iterators instead of a Vec per transfer, empty hoppers with nothing above them or within reach skip the whole pass, one block lookup per tick. Merged with main on a branch together with #13795 and #13811: clippy and 3827 tests pass; the hopper harnesses run in CI parity.
2026-11-09 21:20:57 +01:00
Ludovic Marie 2568835cc6 Merge pull request #13795 from TheMrZZ/fix/command-spawnpoint-respawn-config
🤖 Agent: with the offline capture tools removed, the pitch now goes through RespawnConfig, the personal respawn save (old records load pitch +0.0) and only the free-point branch of find_respawn_and_use; bed and anchor keep pitch 0, as RespawnData's block cases do. Merged with main on a branch together with #13800 and #13811: clippy and 3827 Rust tests pass on the desktop.
2026-11-09 21:20:53 +01:00
Horizon agentandClaude Opus 5.5 e1aa435188 horizon-discord: the Discord switch leaves the engine's Extras (layer plan M5)
The crate keeps its switch: a settings store for its discordPresence line of
horizon-extras.txt (players keep their choice) and a row in the Extras page's
Multiplayer group, which horizon-official registers. The engine loses
Extras::discord_presence, Item::DiscordPresence and the Horizon Settings
button; the hooks lose Activity::Hidden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:19:29 +01:00
martipopsandClaude Opus 5.5 0341ad3d3a Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:37 -05:00
Ludovic Marie 128575e4e5 Merge pull request #14029 from horizon-rust/layers/client-lib-style
🤖 Agent: ui_theme's palette, sizes and Geist fonts come from menu_style; with no fonts given (NEUTRAL) the Horizon overlays measure and draw no text instead of panicking.
2026-11-09 21:15:21 +01:00
martipopsandClaude Opus 5.5 6cc30fbed4 Controllers (radial): main's input_holds asks the pad what each press reached
The radial series' holds_action follows what a press went to, not the binding,
so it takes no settings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:12:36 -05:00
Ludovic Marie cb870cf80d Merge pull request #14027 from horizon-rust/layers/render-theme-out
🤖 Agent: hud_kit's tween curve is pinned to horizon_theme::MOTION by a dev-dependency test, and the HDR logo reads the icon the window hook was given.
2026-11-09 21:11:27 +01:00
Horizon agentandClaude Opus 5.5 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:10:39 +01:00
Horizon agentandClaude Opus 5.5 5385996153 Merge #14107 (split/official-servers-crate) into split/discord-crate
Stacks the Discord crate on the official-servers crate: horizon-official
registers four parts; horizon-client-lib has no horizon-extras feature left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:08:57 +01:00
Ludovic Marie f0cb1e42c0 Merge pull request #14156 from horizon-rust/layers/studio-gather
🤖 Agent: the verify failure was the two rustc-compiled fixtures (test_compute_fov_hook, test_world_input_capture) still modelling game.studio_freecam/studio_replay; they now carry the Studio struct game.rs reads, and both pass on this head.
2026-11-09 21:06:48 +01:00
martipopsandClaude Opus 5.5 83a9845928 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:03:14 -05:00
martipopsandClaude Opus 5.5 3563a7f986 Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Main's input methods (#14001) replace the pad calls: the radial menu closes with
a new player in input_player_replaced (through pad_player_replaced), and
horizon-ctl's key press still closes it beside input_keyboard_used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:58 -05:00
martipopsandClaude Opus 5.5 cc4933eaed Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:21 -05:00
Ludovic Marie 38174db59b Merge pull request #14170 from MSedgeMC/port_map/barrier-pickup-rule
🤖 Agent: Sim::pickup_block already gates a waterlogged barrier on the breaker being creative, as BarrierBlock.pickupBlock does.
2026-11-09 20:59:57 +01:00
Ludovic Marie 093f5efda8 Merge pull request #14001 from horizon-rust/layers/input-feedback
🤖 Agent: gameplay now asks controllers only through input_move, input_holds, input_look and input_feedback in play_input.rs, which still call the same pad functions, so the pad modules can later answer through a registered input source.
2026-11-09 20:59:12 +01:00
Ludovic Marie b444a6b64f Merge pull request #14171 from MSedgeMC/worldlane/chest-opener-rechecks
🤖 Agent: same rows as #14128 without the stray worldgen change: Sim::tick_block's chest arm is recheckOpen, scheduled every 5 ticks from count_opener.
2026-11-09 20:59:03 +01:00
Ludovic Marie e8d192f2ac Merge pull request #14218 from horizon-rust/layers/containers-spec
🤖 Agent: docs only: adds the Look and container-screen API specs to docs/layers.md under Platform registries, following #14217's look::update.
2026-11-09 20:58:59 +01:00
Ludovic Marie 6ad39aa412 Merge pull request #14127 from horizon-rust/fix/offline-names-dev-only
🤖 Agent: the game now plays only as the selected account (else the first), which must own the game itself (launch_for_game); old accounts.json/settings.json load and drop offline, offline_selected, looks, skins/ and dev_offline on the next save, covered by files_from_launchers_with_offline_names_load_and_lose_them_on_save.
2026-11-09 20:58:55 +01:00
Ludovic Marie 6342ce83b2 Merge pull request #14002 from MrCloudy2/fix/bounced-projectile-tick-order
🤖 Agent: a bounced arrow goes back by join number through JoinList::put_back (unit-tested), so it keeps its EntityTickList place; moved out of creatures.rs, which may not grow.
2026-11-09 20:58:51 +01:00
Horizon agent f4e5fbdfd0 Merge the drag hook fixture fix into layers/studio-extras-gate 2026-11-09 20:56:43 +01:00
Horizon agent 6d5b520591 Merge the drag hook fixture fix into layers/replay-move 2026-11-09 20:56:41 +01:00
Horizon agent dff332b257 Merge the drag hook fixture fix into layers/replay-state 2026-11-09 20:56:40 +01:00
Horizon agentandClaude Opus 5.5 c1dc47e41f Tests: the drag hook fixture cuts Studio's drag at studio_mouse_dragged
game.rs's CursorMoved arm now asks self.studio_mouse_dragged(b.width)
instead of the inline timeline drag; the fixture leaves that call out of
its slice as it left the inline block.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:56:39 +01:00
Ludovic Marie 595421d8ee Merge pull request #14018 from MSedgeMC/fix/stonecutter-result-components
🤖 Agent: a stonecutting result with a components patch goes through SmithingResult's template create, so a pack's custom-named result keeps its name; plain results stay on the cheap path.
2026-11-09 20:51:44 +01:00
Ludovic Marie 7169886142 Merge pull request #14122 from MSedgeMC/fix/anvil-selection-colour
🤖 Agent: AnvilScreen.init's setInvertHighlightedTextColor(false) on the rename box; moved apart from #14094's EditBox lines so both merge in either order.
2026-11-09 20:48:53 +01:00
Horizon agentandClaude Opus 5.5 a8bf4c1474 Team: itarqos5 leaves the Horizon Team
Drops the trusted-reviewers line, the Team lists in docs/release-flow.md and the pinned login in
test_nightly_set. GitHub team memberships (horizon-team, contributors) are already removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:46:41 +01:00
Ludovic Marie cdcac3463e Merge pull request #14123 from Mik00000/perf/async-audio-raycasts
🤖 Agent: bee sounds no longer rescan every mob per bee each tick and the sound engine takes one lock per channel update; silent one-frame/empty sounds stop again (agent fix 23ca4a8).
2026-11-09 20:45:15 +01:00
Ludovic Marie e71c626484 Merge pull request #14097 from vnsmoreira/packs/polytone-expr-fast
🤖 Agent: the fast path only takes one-expression programs whose kind is known and is checked bit for bit against the interpreter; 468 lines after the timing test moved to #14185. Queue AFTER 14089.
2026-11-09 20:45:10 +01:00
Ludovic Marie 194ede9376 Merge pull request #14077 from DUzzL/fix/13935-iron-golem
🤖 Agent: OfferFlowerGoal's (6,2,6) box plus forNonCombat().range(6) with sight, isBrightOutside's fixed-time check, the golem's own random for the repair pitch; the defence sight test the body named is now in the diff (agent fix 4643ea9).
2026-11-09 20:45:06 +01:00
Vinícius MoreiraandClaude Opus 5.5 29ac1e17b9 Polytone: fluid modifiers' fog
A fluid modifier's fog_colormap (a flat colour), fog_radius and fog_fade apply to the
water's or lava's fog after everything else: the colour replaces it, the radius scales the
fog, and a fade of 0 makes it a hard wall. Measured on the real mod (red fog at 0.3 of the
radius, within the scene's variance of the vanilla baseline). Colormap ids and expressions
aren't read yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:22 -03:00
Horizon agentandClaude Opus 5.5 4be9343eca Pass trail corners by value to to_array
Vec3::to_array takes &self, so it can't be handed to array::map directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:32:08 +01:00
Horizon agentandClaude Opus 5.5 03fd6705a1 Drop the unused shown binding in Seat::player_layers
worn_of decides what shows since the fx moved onto the hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
Horizon agent 115f5235b9 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-official/Cargo.toml
#	crates/horizon-official/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:24:48 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agent ec19b6320a Merge branch 'split/cosmetics-layers' into split/cosmetics-own 2026-11-09 20:20:01 +01:00
Horizon agent 16e29a2774 Merge branch 'split/cosmetics-draw' into split/cosmetics-layers 2026-11-09 20:20:00 +01:00
Horizon agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
Horizon agentandClaude Opus 5.5 d016678e24 cosmetics test: the prop's root is the item's, whatever its name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:44 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
Horizon agentandClaude Opus 5.5 3d9df204e9 Cosmetics: companions, trails and auras through the hooks; the game's own path removed (spec #14091 step 2b-iii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:08:05 +01:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
Horizon agentandClaude Opus 5.5 4881a0862f Container screens spec: container_key gets the hovered slot, as the other inputs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:59:40 +01:00
Horizon agent 8df100e589 Merge the studio fixture fix into layers/studio-extras-gate 2026-11-09 19:57:58 +01:00
Horizon agent a416f1dfcd Merge the studio fixture fix into layers/replay-move 2026-11-09 19:57:55 +01:00
Horizon agentandClaude Opus 5.5 4c52129f7d Tests: capture fixture follows the Replay split
Replay playback moved out of Studio into game/replay.rs's Replay; the
pointer-capture fixture carries game.replay.driver and Game::studio_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:57:53 +01:00
Horizon agentandClaude Opus 5.5 afa60454b2 Cosmetics: the own body and the inventory picture through PlayerDraw (spec #14091 step 2b-i)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:59 +01:00
Horizon agent 243d765452 Merge the studio fixture fix into layers/replay-state 2026-11-09 19:56:58 +01:00
Horizon agentandClaude Opus 5.5 ff52e244ae Tests: fixture Studio gets flying/replaying/active
fov.rs and the pointer-capture methods now ask self.studio.flying() and
replaying(); the fixtures mirror those three methods of game/studio.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:54 +01:00
Horizon agent bb712cca1a Merge the studio fixture fix into layers/studio-calls 2026-11-09 19:56:20 +01:00
Horizon agentandClaude Opus 5.5 1498fdecd3 Tests: fixtures read Studio's state from game.studio
test_compute_fov_hook and test_world_input_capture paste fov.rs and the
pointer-capture methods into a fixture Game; those now read
self.studio.{freecam,replay,rmb_down}, so the fixture carries a Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:14 +01:00
Horizon agentandClaude Opus 5.5 8cf1e6b4d6 Move a packet's entity removal out of remote.rs
remote.rs is over 3000 lines and may not grow (verify's "Big files don't
grow" failed): remove_entity, remove_entities and their test move to
game/remote_removal.rs, a child module of remote, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:05 +01:00
Horizon agentandClaude Opus 5.5 341a92902c Frog walk: keep FrogModel's 1.5 walk speed factor
FrogModel.setupAnim applies FROG_WALK with applyWalk(pos, speed, 1.5F, 2.5F)
on land (1.0F is the swim animation's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:54:21 +01:00
Horizon agentandClaude Opus 5.5 aa8592fbf9 Cosmetics: their items on the body drawn through PlayerDraw (spec #14091 step 2a-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:20 +01:00
Horizon agentandClaude Opus 5.5 e859476293 test_world_input_capture: the move read is input_move now
tick_remote_level asks the input source (input_move) instead of pad_move, so
the order check (load completes, then the player moves) looks for that call
(verify run 42649031940).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:45:27 +01:00
MSedgeMC 7a4aedf231 Merge the PR branch's test fix 2026-11-09 18:42:08 +00:00
MSedgeMC 8e45334760 test: give the piglin load test an entity to load
`mob_from_nbt` needs the entity's `id` and `Pos`; the fixture only had the
brain, so the test unwrapped `None`. Add both to the compound and to the
never-expiring variant.
2026-11-09 18:41:53 +00:00
Horizon agentandClaude Opus 5.5 89235891ce Container screens spec: the API as implemented (&mut reads, spread, Equipment, every menu's slots)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:38:19 +01:00
Horizon agentandClaude Opus 5.5 2081a0dd95 Merge main into #14127; be.json is main's translation minus the offline-name keys
Main retranslated 252 Belarusian launcher strings; this PR removes the 28
offline-name keys. be.json is main's file with those 28 keys taken out (7 of
them were among the retranslated ones); every lang file parses and none keeps
a removed key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:35:38 +01:00
Horizon agentandClaude Opus 5.5 cef35e64e7 horizon-hooks: std's Mutex by its path after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:32:42 +01:00
Horizon agent 3a9b05d21d Merge remote-tracking branch 'origin/main' into split/discord-crate
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
2026-11-09 19:31:04 +01:00
Horizon agentandClaude Opus 5.5 8534f12db0 Look spec: update, each crate its own fields
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:25:12 +01:00
Horizon agentandClaude Opus 5.5 8aed306751 ui_theme: main's text_line test reads the body size and ink from the style
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:24:26 +01:00
Horizon agentandClaude Opus 5.5 7e95a1f57f stations tests: keep the registration-order doc on its own test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:23:37 +01:00
Horizon agentandClaude Opus 5.5 e0e4c5e9db docs/layers.md: spec of the look and the container screen API (layer plan M5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:22:25 +01:00
Horizon agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon agent da800dff55 Merge remote-tracking branch 'rv3n/14140' into rv3-fix-14140 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 67f61f1c24 Test the piglin anger load on a whole saved piglin
The test loaded a compound with no id or Pos, so mob_from_nbt returned None
and it could not pass; it now saves the id, a position and the brain, and
checks a TTL past the counter's range too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:20:49 +01:00
Horizon-agent 723c4e7c2b Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 19:18:44 +01:00
Horizon-agent c32f43b4b0 Merge remote-tracking branch 'origin/main' into pr-13800 2026-11-09 19:18:37 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon-agent b26a050b2b Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 19:18:26 +01:00
Horizon agent bb19f898e4 Merge branch 'layers/render-theme-out' into layers/client-lib-style 2026-11-09 19:17:07 +01:00
Horizon agent bbc17333bd Merge branch 'layers/render-style' into layers/render-theme-out 2026-11-09 19:17:06 +01:00
Horizon agentandClaude Opus 5.5 8630bbed74 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:17:03 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agent 0f0ca381d4 Merge remote-tracking branch 'origin/main' into split/official-servers-crate
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 19:14:23 +01:00
Horizon agent 2c5baa6a60 Merge branch 'layers/render-theme-out' into layers/client-lib-style
# Conflicts:
#	crates/horizon-client/src/ui_theme.rs
2026-11-09 19:12:40 +01:00
Oloion e3d24ab1a5 mobs: fix frog walking keyframe animation in singleplayer
Remote entity client update now calls frog::walk_target for Species::Frog so limb_speed advances at the vanilla 25x distance rate instead of 4x. Also restore FrogAnimation.FROG_WALK speed factor to vanilla 1.0F in body_anim and guard keyframe looping against zero length.
2026-11-09 21:11:32 +03:00
Horizon agent ed35d477be Merge branch 'layers/render-style' into layers/render-theme-out
# Conflicts:
#	tools/layers.json
2026-11-09 19:10:41 +01:00
Horizon agent 7a236ba054 Merge branch 'layers/window-icon-hook' into layers/render-style
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 19:10:23 +01:00
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
MSedgeMC 385dc78afc Merge remote-tracking branch 'upstream/main' into fix/lectern-has-book-signal 2026-11-09 18:06:14 +00:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Horizon agentandClaude Opus 5.5 661a253ec4 Armor stand /damage: attribute and clear freeze in the existing branches
Main already attributes the hit branch and has entity_event_by. The
explosion, ignite and burn branches now take the cause in place, so the
separate refused function (which left them dead) is gone; causeDamage keeps
one name. The frost-walker test no longer shadows game() (it did not compile).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:04:13 +01:00
Horizon agentandClaude Opus 5.5 ac0f7d6030 Studio's editor runs only with horizon-extras (layer plan M6)
studio::EDITOR = cfg!(feature = "horizon-extras"): without it the bars,
camera path, editor mouse and keys and the freecam/keyframe/path and
studio screen commands are off; recording and playback stay (platform).
The default build is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:03:04 +01:00
MSedgeMC 2e9f807458 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 18:01:23 +00:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
MSedgeMC d56b769df5 Merge upstream/main 2026-11-09 18:00:08 +00:00
Horizon agentandClaude Opus 5.5 42026ce09f Replay recording and playback move to game/replay.rs (layer plan M6)
Pure move: open/close/seek/advance, record start/stop/status, the world
snapshot and the record/replay words of /studio and horizon-ctl studio
leave game/studio.rs, which keeps the editor (free camera, path,
timeline, top bar, its own command words). Where playback touched the
editor it calls it: save_studio_camera_track, studio_replay_opened,
studio_replay_advanced, studio_replay_closing, studio_replay_closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:26 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
MSedgeMC 8d7ca30ea4 Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:58:10 +00:00
Horizon agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01:00
Horizon agentandClaude Opus 5.5 6bb4c0eda9 Put a bounced projectile back through JoinList::put_back
creatures.rs is over 3000 lines and may not grow (verify's file-size check):
the join-order insertion moves to join_seq.rs, with a unit test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:40 +01:00
Horizon agentandClaude Opus 5.5 2f06f532e5 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:52 +01:00
Horizon agentandClaude Opus 5.5 ac0a2f1275 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:51 +01:00
MSedgeMC 092dee299b Merge remote-tracking branch 'upstream/main' into fix/stonecutter-result-components 2026-11-09 17:52:13 +00:00
Horizon agentandClaude Opus 5.5 963782a17b Replay playback's state leaves Studio: game/replay.rs's Replay (layer plan M6)
Recording and playback are platform, Studio's editor is Horizon
(docs/layers.md, Replays): the driver, the camera type to restore,
standalone and the playback clock move to Replay; Studio keeps the free
camera, timeline and top bar. Callers ask self.replay.playing(); the own
player's tick fraction is Game::own_player_alpha(). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:52:07 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agentandClaude Opus 5.5 23ca4a855a audio: silent short sounds stop again; drop the unused camera_epoch
- A channel of one frame or none was skipped before the mixing loop, so it
  never stopped and kept its channel; the inaudible fast path also computed
  (frames - 1) in usize, which underflows for an empty sound. Both now stop
  it, as the loop does; a step of 0 no longer stops a sound the loop keeps.
- Mixer::camera_epoch was written and never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:46:45 +01:00
MSedgeMC 16172da6b7 port_map: the chest and ender chest recheckOpen rows are ports
`Sim::tick_block`'s chest arm is `ChestBlock.tick`/`EnderChestBlock.tick`:
it recounts the openers through `recheck_container_openers`
(`ContainerOpenersCounter.recheckOpeners`), broadcasts the lid event and
re-schedules the five-tick tick while anyone has the container open. The
rows still said only the copper golems' openers were rechecked.
2026-11-09 17:46:19 +00:00
Horizon agentandClaude Opus 5.5 5720b15eff ui: the highlight flag and its test sit apart from #14094's
#14094 adds EditBox::centered beside text_color and a test at the end of
ui.rs's tests; this PR's field, initialiser and test touched the same lines.
Moved further down so both merge in either order. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:49 +01:00
Horizon agentandClaude Opus 5.5 2e754b1297 Polytone: the ticker timing test moves to its own PR; field() last
Keeps this PR under the project's 500-line limit (519 -> 468): the ignored
custom_ticker_cost timing test follows in a separate PR. Ctx's field() moves
to the end of its Host impl, so this merges cleanly beside #14089, which
changes the impl's own line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:45:18 +01:00
MSedgeMC 70c8d7089d port_map: the barrier pickup row's creative rule is checked
`Sim::pickup_block` returns false for a waterlogged barrier unless the
breaker is creative, which is `BarrierBlock.pickupBlock`'s gate; the row
said the rule was not there.
2026-11-09 17:45:14 +00:00
Horizon agentandClaude Opus 5.5 1ffa9ed25f Screenshots: a second F2 in the same second takes the next name
The PNG is now written on its own thread, so the first press's file may not
exist yet when a second press picks its name: both took the same path and
two writers raced on one file. screenshot_name also skips the paths of the
screenshots still being written (UiState::screenshot_messages).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:35 +01:00
Horizon agentandClaude Opus 5.5 4643ea9357 Iron golem: the defence goal sees the hating villager too
DefendVillageTargetGoal.canUse finds the villagers through attackTargeting
(forCombat().range(64)), which tests the golem's line of sight to each: a
villager behind a wall no longer sets the golem on the player. The PR body
named this change but the commit missed it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:12 +01:00
Horizon agentandClaude Opus 5.5 0022d3f42d Clamp a saved anger TTL past the counter's range instead of wrapping it
ExpirableValue's ttl is a long; narrowing it with `as i32` turned a TTL
above i32::MAX into a negative count, so the piglin loaded calm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:22 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Horizon agentandClaude Opus 5.5 38865e907e Studio: game.rs asks it through a few calls (layer plan M6)
game.rs's inline Studio blocks (bars, camera path, timeline drag, mouse
buttons, free-camera flight) and play_keys.rs's replay shortcuts move into
game/studio.rs methods; the engine reads Studio through replaying(),
flying() and active(). Pure move, no behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:37:38 +01:00
Nikita Maximilian Grimm b7615761ee Merge remote-tracking branch 'origin/main' into hdr-lighting-one-meaning
# Conflicts:
#	crates/horizon-client-render/src/gfx/output.rs
2026-11-09 18:34:44 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agentandClaude Opus 5.5 93e0536aca Studio: its state in one struct, game/studio.rs's Studio (layer plan M6)
Game's ten studio_* fields and last_replay_tick become one field, studio:
studio::Studio. Pure gather: every access is renamed, nothing else changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:54 +01:00
Skr1zzzzandClaude Opus 5.5 396353c6b1 Client: one pass over the big entity lists per RemoveEntities packet
Part of #13007. Each removed id ran its own retain over the mobs, orbs, dropped
items, falling blocks and primed TNT, and a scan of the passenger map, so a
packet of n ids made n passes over lists that hold thousands of falling blocks
while sand drops. RemoveEntities now removes its ids together: the per-id side
effects keep their order (the dragon's nearest crystal still follows the
crystals one id at a time), and those five lists and the passenger map are
filtered once against the packet's ids.

8,000 falling blocks, packets of 256 ids (debug test build): 26.1 ms -> 1.3 ms
per packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:26:14 +03:00
Horizon agent 4c28a9b5b9 Merge remote-tracking branch 'origin/main' into pr-13795 2026-11-09 18:26:09 +01:00
MSedgeMC ac1f560622 port_map: the piglin's guarded ANGRY_AT memory is read back
`tools/port_map/game2-004.tsv` recorded `Piglin.readAdditionalSaveData` as
reading the baby flag and inventory; it now also names the brain's
`minecraft:angry_at` UUID and TTL read by `mobs/piglin.rs::read_guarded_anger`.
2026-11-09 17:21:54 +00:00
MSedgeMC 6a4172369c test: a saved guarded anger loads its UUID and ticks
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A compound whose `Brain.memories."minecraft:angry_at"`
has the UUID `[90, 91, 92, 93]` and `ttl: 99L` loads a piglin with
`extra.anger == 99` and `guarded_angry_at == Some(Some([90, 91, 92, 93]))`.
A memory without a TTL (the codec's never-expires form) loads the longest
counter, `i32::MAX`.

Without the read both fields stay at their defaults: `left: 0`, `right: 99`
for the counter and `left: None`, `right: Some(Some([90, 91, 92, 93]))` for
the UUID.
2026-11-09 17:21:54 +00:00
MSedgeMC 06130ae40d fix: a piglin keeps the guarded anger its brain saved
`PiglinAi` sets `ANGRY_AT` with an expiry when a player breaks a guarded block
near a piglin, and `Brain.Packed` saves every memory with a codec under
`Brain.memories`: the value is the player's UUID and the TTL is the ticks left.
`LivingEntity.readAdditionalSaveData` reads that brain back.

Horizon wrote `minecraft:angry_at` in `mob_to_nbt` but `mob_from_nbt` never read
it: a piglin that a player had angered unloaded with its chunk and came back
with `extra.anger` 0 and no actor UUID, so it stopped attacking and the guarded
event was forgotten.

The load now reads the memory back: a four-int UUID into
`extra.piglin.guarded_angry_at` and the `ttl` into the existing `extra.anger`
counter, with no TTL read as the longest counter there is. The writer moves to
`mobs/piglin.rs` beside it, which also keeps `entity_nbt.rs` from growing.
2026-11-09 17:21:53 +00:00
Horizon agentandClaude Opus 5.5 8ac6a79035 rustfmt, Cargo.lock: horizon-discord
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:19:44 +01:00
Horizon agentandClaude Opus 5.5 153e0d20a4 horizon-discord: the hook's Client by its path
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:32 +01:00
Horizon agentandClaude Opus 5.5 1e789800af launcher: remove offline names; play only as an account that owns the game
The project decided that Horizon plays only with a Microsoft account that
owns Minecraft: Java Edition. The launcher loses, in every build:

- the hidden Developer options section (seven clicks on the sidebar
  version) and Settings::dev_offline;
- offline names: adding, selecting and playing as one, the game's made-up
  name as a player, their skins and capes (skins.rs, skin_page.rs), the
  quick switch's and Settings > Account's offline entries, and the CLI's
  `account offline`, `account forget-offline` and `account skin`;
- the `--name` and HORIZON_OFFLINE_SKIN* it gave the game for them.

The game plays as the selected account (else the first), and
account::launch_for_game requires that account to own the game itself:
another account's ownership no longer lets it start under a name of its
own. With no network, its last answer still stands, as before.

Files from older launchers still load: accounts.json's `offline`,
`offline_selected` and `looks` and settings.json's `dev_offline` are
ignored on read and gone from the next save, which also deletes the old
names' skin and cape files.

The 28 strings only these used leave the 86 lang files and the
lang_review copies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:05 +01:00
MSedgeMC 74e0245c47 port_map: EditBox.setInvertHighlightedTextColor 2026-11-09 17:17:21 +00:00
MSedgeMC e5eacec45c test: the anvil's selection keeps its colour
The box reports its own inversion off; a default box inverts; the selection
it would draw is the value.
2026-11-09 17:17:15 +00:00
Mik00000andClaude Opus 5.5 cf23e5001a perf(audio): optimize sound attenuation, spatial math, and batch channel updates
- Early-out for stationary listener in Audio::set_listener (bypasses mutex lock contention completely when camera does not move, ~5.25 ns vs ~36 ns)
- Eliminate redundant distance sqrt in stereo_gains: projections onto orthonormal basis preserve Euclidean distance, so reuse d.length()
- Replace asin in elevation with sqrt(1 - u^2) using identity cos(asin(u)) = sqrt(1 - u^2), avoiding inverse trig entirely
- Single-camera fast path in mix to avoid splitscreen camera search loop in normal single-player / multiplayer mode
- Distance squared early-out for linear attenuation (bypasses basis projections and stereo gains for inaudible sounds outside attenuation radius)
- Fast sample skipping for silent / zero-gain audio frames without decoding interpolation
- Batch channel parameter updates via Backend::update_channel, reducing 3 mutex lock acquisitions per ticking instance to 1
- Batch stopped query via Backend::filter_stopped, replacing N individual channel mutex locks per tick with a single query pass
- Avoid O(N*M) entity loop in Game::start_mob_sounds by collecting bee indices in initial filter pass

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:16:52 +02:00
MSedgeMC 0e9233b230 fix: the anvil's selected name keeps its colour
AnvilScreen sets setInvertHighlightedTextColor(false) on its rename box, so a
selection leaves the white text alone instead of painting it black on the
highlight. EditBox always inverted it.
2026-11-09 17:16:42 +00:00
Horizon agentandClaude Opus 5.5 01343707af horizon-discord: Discord Rich Presence leaves the engine for a Horizon crate
Layer plan M5: discord_presence.rs moves to the new Horizon crate
horizon-discord (file move), which the official executable registers on two
new platform hooks: Hooks::client_started (every game window, automated
runs included, never a dedicated server) and Hooks::activity (what the
player does, dispatched on change only, P1's). game/discord.rs becomes the
engine's game/activity.rs, which reports Activity (Hidden while the Discord
switch is off, until the switch moves to the settings store). The discord
Cargo feature goes: horizon-client's own executable has no presence.
discord_presence_e2e.py runs the official executable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:42 +01:00
Horizon agentandClaude Opus 5.5 466201a864 Cargo.lock: horizon-official-servers after the merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agent ddb4f9df58 Merge branch 'split/official-servers-module' into split/official-servers-crate
# Conflicts:
#	Cargo.lock
#	docs/forking.md
2026-11-09 18:02:48 +01:00
Horizon agentandClaude Opus 5.5 c873af989a Menu style: ui_theme's overlays take their palette, sizes and fonts from it
horizon-client-lib no longer depends on horizon-theme outside its tests
(layer plan M6). menu_style gains the overlays' colours, ui_theme's type
scale and shapes, and its fonts (none given: the overlays draw no text);
ui_theme, the on-screen keyboard, the missing-resources window and the
Windows boot paint read them. horizon_look gives Horizon's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 84baabff3c Renderer: the HDR logo and the HUD kit's motion no longer read horizon-theme
horizon-client-render no longer depends on horizon-theme outside its tests
(layer plan M6):
- hud_kit::motion: a Tween takes a cubic-bezier curve; the solver lives
  here, tested against the design system's curves.
- The HDR calibration logo is the window icon given at startup.
- The font tests take Geist Mono as a dev-dependency.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:10 +01:00
Horizon agentandClaude Opus 5.5 6622e459b2 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 600a863354 Menu style: the settings pages and the tab strip take their colours from the layer above
menu_style (horizon-client-render): the colours of the settings pages
(graphics_style) and the HUD kit's tab strip, given once at startup;
vanilla's neutral greys when none is given. graphics_style's and tabs'
palette consts become functions reading it. horizon_look gives Horizon's
(layer plan M6; render -> theme goes in the next PR).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:58 +01:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
rdbtCVSandClaude Sonnet 5.5 1b4f056692 Merge main into hdr-lighting-one-meaning
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:44:13 +00:00
rdbtCVSandClaude Opus 5.5 15d2dd04c4 Screenshots: write F2's and the world icon's PNG off the render thread
The render thread waited for the GPU to finish the screenshot's frame
(poll wait_indefinitely), then converted the pixels and encoded the PNG
inline: at 4K or a Screenshot Quality above 1x, a second or more of frozen
game. F2's and the world icon's readbacks are now mapped asynchronously and
written on a thread of their own; the result reaches the chat a frame or a
few later. Agent screenshots and screenshot-sequence keep the synchronous
path, which hands them the result with the frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:50 +00:00
Vinícius MoreiraandClaude Opus 5.5 3b03b98836 Polytone: expression fields answered without their names
The fast path resolves the methods without arguments expressions read most (age, life,
custom, x, y, z, temperature, downfall) when the program compiles; a host may answer them by
that Field (Host::field) instead of comparing names, and one that doesn't, or a case it
doesn't cover, goes through the name as before, with the same value. The particles' host
answers a particle's own fields, the colormaps' block host its position and biome climate.
The equivalence test now also runs a host that answers fields.

Custom particle tick (custom_ticker_cost, release): 376 ns before, 344 ns now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 4e4767bbfe Polytone: a numeric fast path for expressions
A Polytone expression ran through the interpreter's Values and Results: about 263 ns for a
firefly's alpha that costs 7 ns written in Rust. An expression of one statement whose every
part is a number (constants, arithmetic, comparisons of numbers, math functions, ternaries,
and root.method(...) calls) now compiles a second time into a tree of f64s. It answers
exactly what the interpreter would: the result's kind (number, boolean, or a method's own
value at the top), method arguments as the interpreter passes them, Value::truthy for
methods in conditions; anything that could tell them apart (strings, == on a method's
value, locals, game functions) stays in the interpreter. A test runs a corpus of
expressions through both and compares every value and every host call.

Custom particle tick (custom_ticker_cost, release): 715 ns before, 376 ns now. A section's
colormap tints (colormap_tint_cost): 178 µs before, 135 µs now, same colours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
Vinícius MoreiraandClaude Opus 5.5 a2d34c7dbd Polytone: a timing test for custom particle tickers
custom_ticker_cost (ignored) ticks a firefly-like custom particle (five expressions with
sines and a named custom) and prints the time a tick takes, about 0.7 µs in release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:42:26 -03:00
DUzzL e42acfc7ea Give the iron golem's poppy offer vanilla's range, sight and daylight gate, sight-test villagers in the defence goal, draw the repair pitch from the golem 2026-11-09 17:37:02 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
Horizon agent d82a6941d1 Merge remote-tracking branch 'origin/main' into pr-13800
# Conflicts:
#	crates/horizon-client/src/game/machines.rs
2026-11-09 17:19:40 +01:00
Horizon agentandClaude Opus 5.5 f7772b587f Natural spawning: the level keeps every player, the spawn state the anchoring ones
After main filtered fake players out of the spawn anchors, building the
level from that list would drop them from natural_level too; main builds
the level from every player and SpawnPlayer from the filtered list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:42 +01:00
Horizon-agent d1098c9a91 Merge remote-tracking branch 'origin/main' into pr-13811 2026-11-09 17:17:21 +01:00
Horizon-agentandClaude Opus 5.5 d50d56d837 Natural spawning: shuffle every pass, no candidate cache keyed on chunk count
Util.shuffle in ServerChunkCache.tickChunks draws from the level's random
whether or not a category may spawn, so skipping it moved every later draw
of that random off vanilla's. The candidate cache keyed on player x/z and
the chunk count went stale when a chunk's ticket level or entity loading
changed under a player standing still (an AFK farm). Both go; the allocation
savings (streamed Counted, reused SpawnPlayer buffer, the single-player
collect) stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:21 +01:00
Horizon agentandClaude Opus 5.5 8e2780a8f0 Cargo.lock: horizon-official-servers
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Horizon agentandClaude Opus 5.5 1feb0c7b0b Keep every hopper's cooldown entry, as vanilla keeps tickedGameTime
With get_mut, an idle hopper never recorded the tick it ticked, so a
hopper that had already ticked this tick and then received an item from
another hopper took cooldown 8 instead of 7 (HopperBlockEntity.tryMoveInItem
skips one when its tickedGameTime >= the source's). Restore the per-tick
entry and the original prune; the other optimizations stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:07 +01:00
Tran Anh Van 96aa9791e6 Isolate ArmorStand.hurtServer changes to avoid 3-way merge overlap 2026-11-09 23:12:17 +07:00
Horizon agentandClaude Opus 5.5 8a5af429d7 Drop the offline capture reporting tools
They only re-project captures kept on the author's machine and start no
engine, so the test_*_parity.py name would register a harness CI cannot
run. The Rust fix and its unit and guest-death tests stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:50 +01:00
Horizon agentandClaude Opus 5.5 2cd4ec9888 horizon-official-servers: the official server leaves the engine for a Horizon crate
Layer plan M5: official_servers.rs moves (pure file move plus its hook) to
the new Horizon crate horizon-official-servers, which the official
executable registers through the platform's hooks: Hooks::started now gets
the game folder (Started::game_dir), and horizon-platform re-exports
servers.dat as horizon_platform::servers. Its addresses come from
horizon-addresses: the withdrawn partner's joins the list file, so its
allowed entry in tools/addresses.json goes. horizon-client's own executable
(plain builds, harnesses) no longer touches servers.dat at startup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:24 +01:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
MSedgeMC f0c1ec9d84 A stonecutter result keeps its components
StonecuttingRecipe.assemble creates the result from ItemStackTemplate, so a
result's components (a data pack's custom name, damage, ...) belong on the
output. Horizon built the result from its id and count only.

Test: a stonecutting recipe whose stone result names it "Cut Stone" yields a
named stone.
2026-11-09 15:59:33 +00:00
Tran Anh Van c66936f747 Sync sculk entity_event_by and entity_killed_event_by with upstream main 2026-11-09 22:54:38 +07:00
Horizon agentandClaude Opus 5.5 755b7800c1 Input source: what gameplay tells the pad goes through engine methods too
input_keyboard_used, input_made_pointer_event, input_player_replaced,
input_takes_stack_drop and input_hud_begin replace gameplay's direct writes to
the pad's state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:51:53 +01:00
Horizon-agentandClaude Opus 5.5 1d52dd41ba Give the HAS_BOOK lectern test its own doc and a positive control
The new test was inserted between the getRedstoneSignal doc comment and the
test it documents. It now also checks that a lectern whose state has
has_book=true reads the book signal.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:21 +00:00
Tran Anh Van 0a6874877d Sync Guest::may_build with upstream main 2026-11-09 22:49:53 +07:00
MrCloudy2andClaude Opus 5.5 30149bfb41 Harness: an arrow bouncing off a wind charge keeps its place in the tick order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:47:59 +01:00
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07:00
Ludovic MarieandClaude Opus 5.5 0031c67d04 rustfmt explosion.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:14 +01:00
MrCloudy2andClaude Opus 5.5 2726614191 A projectile put back after its hit keeps its place in the tick order
An arrow that bounces off an entity (deflect REVERSE) never leaves the level
in vanilla, so it keeps its place in EntityTickList. Horizon's projectile
pass drops it and finish_arrow_hit adds it again with add_projectile, which
appended it behind every projectile already waiting: those then ticked
before it. add_projectile now puts a projectile that has already joined back
at its join position; a new one still goes last.

Found with the Orbital Skybender: the arrow that reads each digit missed
every other wind charge, so only half the digits were read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:41:23 +01:00
martipopsandClaude Opus 5.5 fb23266b6e Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 d7d1e72afc Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
The radial menu's overlay_holds_view gate moves with debug_crosshair_drawn into
game/hud_render.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:33 -05:00
martipopsandClaude Opus 5.5 2a5ec67594 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:32:17 -05:00
Ludovic MarieandClaude Opus 5.5 5152376c4b input_feedback: commands already have a feedback
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:14 +01:00
Horizon agentandClaude Opus 5.5 8f331a10f5 Input source: gameplay asks controllers through engine methods (layer plan M6)
Gameplay reads the analog move vector, held mappings and the view turn, and
reports what the player may feel (Feedback), through input_move, input_holds,
input_look and feedback in play_input.rs. Only these four bodies name the pad
modules, which answer today; Feedback is an engine type the pad maps to Rumble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:15:28 +01:00
MSedgeMC 97ab5f100a port_map: tick_item_activation is in game/totem.rs
The row's note still said game.rs after the totem split. The function is in
game/totem.rs and both tick paths call it.
2026-11-09 14:35:28 +00:00
Horizon agentandClaude Opus 5.5 95a3721df1 perf(villagers): list a column's POI sections under one lookup
A villager's POI search (AcquirePoi, occupied POIs, homes) visited each
section of 9x9 columns through PoiSections::get: a mutex lock, a hash
lookup and an Arc clone per section. PoiSections now keys its lists by
column, and PoiSections::column takes the lock and finds the column once,
handing back only the sections that hold POIs, in the same order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:04:32 +01:00
Mik00000 0f8225e608 perf(spawning): optimize natural mob spawning density scan and chunk cache 2026-11-09 12:59:02 +02:00
Nikita Maximilian GrimmandClaude Opus 5.5 b2ba0cb395 HDR settings: HDR Lighting greyed under a shader pack, one switch for it
HDR Lighting is one thing: all of Horizon's own lift above paper white, light
sources, the sun and the lit surfaces. HORIZON_HDR_LIGHTING=0 switched off only
the surfaces' part, a second switch with another scope; it is gone, and the
setting does the job. docs/graphics.md now describes the setting.

Under a shaders/ pack, and under a 0.2 pack on the float frame, Horizon lifts
nothing, so the row did nothing there. It is now greyed and says "The shader
pack lights the world itself." Under a 0.1 pack on the 8- or 16-bit frame the
last pass still lifts the light sources and the sun, so the row stays live
there, as it does for a pack that is refused, missing or whose mod is off. The
page reads this from the renderer's output, which now carries whether the
running pack lights the world. HDR's own reasons still come first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 11:21:31 +01:00
Mik00000 72f748a785 perf(machines): optimize block entity ticking and hopper transfers 2026-11-09 12:11:26 +02:00
TheMrZZandCodex 9ad798b74b Preserve personal spawnpoint pitch for dedicated guests
Part of #13625. Keep owner physical pitch open and document recovered offline arrival evidence.

Co-Authored-By: Codex <[email protected]>
2026-11-09 10:33:51 +01:00
MSedgeMC 4a296fffb2 A lectern without its has_book state feeds no comparator
LecternBlock.getAnalogOutputSignal returns 0 unless the block state has
HAS_BOOK, before it asks the block entity for its page signal. Horizon used
the block entity's book alone, so a lectern whose state says it has no book
still fed a comparator.
2026-11-09 09:04:21 +00:00
martipopsandClaude Opus 5.5 f3e4420699 Merge controller/dial-3-play (with upstream/main) into controller/dial-4-editor
Main's renderer output for the graphics screen and HDR calibration moves with
shell_mut and shell_events into options_ui/shell_pages.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:03:26 -05:00
martipopsandClaude Opus 5.5 db3f211cfa Merge controller/dial-2-model (with upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:40 -05:00
martipopsandClaude Opus 5.5 dc2aaacea3 Merge upstream/main into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:39 -05:00
Vinícius MoreiraandClaude Opus 5.5 41fc640974 A player's break of an armor stand rolls its loot table
ArmorStand.hurtServer breaks a stand through brokenByPlayer on a second
player hit within five ticks, and at once for an always_kills_armor_stands
type: the stand item (with the stand's name) pops at its block, then
brokenByAnything plays the sound, rolls the loot table and pops the
equipment. Horizon's punch dropped the item and equipment without the
loot table, and /damage refused every can_break / always_kills type.

Part of #12514

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:35:28 -03:00
AppaandCodex 300d65c317 Preserve piston restitution outside the unoccupied mob slice
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 23:13:12 -08:00
AppaandCodex 18c8cc5eb9 Cover partial piston landings and the mob bounce sync marker
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:58:07 -08:00
AppaandCodex f9baaaed27 Limit the ground-state change to vertical NoAI pushes
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:24:42 -08:00
AppaandCodex 72352e0006 Keep the piston mob fix on the authoritative path
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:18:07 -08:00
AppaandCodex d4da822ac2 Keep piston bounce fixture gamerules unchanged
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:06:44 -08:00
AppaandCodex 26f8689238 Use mob restitution for piston pushes onto slime
Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 22:01:33 -08:00
martipopsandClaude Opus 5.5 9a43f998c1 Docs (UI kit): the shell's lists, steps and opened focus
Screen::choose, Hosted::clicks_step, Shell::focus_soon and
changed_elsewhere, the binding row's chosen text, Capture::Pad on any
shell screen, and where a shell screen is hosted (options_ui/shell_pages.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:44:08 -05:00
martipopsandClaude Opus 5.5 2cdde29154 Merge controller/dial-3-play into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:38 -05:00
martipopsandClaude Opus 5.5 7816e1adbb Docs (UI kit): overlays that hold the view, through overlay_holds_view
What vanilla draws in the middle of the view (the crosshairs, the chat's
lines, the toasts, the hands) gives way to an overlay through one query,
so the next overlay adds its condition there rather than to each piece.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 d94ceb2eb9 Merge controller/dial-2-model into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:43:31 -05:00
martipopsandClaude Opus 5.5 4843a2b975 Docs (UI kit): who asks pad_capture, and how a new overlay joins it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:42:32 -05:00
martipopsandClaude Opus 5.5 0d7c032320 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:47 -05:00
martipopsandClaude Opus 5.5 932fea900e Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Main moved hand_geometry and first_person_arm to game/frame_geometry.rs:
the hands still come from hands_shown there, so an overlay that holds
the view hides them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:42 -05:00
martipopsandClaude Opus 5.5 a53de4943f Merge upstream/main (a758c3eb20) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 22:39:11 -05:00
martipopsandClaude Opus 5.5 2aee551905 Options: the shell pages' glue in options_ui/shell_pages.rs
Which shell each page opens, what a shell's outputs do, and the radial
menu's name entry and rows move out of options_ui.rs as they are, so the
file doesn't grow. pad_clicks_step now also sets Shaders' shell back,
which main put on the shell since.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
martipopsandClaude Opus 5.5 599ef2acd3 Settings shell: its tests in settings_shell/tests.rs
A pure move: the tests module leaves settings_shell.rs as it is, so the
file is under 3000 lines (docs/layers.md#file-size) and the radial
editor's additions to the shell don't grow a file over the limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:26:15 -05:00
martipopsandClaude Opus 5.5 9969321db4 Merge controller/dial-3-play (upstream/main) into controller/dial-4-editor
Main put Shaders on the settings shell and made Screen::undo return
outputs: the radial screens' sheets use Sheet::new and their undo returns
none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:22:10 -05:00
martipopsandClaude Opus 5.5 acfa08d9e7 Controllers (radial): one overlay_holds_view query, so game.rs and remote.rs don't grow
game/hud_room.rs answers whether an overlay holds the view (the radial
menu, once it shows), and the 2D and F3 crosshairs, the chat, the toasts
and the hands ask it. The four toast draws go into draw_every_toast and
the hands into hands_shown there; draw_pad_over_hud draws the menu and
then the pad's cursor. The respawn no longer closes the menu itself: the
new player's first position does, through the one pad_player_replaced
call in remote.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 91d32f40bc Merge controller/dial-2-model (upstream/main) into controller/dial-3-play
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:17:38 -05:00
martipopsandClaude Opus 5.5 1691d8d65b Controllers (radial): the radial menu's file settings in gamepad/radial_file.rs
RadialMode, RadialStick, the opener's thumb rule, each stick's session
opener, can_bind, set_radial_stick and their tests move out of
gamepad.rs as they are, so gamepad.rs stays under 3000 lines. The paths
under horizon_client_input::gamepad stay the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 408b3dc47d Controllers: pad_capture is a module of game/pad, so game.rs doesn't grow
game/pad_capture.rs moves to game/pad/capture.rs as it is, its items
visible to the gameplay module as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:10:46 -05:00
martipopsandClaude Opus 5.5 d55b4b0a78 Merge controller/dial-1-kit (upstream/main) into controller/dial-2-model
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 21:07:52 -05:00
martipopsandClaude Opus 5.5 1a1e4c6f46 Controllers (radial): edit the menu's pages on the settings shell
Controller Settings > Bindings > Radial Menu... opens the pages on
main's settings shell (radial_editor.rs): a sheet per page with its
eight slots by direction, then Rename Page, New Page and Delete Page,
then Reset All Pages and the opener's row.

- A press on a slot opens the list of every action and Empty for it,
  which names the slot, marks what it holds and opens on it; Right and
  Left still step it (`Screen::choose`, and a controller's Left and
  Right count as steps, `pad_clicks_step`). Back from the list, the
  focus is on the slot again (`Shell::focus_soon`).
- Rename opens a small page with a name field, Done and Cancel.
- Delete and Reset act at once; Undo puts the pages and the opener back
  as the screen found them. At most 6 pages, at least one.
- The opener's row is a `Value::Binding` on `Capture::Pad`: it waits in
  place for a button, refuses the ones under the selecting thumb and B,
  and its strip says when no button opens the menu or another action
  shares it.
- Controller Settings' own Undo leaves the pages to this screen.

e2e: radial_editor, and Settings Pages reaches the new page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 7008bc32ba Controllers (radial): e2e for the radial menu in play
tools/pad_menu_e2e.py gains 28 radial_* cases, each a real offscreen game
driven through horizon-ctl's virtual pad: commits, retention, pages,
cancels (B, Start, a disconnect, focus loss, death with immediate
respawn), the camera and the walking stick, menus and the on-screen
keyboard keeping the opener, held sneak and drop, D-pad rolls after a
commit or a cancel, RT/keys/clicks fighting back, Toggle mode, the left
stick and Either, a bumper opener, haptics, narration, unavailable and
missing actions, a file from before the menu, Nintendo glyphs and the
status rows. A case can seed the controller file and pass extra launch
arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 2ccc6f7ffe Controllers (radial): Hold or Toggle, and the stick that selects
Controller Settings > General gains a Radial Menu group on main's
settings shell: Radial Menu (Hold, the default, or Toggle) and Radial
Menu Selects With (Right Stick, Left Stick or Either), each describing
its value in the strip, and stepping with Left and Right as every shell
choice does. Stepping the stick goes through `set_radial_stick`, so an
opener under the thumb that now selects is let go, and stepping back
gives it again. e2e: radial_stick_back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:42 -05:00
martipopsandClaude Opus 5.5 a14242eb15 Controllers (radial): the menu's settings in the controller file
PadSettings gains the radial pages, `radialMode` and `radialStick`, and
the opener's binding, `pad.radial` on D-pad Right; nothing in play reads
the opener yet (it routes as a held button until the menu does).

- `bindLayout:2`: once, for a file from before the opener, a button the
  player bound by hand keeps it, and the opener goes unbound rather than
  sharing it. A file from before the binds list's defaults binds none.
- An opener under the thumb that selects, or on B, can't be bound
  (`can_bind`), and a file with one reads as never saved.
- `set_radial_stick`: a stick that takes the opener's thumb unbinds it;
  stepping back gives each stick the opener it had this session, else
  D-pad Right where it's free.
- Lines this game doesn't read (a newer Horizon's settings, an action it
  doesn't have, a button it doesn't know) are kept as they were, and a
  newer `bindLayout` is kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d747ae6c26 Controllers (radial): hold D-pad Right for a radial menu of actions
The radial menu in play (game/pad/radial_play.rs): hold the opener and
the selecting stick points at one of eight slots; letting go runs the
action once, through the same path as its button. Bumpers turn the
pages. `pad.radial` now routes to it, and the dial draws over play with
the game's icon painter and the frame's motion clock.

- Hold or Toggle, and the right, left or either stick, from the file.
- B, Start, a disconnect, focus loss, a new player (death with
  immediate respawn, the End's exit) or a screen cancels; RT/LT, any key
  or mouse click cancels and goes on. A flick between polls counts (the
  pads' peaks, splitscreen's router included). Holds from before the
  open keep working; a held drop restarts its wait.
- A D-pad roll off the opener reaches nothing for 100 ms after the later
  of the close and the opener's release.
- What can't run is dimmed: Emote without cosmetics, Quick Actions
  without a dialog; Social in single player shows vanilla's "not
  available" message, as its key now does.
- While it shows: the chat lines, toasts, crosshair (F3's too), guide
  and first-person hand give way; the status rows are kept clear.
- Haptics on reaching a runnable slot, a page turn and a release that
  ran nothing; narration of the page and a settled slot.
- horizon-ctl: `pad focus true|false` sends a real focus event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 a9e60a6782 Controllers (radial): pages of eight slots, and their text
radial.rs gains what the menu's pages hold: `RadialAction` (actions that
do everything on their press; Horizon's own namespaced, vanilla's by
their key mapping, old ids read through an alias table), `Slot` (an
action, Empty, or a Missing id kept as it was, which never runs), named
`Page`s of a kind (`Fixed` slots, or `Hotbar`, read and kept), and the
default General and Camera presets, which hold only actions with no
button of their own.

`Pages::parse` and `serialize`: the `radial_<n>_*` lines of
horizon-controller.txt. A page the player hasn't changed is saved as its
preset, so it follows the defaults; `radialLayout` is read and a newer
one kept; unknown fields, kinds and slot ids past the eighth survive a
save; up to 16 pages are read and the menu turns through 6. Edits:
set_slot, rename, add, remove.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 7a92b5064c Controllers (radial): the dial's hub, page tabs and Cancel hint
The hub names the selection on a dark disc: the action, and for
Perspective, Hide HUD and Cinematic Camera its state and what a press
turns it to; for a slot that can't run, why; for a missing action, its
id; while a stick must be let go first, "Let go of the stick". The large
icon goes above the name when the hub has room, and its text stays off
every tile (`fit::fit_in_circle`). Toggle tiles get state chips where
there's room for them.

Along the top, the pages are a `tabs::tab_strip` between the bumpers'
glyphs; collapsed, the bumpers name the pages they turn to. With a
bumper for the opener only the other shows. B's Cancel sits on a plate
at the lower right when the view is roomy, in the player's glyphs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 87346bb92f Controllers (radial): the menu's model, held open by a button
horizon-client-input's radial.rs: `RadialMenu`, what the opener, the
selecting stick and the bumpers do while the menu is open, knowing pages
only by their count and slots by their index. Eight slots clockwise from
the top; a release commits once; 250 ms of neutral retention; a stick
leant since before the press waits to be let go (`StickHistory`), while a
flick that starts with the press selects; tilts that follow the
deadzone (`Tilt`); toggle mode's latch, with A running the slot settled
on (`A_SETTLE`); `DpadSettle` for a thumb rolling off the opener; and
`Settle` for narration. Every tuning value is at the top of the file.

gamepad.rs: `RadialMode` and `RadialStick`, `opener_refused` (the opener
is never under a thumb that selects, nor on B) and the radial haptics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 d16c9137f9 Controllers (radial): the dial's ring and tiles
radial_overlay.rs draws the radial menu over play, in the look the
mockups settled on (`docs/ui-kit.md`'s pieces): a dark vignette, the
ring's track with the selected 45° sector lit, and eight tiles on true
45° rays, each vanilla's game mode switcher slot with its action's icon
from the player's own resources. An empty slot is a pip; one that can't
run is dimmed and slashed; a missing action shows its mark. The sizes
follow the view's height (`Layout`, the scaling rule in its docs), and
the ring keeps below the boss bars and above the status rows over the
hotbar (`Keep::StatusRows`, which the HUD's room gains). Its motion
(`Look`) is quiet and never selects: a fade and scale in, the sector
easing to a new slot, the tiles sliding in on a page turn, a commit's
flash and a fade out.

Nothing draws it yet. The hub, the page tabs and the Cancel hint come
next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
martipopsandClaude Opus 5.5 0e4163f67f Controllers: one routing point for what owns the pad
game/pad_capture.rs: `Game::pad_capture` says which overlay over play
owns the controller now, in one priority order (the on-screen keyboard,
then a prompt over the world). The keyboard checks in pad.rs,
pad_actions.rs, pad_menu.rs and the gameplay guide ask
`pad_keyboard_captures()`. Behaviour is unchanged; the radial menu adds
its place in the order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-08 19:40:41 -05:00
518 changed files with 21757 additions and 13378 deletions

No files matched your search

-1
View File
@@ -13,4 +13,3 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
+2
View File
@@ -198,3 +198,5 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
Generated
+21
View File
@@ -1449,6 +1449,7 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1649,6 +1650,15 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1792,14 +1802,25 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
+2
View File
@@ -34,6 +34,8 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
+483
View File
@@ -0,0 +1,483 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
+2
View File
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
+4
View File
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
+37 -172
View File
@@ -74,70 +74,11 @@ pub trait ResourceSource {
vec!["minecraft".into()]
}
}
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
fn bundled_code(code: &str) -> (&str, bool) {
match code {
"ko" => ("ko_kr", false),
"hr" => ("hr_hr", false),
"de_at" => ("de_de", false),
"de_ch" => ("de_de", true),
"fr_ca" | "fr_ch" => ("fr_fr", false),
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
code => (code, false),
}
}
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
std::sync::OnceLock::new();
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
/// counts.
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
let _ = CRATE_FILES.set(files);
}
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
if files.is_empty() {
return;
}
let (file, swiss) = bundled_code(code);
let mut own = HashMap::new();
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
read_lang_file(&bytes, &mut own);
}
for (c, json) in files.iter().flat_map(|f| f.iter()) {
if *c != file {
continue;
}
let mut theirs = HashMap::new();
if swiss {
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
} else {
read_lang_file(json.as_bytes(), &mut theirs);
}
for (key, value) in theirs {
if !own.contains_key(&key) {
map.insert(key, value);
}
}
}
}
/// Horizon's own strings, compiled in so they need no prepared resources.
struct Bundled;
impl ResourceSource for Bundled {
fn read(&self, path: &str) -> Option<Vec<u8>> {
let code = path
.strip_prefix("assets/horizon/lang/")?
.strip_suffix(".json")?;
let (file, swiss) = bundled_code(code);
let path = format!("assets/horizon/lang/{file}.json");
let body = match path.as_str() {
let body = match path {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
@@ -162,10 +103,10 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/ko_kr.json" => {
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
}
"assets/horizon/lang/hr_hr.json" => {
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
}
"assets/horizon/lang/pt_pt.json" => {
@@ -390,20 +331,30 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/vec_it.json" => {
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
}
"assets/horizon/lang/de_de.json" => {
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
"assets/horizon/lang/fr_fr.json" => {
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json" => {
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
if swiss {
return Some(body.replace('ß', "ss").into_bytes());
}
Some(body.as_bytes().to_vec())
}
fn namespaces(&self) -> Vec<String> {
@@ -487,16 +438,6 @@ impl Language {
/// from every namespace and every source in priority order (later sources
/// override).
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
Self::load_with(sources, code, crate_files)
}
/// [`Language::load`] with these crates' lang files.
fn load_with(
sources: &[&dyn ResourceSource],
code: &str,
crate_files: &[&[(&str, &str)]],
) -> Self {
let mut map = HashMap::new();
let mut codes = vec![DEFAULT];
if code != DEFAULT {
@@ -512,14 +453,12 @@ impl Language {
sources.iter().flat_map(|s| s.namespaces()).collect();
for c in codes {
for namespace in &namespaces {
for (i, source) in sources.iter().enumerate() {
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
read_lang_file(&bytes, &mut map);
}
// The crates' text right after the engine's own (`Bundled`, the first source).
if i == 0 && namespace == "horizon" {
read_crate_files(crate_files, c, &mut map);
}
for source in sources {
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
else {
continue;
};
read_lang_file(&bytes, &mut map);
}
}
}
@@ -749,43 +688,6 @@ mod tests {
);
}
#[test]
fn a_crates_text_loads_after_the_engines_and_before_packs() {
static CRATE: &[(&str, &str)] = &[
(
"en_us",
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
),
(
"de_de",
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
),
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
];
let pack = Mem(HashMap::from([(
"assets/horizon/lang/en_us.json".to_owned(),
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
)]));
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
let en = load("en_us");
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
// The engine's own file wins on a key both have; a pack wins over both.
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
let de = load("de_de");
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
// Regional variants read their language's file, as the engine's do.
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
// A language the crate lacks falls back to its English.
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
// Without crate files, nothing changes.
let plain = Language::load_with(&[&pack], "de_de", &[]);
assert_eq!(plain.get("test.crate.hello"), None);
assert_eq!(plain.len() + 1, de.len());
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
@@ -837,20 +739,6 @@ mod tests {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-client/assets/horizon/lang")
}
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
/// registered through `horizon_hooks::lang`), with that folder.
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
.unwrap()
.flatten()
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
.map(|(name, path)| (name, path.join("assets/lang")))
.filter(|(_, dir)| dir.join("en_us.json").is_file())
.collect();
dirs.sort();
dirs
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
@@ -888,33 +776,11 @@ mod tests {
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let engine = check_lang_dir(&bundled_dir(), true);
assert!(
engine.1 >= 7,
"found {} bundled lang files in {}",
engine.1,
bundled_dir().display()
);
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
// files also have, the engine's text would win and hide the crate's.
for (name, dir) in crate_lang_dirs() {
let (en, _) = check_lang_dir(&dir, false);
for key in en.keys() {
assert!(
!engine.0.contains_key(key),
"{name}: {key} is also the engine's"
);
}
}
}
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
/// same placeholders as English, no empty text. Its en_us and how many files it has.
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(dir).unwrap().flatten() {
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
@@ -923,7 +789,7 @@ mod tests {
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
!bundled || Bundled.read(&path).is_some(),
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
@@ -949,7 +815,11 @@ mod tests {
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
(en, seen)
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
@@ -964,10 +834,10 @@ mod tests {
}
}
}
let english = |dir: &std::path::Path| -> HashMap<String, String> {
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
};
let mut en = english(&bundled_dir());
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
@@ -977,11 +847,6 @@ mod tests {
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
// A crate that ships lang files names its keys in its own sources.
for (name, dir) in crate_lang_dirs() {
en.extend(english(&dir));
rust_files(&crates.join(name).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
+4 -2
View File
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
@@ -98,6 +98,7 @@ actions! {
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
Radial, "pad.radial", None;
}
impl Action {
+221 -18
View File
@@ -253,6 +253,17 @@ impl Activity {
/// Horizon's pad-only actions (no vanilla key mapping).
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
/// Held, opens the radial menu (`radial.rs`).
pub const RADIAL: &str = "pad.radial";
mod radial_file;
pub use radial_file::{RadialMode, RadialStick, opener_refused};
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
/// are; 2: the radial menu's opener resolved against the player's own buttons.
const BIND_LAYOUT: u32 = 2;
/// The layout from which the file, not the default, says where the opener is.
const OPENER_LAYOUT: u32 = 2;
/// Bindable actions and their default buttons: vanilla key mappings (the
/// game acts as their default key) and Horizon's pad-only actions. Start is
@@ -271,6 +282,7 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.togglePerspective", Some(Button::Select)),
("key.pickItem", Some(Button::DPadLeft)),
("key.chat", Some(Button::DPadUp)),
(RADIAL, Some(Button::DPadRight)),
("key.advancements", None),
("key.playerlist", None),
("key.command", None),
@@ -303,11 +315,14 @@ pub enum Route {
HotbarStep {
next: bool,
},
Radial,
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Fullscreen,
/// F3's debug screen, shown or hidden as F3 alone does.
DebugOverlay,
/// Does nothing by itself: what reads it asks whether the button is held
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
Held,
@@ -320,10 +335,12 @@ pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
RADIAL => Some(Route::Radial),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
"key.fullscreen" => Some(Route::Fullscreen),
"key.debug.overlay" => Some(Route::DebugOverlay),
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
@@ -334,12 +351,16 @@ pub fn route(action: &str) -> Option<Route> {
}
}
/// The radial menu's opener, as the binds list and the gameplay guide name it.
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
match action {
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
HOTBAR_NEXT => "Hotbar: Next Slot",
RADIAL => RADIAL_CAPTION,
other => other,
}
}
@@ -654,6 +675,21 @@ pub struct PadSettings {
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
/// The radial menu's pages.
pub radial: crate::radial::Pages,
/// Whether the radial menu's opener is held or pressed once.
pub radial_mode: RadialMode,
/// The stick that selects in it.
pub radial_stick: RadialStick,
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
/// button it doesn't know), kept as they were so a save here loses nothing.
extra: Vec<(String, String)>,
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
/// kept so that one doesn't resolve its file again as older than it is.
bind_layout: u32,
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
/// With row and back gives each stick's own opener again.
openers: radial_file::StickOpeners,
}
impl Default for PadSettings {
fn default() -> Self {
@@ -680,6 +716,12 @@ impl Default for PadSettings {
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
radial: Default::default(),
radial_mode: RadialMode::Hold,
radial_stick: RadialStick::Right,
extra: Vec::new(),
bind_layout: BIND_LAYOUT,
openers: radial_file::StickOpeners::default(),
}
}
}
@@ -706,6 +748,7 @@ impl PadSettings {
}
pub fn parse(text: &str) -> Self {
let mut s = Self::default();
let mut saved_opener = None;
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -754,22 +797,85 @@ impl PadSettings {
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
"radialMode" => s.radial_mode = RadialMode::parse(value),
"radialStick" => s.radial_stick = RadialStick::parse(value),
// Read after the loop, from the whole text.
"bindLayout" => {}
// The radial menu's pages (`radial::Pages::parse`).
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
other => {
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
if let Some(button) = button {
s.bind(action, button);
}
let known = other
.strip_prefix("bind_")
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
match (known, button) {
// The opener once the selecting stick is known.
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
(Some(action), Some(button)) => s.bind(action, button),
_ => s.extra.push((other.to_owned(), value.to_owned())),
}
}
}
}
// A file from before these defaults: its player's buttons stay where they were.
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
// An opener on a button it can't be with this stick reads as never saved.
let refused_opener = match saved_opener {
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
Some(button) => {
s.bind(RADIAL, button);
false
}
None => false,
};
if let Some(pages) = crate::radial::Pages::parse(
text.lines()
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim(), value.trim())),
) {
s.radial = pages;
}
let lines = || text.lines().map(str::trim);
let saved = |action: &str| {
let prefix = format!("bind_{action}:");
lines().any(|line| line.starts_with(&prefix))
};
// The layout the file was written in; none in a file from before these defaults.
let layout = lines()
.find_map(|line| line.strip_prefix("bindLayout:"))
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
// Once, for a file from before the radial menu's opener, or one with the opener on a
// button it can no longer be: a button the player bound by hand stays theirs, and the
// opener's default doesn't take it. From then on the file says where the opener is, and
// a conflict made since stays shown in the binds list.
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
let default_opener = ACTIONS
.iter()
.find(|(action, _)| *action == RADIAL)
.and_then(|(_, button)| *button);
let taken = s.bindings.iter().any(|(action, button)| {
*action != RADIAL && button.is_some() && *button == default_opener
});
if taken {
s.bindings.insert(RADIAL, None);
}
}
// The default too can be under the selecting thumb (D-pad Right, the left stick
// selecting): no button opens the menu until the player picks one.
if s.binding(RADIAL)
.is_some_and(|b| opener_refused(s.radial_stick, b))
{
s.bindings.insert(RADIAL, None);
}
// A file from before these defaults: its player's buttons stay where they were, and
// nothing they saved shares a button with the radial menu's opener. They can bind one
// in the binds list.
if !text.is_empty() && layout.is_none() {
s.bindings.entry(RADIAL).or_insert(None);
for (action, button) in [
("key.drop", Button::East),
("key.togglePerspective", Button::DPadUp),
@@ -778,10 +884,7 @@ impl PadSettings {
("key.advancements", Button::DPadDown),
] {
// A button saved by hand stays, even one that's now this action's default.
if !text
.lines()
.any(|line| line.starts_with(&format!("bind_{action}:")))
{
if !saved(action) {
s.bindings.entry(action).or_insert(Some(button));
}
}
@@ -822,7 +925,25 @@ impl PadSettings {
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str("bindLayout:1\n");
text.push_str(&format!(
"radialMode:{}\nradialStick:{}\n",
self.radial_mode.name(),
self.radial_stick.name()
));
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
for (key, value) in &self.extra {
// A button this game didn't know, for an action the player has bound here since.
let rebound = key
.strip_prefix("bind_")
.is_some_and(|action| self.bindings.contains_key(action));
if !rebound {
text.push_str(&format!("{key}:{value}\n"));
}
}
// The default pages aren't written: they can improve for a player who never changed them.
if self.radial != crate::radial::Pages::default() {
text.push_str(&self.radial.serialize());
}
text
}
pub fn default_of(action: &str) -> Option<Button> {
@@ -835,11 +956,15 @@ impl PadSettings {
None => Self::default_of(action),
}
}
/// Bind an action (`None` unbinds); unknown actions are ignored.
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
/// (`can_bind`), changes nothing.
pub fn bind(&mut self, action: &str, button: Option<Button>) {
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
return;
};
if button.is_some_and(|b| !self.can_bind(action, b)) {
return;
}
if button == *default {
self.bindings.remove(name);
} else {
@@ -1440,6 +1565,24 @@ pub enum Rumble {
radius: f32,
},
Totem,
/// The radial menu's stick reached a slot that would run: a tick.
RadialSlot,
/// The radial menu's page turned: a shorter tick.
RadialPage,
/// The page turn went round an end: a second tick after the first.
RadialWrap,
/// A release ran nothing after something had been selected: a soft buzz.
RadialNothing,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
use crate::input_source::Feedback;
match event {
Feedback::BlockBroken => Rumble::BlockBroken,
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
Feedback::Totem => Rumble::Totem,
}
}
}
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
/// long it lasts.
@@ -1449,6 +1592,7 @@ pub struct Pulse {
pub weak: f32,
pub millis: u32,
}
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
impl Rumble {
pub fn name(self) -> &'static str {
match self {
@@ -1456,6 +1600,10 @@ impl Rumble {
Rumble::BlockBroken => "block",
Rumble::Explosion { .. } => "explosion",
Rumble::Totem => "totem",
Rumble::RadialSlot => "radial",
Rumble::RadialPage => "radial_page",
Rumble::RadialWrap => "radial_wrap",
Rumble::RadialNothing => "radial_nothing",
}
}
/// The pulse, or `None` when it is too faint to feel.
@@ -1482,6 +1630,9 @@ impl Rumble {
millis: 350,
})
}
Rumble::RadialSlot => Some(RADIAL_TICK),
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
Rumble::RadialNothing => Some(RADIAL_NOTHING),
Rumble::Totem => Some(Pulse {
strong: 0.7,
weak: 0.7,
@@ -1503,6 +1654,10 @@ impl Rumble {
};
match self {
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
Rumble::RadialSlot
| Rumble::RadialPage
| Rumble::RadialWrap
| Rumble::RadialNothing => vec![p],
Rumble::Hurt(_) => {
let tail = p.millis.saturating_sub(50) / 2;
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
@@ -1863,7 +2018,55 @@ bind_key.hotbar.2:north
let odd = PadSettings::parse(
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
);
assert_eq!(odd, PadSettings::default());
assert_eq!(
odd.look_sensitivity,
PadSettings::default().look_sensitivity
);
assert_eq!(odd.binding("key.jump"), Some(Button::South));
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
}
#[test]
fn lines_this_game_doesnt_read_survive_a_save() {
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
let text =
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
let s = PadSettings::parse(text);
assert_eq!(
s.binding("key.jump"),
Some(Button::South),
"the default meanwhile"
);
let saved = s.serialize();
for line in [
"hapticStrength:0.4\n",
"bind_horizon:dash:south\n",
"bind_key.jump:paddle1\n",
] {
assert!(saved.contains(line), "{line} lost from\n{saved}");
}
assert_eq!(PadSettings::parse(&saved), s);
// Bound here since, the action's own line is the one written.
let mut s = s;
s.bind("key.jump", Some(Button::West));
let saved = s.serialize();
assert!(!saved.contains("paddle1"), "{saved}");
assert_eq!(
PadSettings::parse(&saved).binding("key.jump"),
Some(Button::West)
);
}
#[test]
fn a_newer_bind_layout_is_kept() {
let newer = BIND_LAYOUT + 1;
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert!(
s.serialize()
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
);
}
#[test]
@@ -0,0 +1,354 @@
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
use super::{Button, PadSettings, RADIAL};
/// The buttons under the right thumb, which steers the right stick.
const RIGHT_THUMB: [Button; 4] = [
Button::South,
Button::West,
Button::North,
Button::RightStick,
];
/// The buttons under the left thumb, which steers the left stick.
const LEFT_THUMB: [Button; 5] = [
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::LeftStick,
];
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
/// with a bumper for the opener the other bumper turns the pages.
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
button == Button::East
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
}
/// How the radial menu's opener holds it open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialMode {
/// Held: letting go runs what is selected.
#[default]
Hold,
/// A press opens it; A or the opener again runs what is selected, B closes it.
Toggle,
}
impl RadialMode {
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
pub fn name(self) -> &'static str {
match self {
Self::Hold => "hold",
Self::Toggle => "toggle",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == value)
.unwrap_or_default()
}
}
/// The stick that selects in the radial menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialStick {
#[default]
Right,
Left,
/// The last stick to lean past the select tilt.
Either,
}
impl RadialStick {
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
pub fn name(self) -> &'static str {
match self {
Self::Right => "right",
Self::Left => "left",
Self::Either => "either",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|stick| stick.name() == value)
.unwrap_or_default()
}
}
/// The opener each stick had, for this session: what the player stepped through, not a setting,
/// so it is neither saved nor compared.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct StickOpeners([Option<Button>; 3]);
impl PartialEq for StickOpeners {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl StickOpeners {
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
let at = RadialStick::ALL
.iter()
.position(|s| *s == stick)
.unwrap_or(0);
&mut self.0[at]
}
}
impl PadSettings {
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
/// a thumb that selects, or on B (`opener_refused`).
pub fn can_bind(&self, action: &str, button: Button) -> bool {
action != RADIAL || !opener_refused(self.radial_stick, button)
}
/// The stick the radial menu selects with. An opener under the thumb that now selects is
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
/// stick had before in this session, or else its default where nothing else has it (D-pad
/// Right, back on the right stick).
pub fn set_radial_stick(&mut self, stick: RadialStick) {
let was = self.radial_stick;
*self.openers.slot(was) = self.binding(RADIAL);
self.radial_stick = stick;
if self
.binding(RADIAL)
.is_some_and(|b| opener_refused(stick, b))
{
self.bindings.insert(RADIAL, None);
}
if self.binding(RADIAL).is_some() {
return;
}
// The player's own, shared or not: they put it there.
if let Some(own) = self
.openers
.slot(stick)
.filter(|b| !opener_refused(stick, *b))
{
self.bind(RADIAL, Some(own));
return;
}
let default = Self::default_of(RADIAL);
let free =
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
if free {
self.bindings.remove(RADIAL);
}
}
}
#[cfg(test)]
mod tests {
use super::super::Rumble;
use super::*;
#[test]
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
for s in [PadSettings::default(), PadSettings::parse("")] {
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
assert!(!s.conflicts(RADIAL));
}
let s = PadSettings::parse(&PadSettings::default().serialize());
assert_eq!(s, PadSettings::default());
}
#[test]
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding(RADIAL), None);
assert!(!s.conflicts("key.playerlist"));
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn a_saved_opener_stays_where_it_was_saved() {
let s =
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
let mut s = PadSettings::default();
s.bind("key.playerlist", Some(Button::DPadRight));
let text = s.serialize();
assert!(text.contains("bindLayout:2"));
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
assert!(read.conflicts(RADIAL));
assert_eq!(read, s);
// The same, hand-written with spaces around the lines.
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_layout_one_file_resolves_the_opener_once() {
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
let text = s.serialize();
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
assert_eq!(PadSettings::parse(&text), s);
// Without a conflict the opener keeps its default.
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// A line with leading spaces is still a saved opener.
let s =
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
use Button as B;
let face = [B::South, B::West, B::North, B::RightStick];
let dpad = [
B::DPadUp,
B::DPadDown,
B::DPadLeft,
B::DPadRight,
B::LeftStick,
];
for stick in RadialStick::ALL {
let mut s = PadSettings::default();
s.set_radial_stick(stick);
for button in Button::ALL {
let refused = button == B::East
|| (stick != RadialStick::Left && face.contains(&button))
|| (stick != RadialStick::Right && dpad.contains(&button));
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
// Other actions still take any of them.
assert!(s.can_bind("key.chat", button));
}
// A bumper is always free, and stays one through the file.
s.bind(RADIAL, Some(B::LeftBumper));
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
}
// A file with a refused opener reads as never saved: the default, unless taken.
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
let taken = format!("{text}bind_key.playerlist:right\n");
assert_eq!(
PadSettings::parse(&taken).binding(RADIAL),
None,
"{button:?}"
);
}
// Read with the stick it was saved for, wherever the stick's line is.
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
assert_eq!(left.binding(RADIAL), Some(B::North));
}
#[test]
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
assert_eq!(
s.binding(RADIAL),
None,
"D-pad Right is under the left thumb"
);
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// An opener the player picked that both sticks allow stays.
s.bind(RADIAL, Some(Button::RightBumper));
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
}
#[test]
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
s.bind(RADIAL, Some(Button::North));
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// And Left again has its Y back, shared with Inventory as the player left it.
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), Some(Button::North));
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
let mut s = PadSettings::default();
s.bind(RADIAL, Some(Button::DPadLeft));
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
}
#[test]
fn a_file_from_before_the_radial_menu_binds_no_opener() {
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
assert_eq!(s.binding(RADIAL), None);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
let tick = Rumble::RadialSlot.pulse().unwrap();
assert!((40..=50).contains(&tick.millis), "{tick:?}");
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
let page = Rumble::RadialPage.pulse().unwrap();
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
let nothing = Rumble::RadialNothing.pulse().unwrap();
assert!(
nothing.strong > 0.0 && nothing.millis > tick.millis,
"{nothing:?}"
);
assert!(
nothing.strong.max(nothing.weak) < 0.3,
"a soft buzz: {nothing:?}"
);
}
#[test]
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
let s = PadSettings::default();
assert_eq!(
(s.radial_mode, s.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
for mode in RadialMode::ALL {
for stick in RadialStick::ALL {
let mut s = PadSettings {
radial_mode: mode,
..PadSettings::default()
};
s.set_radial_stick(stick);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
}
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
assert_eq!(
(read.radial_mode, read.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
}
#[test]
fn radial_pages_survive_the_settings_file() {
let mut s = PadSettings::default();
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
s.radial =
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
let read = PadSettings::parse(&s.serialize());
assert_eq!(read.radial, s.radial);
assert_ne!(read.radial, crate::radial::Pages::default());
}
}
@@ -141,8 +141,13 @@ impl Guide {
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
// Nothing else in play names the radial menu.
GuideDetail::Basic => Guide {
left: Vec::new(),
left: self
.left
.into_iter()
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
.collect(),
right: self.right,
},
GuideDetail::Off => Guide {
@@ -178,6 +183,8 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
// Nothing else in play says the menu is there; it opens for a spectator too.
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
]
.into_iter()
.flatten()
@@ -566,6 +573,7 @@ mod tests {
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
]
);
}
@@ -736,12 +744,12 @@ mod tests {
gliding: true,
..Facts::default()
}),
["key.inventory"]
["key.inventory", crate::gamepad::RADIAL_CAPTION]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
let facts = Facts {
spectator: true,
flying: true,
@@ -765,7 +773,8 @@ mod tests {
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down"
"horizon.controller.guide.fly_down",
crate::gamepad::RADIAL_CAPTION
]
);
}
@@ -0,0 +1,15 @@
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
//! types, never a controller's; the controller code answers.
/// Something that happened to the player that a device may let them feel (a controller's
/// vibration). The device decides how, or whether.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Feedback {
/// The player broke a block.
BlockBroken,
/// An explosion this far away (blocks) with this power.
Explosion { distance: f64, radius: f32 },
/// A totem of undying saved the player.
Totem,
}
+2
View File
@@ -9,11 +9,13 @@ pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod input_source;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod radial;
pub mod snap;
pub mod window;
File diff suppressed because it is too large. Load diff
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -739,7 +760,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
+3 -7
View File
@@ -70,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
@@ -109,17 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "horizon-extras"]
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
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", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
@@ -131,6 +125,8 @@ windows-segment-heap = []
workspace = true
[dev-dependencies]
# ui_theme's unit tests draw text in the design system's fonts.
horizon-theme = { path = "../horizon-theme" }
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
+2 -1
View File
@@ -29,7 +29,6 @@ horizon-protocol = { path = "../horizon-protocol" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
@@ -85,5 +84,7 @@ horizon-extras = []
workspace = true
[dev-dependencies]
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
horizon-theme = { path = "../horizon-theme" }
horizon-items = { path = "../horizon-items", features = ["test-support"] }
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
@@ -0,0 +1,158 @@
//! Audio occlusion, attenuation and mixing benchmark.
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
use horizon_client_render::{
audio::Audio,
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
};
use std::{path::Path, time::Instant};
fn main() {
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
let mut audio = Audio::silent(Path::new("/nonexistent"));
audio.engine.loaded = true;
audio.backend_live_for_bench();
audio.record_heard = false;
// Register a sound event with attenuation distance 16
audio.engine.registry.add_file(
"minecraft",
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
&|_, _| true,
);
// Insert 1 second of audio into cache (48000 mono samples)
let samples: Vec<f32> = (0..48_000)
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
.collect();
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
// 1. Benchmark set_listener (called every client render frame)
let listener_iterations = 200_000;
let start = Instant::now();
for i in 0..listener_iterations {
let angle = (i as f32) * 0.01;
audio.set_listener(
[
100.0 + (angle.cos() as f64) * 5.0,
64.0,
200.0 + (angle.sin() as f64) * 5.0,
],
angle,
0.0,
);
}
let listener_elapsed = start.elapsed();
println!(
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
listener_elapsed,
listener_iterations,
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
);
// 1b. Benchmark set_listener stationary (lock elimination early-out)
let stationary_iterations = 200_000;
let start = Instant::now();
for _ in 0..stationary_iterations {
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
}
let stationary_elapsed = start.elapsed();
println!(
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
stationary_elapsed,
stationary_iterations,
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
);
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
// 64 sounds within attenuation distance (<16 blocks)
// 64 sounds outside attenuation distance (>16 blocks)
for i in 0..128 {
let dist = if i < 64 {
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
} else {
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
};
let pos = [100.0 + dist, 64.0, 200.0];
let instance = SoundInstance::new(
"minecraft:bench.loop",
crate_sound_source(i),
1.0,
1.0,
horizon_core::random::LegacyRandom::new(i as i64),
true, // looping
0,
horizon_client_render::sound_engine::Attenuation::Linear,
pos,
false,
);
audio.play_instance(instance, false);
}
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
let mut buffer = vec![0.0f32; 1024 * 2];
let mix_iterations = 5_000;
let start = Instant::now();
for _ in 0..mix_iterations {
audio.mix_buffer(&mut buffer, 2, 48_000);
}
let mix_elapsed = start.elapsed();
println!(
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
mix_elapsed,
mix_iterations,
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
);
// 4. Benchmark SoundEngine::tick with ticking mob sounds
let mut mobs = Vec::new();
for i in 0..64 {
mobs.push(MobSound {
id: 2000 + i,
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
speed: 0.25,
baby: false,
angry: i % 2 == 0,
silent: false,
attack_scale: 0.5,
sniffer_digging: false,
});
}
let tick_ctx = TickContext {
player_underwater: false,
player_removed: false,
vehicle: None,
carts: Vec::new(),
mobs: mobs.clone(),
frozen: false,
player_position: [100.0, 64.0, 200.0],
player_speed_sqr: 0.1,
player_fall_flying: false,
ridden: None,
camera_position: [100.0, 64.0, 200.0],
};
let tick_iterations = 10_000;
let start = Instant::now();
for _ in 0..tick_iterations {
audio.tick(false, &tick_ctx);
}
let tick_elapsed = start.elapsed();
println!(
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
tick_elapsed,
tick_iterations,
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
);
}
fn crate_sound_source(i: u32) -> usize {
match i % 6 {
0 => 0, // master
1 => 1, // music
2 => 2, // records
3 => 3, // weather
4 => 4, // blocks
_ => 5, // hostile
}
}
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
+167 -53
View File
@@ -478,6 +478,7 @@ pub struct Audio {
pub record_heard: bool,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub listener_pitch: f32,
pub master: f32,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.gain = volume;
c.pitch = pitch;
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
}
}
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
if let Ok(m) = self.mixer.lock() {
for &(h, c) in live {
if m.channels
.get(&c)
.is_none_or(|ch| ch.state == State::Stopped)
{
stopped.push(h);
}
}
} else {
stopped.extend(live.iter().map(|&(h, _)| h));
}
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
stereo_gains_with_length(right, up, forward, length)
}
#[inline]
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
if length <= f32::EPSILON {
return stereo_gains(0.0, 0.0, 1.0);
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
}
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
let (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let (yaw_sin, yaw_cos) = yaw.sin_cos();
let (pitch_sin, pitch_cos) = pitch.sin_cos();
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mixer.channels {
for (id, c) in &mut mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let camera = if !shared {
let cam = &mixer.cameras[0];
if c.relative || cam.2 == c.world {
Some((cam.0, cam.1))
} else {
None
}
} else {
mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
.map(|camera| (camera.0, camera.1))
};
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
distance_gain(c.linear, distance)
let d_sqr = length_sq(d);
let (gain, [left, right]) = if camera.is_none() {
(0.0, [0.0, 0.0])
} else if direct {
let gain = c.gain.clamp(0.0, 1.0);
(gain, [gain, gain])
} else {
let mut zero = false;
if let Some(max) = c.linear {
let max_d = max as f64;
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
zero = true;
}
} else {
0.0
};
let [left, right] = if direct {
[gain, gain]
} else {
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
if zero {
(0.0, [0.0, 0.0])
} else {
let distance = d_sqr.sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let [mut left, mut right] = if c.relative {
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
} else {
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
};
if shared {
let middle = (left + right) * 0.5;
left = middle + (left - middle) * 0.5;
right = middle + (right - middle) * 0.5;
}
(gain, [left * gain, right * gain])
}
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
let inaudible = if channels >= 2 {
left == 0.0 && right == 0.0
} else {
gain == 0.0
};
if inaudible {
let buffer_frames = data.len() / channels;
// As the loop below: a sound of one frame or none stops, looping or not.
if !c.looping || frames <= 1 {
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
// reaches the end, as in the loop below.
let needed = frames as f64 - 1.0 - c.position;
if needed <= 0.0 {
c.state = State::Stopped;
} else if step > 0.0 {
let steps_to_stop = needed / step;
if (buffer_frames as f64) >= steps_to_stop {
c.state = State::Stopped;
} else {
c.position += step * (buffer_frames as f64);
}
}
} else {
for _ in 0..buffer_frames {
let i = c.position as usize;
if i + 1 >= frames {
c.position = 0.0;
}
c.position += step;
}
}
continue;
}
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
let t = (c.position - i as f64) as f32;
if source_channels > 1 {
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1412,6 +1482,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: 1.0,
random: crate::random_source::create(),
source_override: None,
@@ -1535,8 +1606,12 @@ impl Audio {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
return;
}
self.listener = position;
self.listener_yaw = yaw;
self.listener_pitch = pitch;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
@@ -1576,6 +1651,7 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: self.master,
random: crate::random_source::create(),
source_override: None,
@@ -2109,6 +2185,25 @@ impl Audio {
})
.collect()
}
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
if let Ok(mut m) = self.backend.mixer.lock() {
mix(&mut m, data, channels, rate);
}
}
#[doc(hidden)]
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
if let Ok(mut c) = self.backend.cache.lock() {
c.insert(
key.to_owned(),
Some((std::sync::Arc::new(samples), rate, channels)),
);
}
}
#[doc(hidden)]
pub fn backend_live_for_bench(&mut self) {
self.backend.live = true;
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
@@ -2613,6 +2708,25 @@ mod tests {
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
/// A sound of one frame or none, heard or not, looping or not, stops at its first
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
#[test]
fn sounds_too_short_to_mix_stop_even_unheard() {
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
let mut mixer = Mixer::default();
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
source.gain = 0.0;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(
mixer.channels[&1].state,
State::Stopped,
"{samples:?} {looping}"
);
}
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
@@ -57,6 +57,8 @@ pub enum Item {
/// Wait for a button to bind to this action.
Bind(&'static str),
ResetBinds,
/// Opens the radial menu's pages (`radial_editor`).
Radial,
Info(Info),
}
@@ -106,6 +108,15 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
}
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
},
Opt::PadRadialMode => match pad.radial_mode {
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
},
Opt::PadRadialStick => match pad.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
},
_ => return None,
})
}
@@ -123,6 +134,11 @@ fn info(info: Info, text: &'static str) -> Setting {
}
}
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
const RADIAL_GROUP: &str = "Radial Menu";
/// The row that opens the radial menu's pages.
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
/// Shown on a binding's row until it is focused, and in the search.
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
@@ -201,6 +217,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
RADIAL_GROUP,
vec![
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
],
),
(
"Menus",
vec![
@@ -231,6 +254,13 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
(
Page::Bindings,
vec![
(
RADIAL_GROUP,
vec![Setting {
item: Item::Radial,
text: "Named pages of eight actions, held open with a button.",
}],
),
("Actions", binds),
(
"Reset",
@@ -437,6 +467,7 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
Row::new(item, name, value).about(about)
}
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
Item::Info(which) => {
let (name, value) = context.read(which);
Row::new(item, lang::get(name), Value::Read(value)).about(about)
@@ -496,6 +527,7 @@ impl Screen for ControllerScreen {
s.controller.reset_bindings();
vec![Output::Event(Event::Changed)]
}
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
Item::Bind(_) | Item::Info(_) => Vec::new(),
}
}
@@ -533,15 +565,21 @@ impl Screen for ControllerScreen {
fn undoes(&self) -> bool {
true
}
/// This player's controller settings, bindings included, as the screen found them.
/// This player's controller settings, bindings included, as the screen found them. The
/// radial menu's pages stay as they are: their own screen has its own Undo.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
let radial = std::mem::take(&mut s.controller.radial);
s.controller = before.controller.clone();
s.controller.radial = radial;
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
/// and nor do the radial menu's pages, which this Undo keeps.
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
Some(s.controller != before.controller)
let mut now = s.controller.clone();
now.radial = before.controller.radial.clone();
Some(now != before.controller)
}
}
@@ -793,6 +831,48 @@ mod tests {
assert_eq!(arts(&batch, row), 0);
}
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
/// and another change's Undo keeps them as edited.
#[test]
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
let mut s = Settings::default();
let mut ui = ui(Page::General);
draw(&mut ui, &s, None);
s.controller.radial.remove(1);
draw(&mut ui, &s, None);
assert!(
!ui.changed(),
"Undo here keeps the pages: it has nothing to do"
);
s.controller.invert_y = !s.controller.invert_y;
draw(&mut ui, &s, None);
assert!(ui.changed());
let reached = (0..100).any(|_| {
key(&mut ui, &mut s, K::Tab);
ui.focused() == Some(Target::Undo)
});
assert!(reached);
key(&mut ui, &mut s, K::Enter);
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
}
/// The Bindings page's radial row opens the radial menu's pages.
#[test]
fn the_radial_row_opens_the_radial_pages() {
let mut s = Settings::default();
let mut ui = ui(Page::Bindings);
let at = ui.point_at(&s, SIZE, Item::Radial);
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
assert_eq!(
outputs,
[
CLICK,
Output::Push(crate::options_ui::Page::ControllerRadial)
]
);
}
#[test]
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
let s = Settings::default();
@@ -816,8 +896,8 @@ mod tests {
let mut s = Settings::default();
s.controller.bind(JUMP, Some(Button::North));
let mut ui = ui(Page::Bindings);
// Search, the three pages, then the first action.
for _ in 0..Page::ALL.len() + 2 {
// Search, the three pages, the radial menu's row, then the first action.
for _ in 0..Page::ALL.len() + 3 {
key(&mut ui, &mut s, K::Tab);
}
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
@@ -939,6 +1019,8 @@ mod tests {
(Opt::PadNintendoButtons, 2),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
(Opt::PadRadialMode, 2),
(Opt::PadRadialStick, 3),
] {
let setting = option(opt, "the fixed sentence");
let mut seen = Vec::new();
@@ -972,6 +1054,8 @@ mod tests {
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadLightbar,
Opt::PadRadialMode,
Opt::PadRadialStick,
] {
for _ in 0..4 {
lang::get(choice_description(opt, &settings.controller).unwrap());
@@ -990,6 +1074,7 @@ mod tests {
}
ControllerScreen::default().side();
lang::get(TITLE);
lang::get(RADIAL_PAGES);
for text in [
"Press the button for this. %s cancels.",
"This button also does: %s",
@@ -569,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
+6 -23
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
//! (No AI, Agent Control) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,8 +145,6 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -233,7 +231,6 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -338,7 +335,6 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -376,7 +372,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -418,7 +414,6 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -613,8 +608,6 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -691,7 +684,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
@@ -724,7 +716,6 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
@@ -768,7 +759,6 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
@@ -792,10 +782,9 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
return None;
}
})
@@ -844,9 +833,6 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -927,7 +913,6 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
@@ -986,7 +971,6 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
@@ -1104,7 +1088,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert!(!d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1143,7 +1127,6 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+53
View File
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: crate::pack_palette::FluidFogs,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
}
}
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
let fluid = match input.fog_type {
FogType::Lava => input.fluid_fog.lava,
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
input.fluid_fog.water
}
_ => None,
};
if let Some(f) = fluid {
if let Some(rgb) = f.color {
let [r, g, b] = self::rgb(rgb);
fog.color = [r, g, b, 1.0];
}
let end = fog.environmental_end * f.radius;
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
(fog.environmental_start, fog.environmental_end) = (start, end);
(fog.sky_end, fog.cloud_end) = (end, end);
}
let span = (rd / 10.0).clamp(4.0, 64.0);
fog.render_distance_start = rd - span;
fog.render_distance_end = rd;
@@ -495,6 +515,7 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -611,6 +632,38 @@ mod tests {
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
#[test]
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Lava;
input.fire_resistance = true;
let vanilla = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(
(vanilla.environmental_start, vanilla.environmental_end),
(0.0, 5.0)
);
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
color: Some(0x00FF00),
radius: 2.0,
fade: 0.5,
});
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
// The end doubles; half the fade from 0 to 10 starts it at 5.
assert_eq!(
(fog.environmental_start, fog.environmental_end),
(5.0, 10.0)
);
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
+40 -107
View File
@@ -201,6 +201,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -236,6 +237,7 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -1084,6 +1086,9 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1578,6 +1583,9 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -5077,6 +5085,7 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5348,6 +5357,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -7041,8 +7052,10 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
pub fn output(&self) -> output::EffectiveOutput {
let native = self.native.as_ref().map(|n| n.frame.api);
self.output
.with_pack(self.shader_pack_kind() == Some("iris"), native)
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7975,6 +7988,8 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8319,9 +8334,7 @@ impl Renderer {
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
let hdr_lighting = self.shader_pack_kind().is_none();
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12044,24 +12057,18 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12664,96 +12671,22 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
let error = format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
)));
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// 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(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
+7 -5
View File
@@ -191,7 +191,7 @@ pub struct Hdr {
pub masks: Masks,
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
/// and with HDR Lighting (`look`) off.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -1001,12 +1001,14 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
/// given: an empty mask.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let (w, h, rgba) = horizon_client_input::window::icon_png()
.and_then(|png| crate::hud::decode_bytes(png).ok())
.unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
pub(crate) ui_brightness: f32,
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
/// Lighting has nothing to switch (`with_pack`).
pub(crate) pack_lights: bool,
}
impl Default for EffectiveOutput {
fn default() -> Self {
@@ -166,7 +169,22 @@ impl EffectiveOutput {
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
pack_lights: false,
};
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
/// `Renderer::render`); under the rest Horizon lifts nothing.
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
let pack_lights = shaders
|| native.is_some_and(|api| {
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
});
Self {
pack_lights,
..self
}
}
/// Whether the destination offers HDR output.
pub fn hdr_offered(&self) -> bool {
self.offered.is_some()
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -0,0 +1,321 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -2,16 +2,21 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
//! Its colours come from `menu_style`, given at startup (in the official game the design
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
//! the one main button, in vanilla's font.
use crate::menu_style::{self, SettingsColours};
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -36,54 +41,91 @@ impl Cost {
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
Cost::None => b().ink_soft,
Cost::Low | Cost::Saves => b().success,
Cost::Medium => b().warn,
Cost::High => b().danger,
}
}
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
use menu_style::hud_colour as hex;
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
fn glass() -> u32 {
hex(b().page, 118)
}
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
pub fn edge() -> u32 {
hex(b().ink, 26)
}
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
fn group_fill() -> u32 {
hex(b().ink, 11)
}
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
pub fn hover() -> u32 {
hex(b().ink, 18)
}
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
pub fn open() -> u32 {
hex(b().ink, 26)
}
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
fn well_fill() -> u32 {
hex(b().outline, 102)
}
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
fn button_fill() -> u32 {
hex(b().ink, 20)
}
fn button_hover() -> u32 {
hex(b().ink, 41)
}
fn track_fill() -> u32 {
hex(b().ink, 64)
}
fn handle_fill() -> u32 {
hex(b().ink, 255)
}
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
pub fn muted() -> u32 {
hex(b().muted, 255)
}
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
pub fn veil() -> u32 {
hex(b().page, 77)
}
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
fill(batch, atlas, rect, glass());
ring(batch, atlas, rect, edge());
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
fill(batch, atlas, rect, group_fill());
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
fill(batch, atlas, track, row_rule());
fill(batch, atlas, thumb, track_fill());
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
fill(batch, atlas, rect, well_fill());
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
(true, true) => accent(),
(true, false) => muted(),
(false, _) => box_edge(),
};
ring(batch, atlas, rect, edge);
if on {
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
@@ -198,17 +240,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
let ty = y + ((h - 8.0) / 2.0).floor();
let left = x + 2.0 + room;
scrolling_label(
@@ -252,7 +294,7 @@ pub fn slider(
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
let color = if live { WHITE } else { muted() };
scrolling_label(
batch,
atlas,
@@ -263,17 +305,22 @@ pub fn slider(
);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(batch, atlas, [x, line, w, 2.0], track_fill());
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
if live { accent() } else { muted() },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
fill(
batch,
atlas,
handle,
if live { handle_fill() } else { muted() },
);
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
@@ -290,13 +337,13 @@ pub fn cycler(
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
let arrows = if hover && live { WHITE } else { muted() };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(false, _) => muted(),
(true, true) => WHITE,
(true, false) => SOFT,
(true, false) => soft(),
};
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
}
@@ -1110,7 +1110,8 @@ impl Item {
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1141,6 +1142,9 @@ impl Item {
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::HdrLighting) if out.pack_lights => {
Some("The shader pack lights the world itself.")
}
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
use horizon_shader_api::Api;
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrLighting);
let lights = Some("The shader pack lights the world itself.");
let out = offering(true, None);
// No pack running, as with one picked but refused, missing or its mod off: live.
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
None
);
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
lights
);
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
let float = EffectiveOutput {
frame: crate::gfx::FrameConfiguration {
format: wgpu::TextureFormat::Rgba16Float,
..out.frame
},
..out
};
assert_eq!(
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
lights
);
// HDR's own reasons come first.
assert_eq!(
item.blocked(&s, &NONE.with_pack(true, None)),
Some("Your display or system doesn't offer HDR output.")
);
s.horizon.hdr = HdrMode::Off;
assert_eq!(
item.blocked(&s, &out.with_pack(true, None)),
Some("Turn HDR on to use it.")
);
}
#[test]
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let mut s = Settings::default();
@@ -3,8 +3,9 @@
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
//! no motion ever decides what input does.
//!
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
//! of them, at the theme's duration or a shorter one of its own.
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
//! own.
//!
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
@@ -16,7 +17,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! // Half way through: partly faded in, partly grown from 94%.
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
//! let alpha = motion::fade(open, clock, opened);
@@ -26,7 +27,6 @@
//! let still = Clock::new(opened, true);
//! assert_eq!(motion::fade(open, still, opened), 1.0);
//! ```
use horizon_theme::Motion;
use std::time::{Duration, Instant};
/// The time a frame shows, built once a frame by the game.
@@ -50,28 +50,20 @@ impl Clock {
}
}
/// A duration and one of the design system's easing curves.
/// A duration and an easing curve.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
/// The CSS `cubic-bezier` control points of its curve.
pub curve: [f32; 4],
}
impl Tween {
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
/// the same curves.
pub const fn new(duration: Duration, motion: Motion) -> Self {
Self {
duration,
curve: motion.curve,
}
}
/// A theme motion as it is: its own duration and curve.
pub const fn of(motion: Motion) -> Self {
Self::new(motion.duration(), motion)
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
/// curves.
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
Self { duration, curve }
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -84,7 +76,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
horizon_theme::bezier(self.curve, self.linear(clock, since))
bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -98,6 +90,29 @@ impl Tween {
}
}
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
/// returns y.
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
if t == 0.0 || t == 1.0 {
return t;
}
let at = |a: f32, b: f32, s: f32| {
let u = 1.0 - s;
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
};
let (mut lo, mut hi) = (0f32, 1f32);
for _ in 0..24 {
let mid = (lo + hi) / 2.0;
if at(x1, x2, mid) < t {
lo = mid;
} else {
hi = mid;
}
}
at(y1, y2, (lo + hi) / 2.0)
}
/// An alpha from 0 to 1 as something fades in.
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
tween.progress(clock, since)
@@ -145,7 +160,7 @@ mod tests {
#[test]
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
@@ -160,8 +175,7 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
assert_eq!(fade(tween, still, t0), 1.0);
assert_eq!(fade_out(tween, still, t0), 0.0);
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
@@ -173,7 +187,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -184,10 +198,25 @@ mod tests {
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
}
#[test]
fn the_curve_is_the_design_systems() {
for motion in horizon_theme::MOTION {
for i in 0..=20 {
let t = i as f32 / 20.0;
assert_eq!(
bezier(motion.curve, t),
motion.ease(t),
"{} at {t}",
motion.name
);
}
}
}
#[test]
fn a_turn_takes_the_short_way_round() {
let t0 = Instant::now();
let tween = Tween::new(MS(80), horizon_theme::MOVE);
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
let from = 7.0 * FRAC_PI_4;
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
@@ -1,6 +1,7 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
//! the hotbar at the bottom centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
@@ -34,6 +35,16 @@ pub const GAP: f32 = 2.0;
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
/// bottom.
StatusRows,
}
impl Keep {
/// It sits at the top of the view, not the bottom.
fn at_top(self) -> bool {
matches!(self, Self::BossBars)
}
}
/// A view and the keep-outs its HUD shows this frame.
@@ -90,10 +101,20 @@ impl Room {
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.filter(|(keep, _)| keep.at_top())
.map(|(_, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
/// rows' top, or the view's height with none.
pub fn bottom(&self) -> f32 {
self.keeps()
.filter(|(keep, _)| !keep.at_top())
.map(|(_, [_, y, _, _])| y)
.fold(self.height, f32::min)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
@@ -134,4 +155,15 @@ mod tests {
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
#[test]
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
let room = Room::new(427.0, 240.0);
assert_eq!(room.bottom(), 240.0);
// Hearts and armour from y 191 down.
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
assert_eq!(rows.bottom(), 191.0);
assert_eq!(rows.top(), 0.0);
assert_eq!(rows.below_top(18.0), 18.0);
}
}
@@ -38,6 +38,12 @@
//! ```
use crate::hud::{HudAtlas, HudBatch};
use crate::hud_kit::{colour, fit};
use crate::menu_style::{self, OverlayColours};
/// The overlays' colours (`menu_style`, given at startup).
fn overlay() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// A pip under the strip: its size and the step from one to the next, in GUI px.
pub const PIP: [f32; 2] = [3.0, 2.0];
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
/// How far, in GUI px, the arrows sit from the name.
pub const ARROW_GAP: f32 = 4.0;
/// An arrow with a page that way, and one at an end, with none.
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
pub fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
/// The pips (the selected one is white).
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
/// The selected tab's fill and text, and the others'.
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub fn selected_text() -> u32 {
colour(overlay().page, 255)
}
pub fn tab_fill() -> u32 {
colour(overlay().page, 191)
}
pub fn tab_text() -> u32 {
colour(overlay().backdrop_muted, 255)
}
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on { ARROW } else { ARROW_OFF },
if on {
arrow_colour()
} else {
arrow_off_colour()
},
true,
);
}
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
for tab in &row.tabs {
let [tx, ty, tw, th] = tab.area;
let (fill, text) = if tab.selected {
(SELECTED_FILL, SELECTED_TEXT)
(SELECTED_FILL, selected_text())
} else {
(TAB_FILL, TAB_TEXT)
(tab_fill(), tab_text())
};
let tab_start = batch.vertices.len();
// Cut where the edges' fades start, so each fade runs over its own piece.
@@ -380,15 +400,16 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
})
.map(|v| v.color >> 24)
.max()
.unwrap_or(0)
};
let first = collapsed(&atlas, &pages, 0, AREA);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
}
#[test]
@@ -131,6 +131,23 @@ pub enum GuiDraw {
pub const WHITE: u32 = 0xFFFF_FFFF;
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
/// of the HUD begins.
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
draws
.iter()
.filter_map(|d| match *d {
GuiDraw::Sprite { y, .. }
| GuiDraw::AtlasSprite { y, .. }
| GuiDraw::Blit { y, .. }
| GuiDraw::Text { y, .. }
| GuiDraw::Item { y, .. } => Some(y),
GuiDraw::Fill { y0, .. } => Some(y0),
GuiDraw::Push(_) | GuiDraw::Pop => None,
})
.min()
}
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
GuiDraw::Sprite {
sprite: Cow::Borrowed(sprite),
@@ -583,6 +583,9 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
/// span the same.
pub const HOTBAR_HALF_WIDTH: i32 = 91;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
+4
View File
@@ -74,6 +74,7 @@ pub mod light;
pub mod lod;
pub mod logging;
pub mod menu_blur;
pub mod menu_style;
pub mod mesher;
pub mod minimap;
pub mod mipmap;
@@ -109,6 +110,8 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod radial_editor;
pub mod radial_overlay;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
@@ -169,6 +172,7 @@ pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::radial;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
@@ -0,0 +1,150 @@
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
//! read it.
//!
//! The layer above gives it once at startup ([`install`]), before the first frame; the
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
//! use their own fonts. A read is one atomic load, and only those screens make it.
use std::sync::OnceLock;
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MenuStyle {
pub settings: SettingsColours,
pub overlay: OverlayColours,
pub sizes: Sizes,
/// The overlays' TrueType faces; none: they draw no text.
pub fonts: Option<Fonts>,
}
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SettingsColours {
/// The plate behind everything, drawn see-through.
pub page: u32,
/// Text, and the light the panels, rules, hovers and controls are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// Labels, descriptions, what can't be used.
pub muted: u32,
/// Wells and the dark edge of small marks.
pub outline: u32,
/// Focus, a checked box, a slider's fill.
pub accent: u32,
/// The one main button, and while hovered.
pub face: u32,
pub face_hover: u32,
pub success: u32,
pub warn: u32,
pub danger: u32,
}
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct OverlayColours {
/// The dark page under text and tabs.
pub page: u32,
/// Muted text over a veiled backdrop.
pub backdrop_muted: u32,
/// Text and the light hover and pressed plates are drawn in.
pub ink: u32,
/// Secondary text and values.
pub ink_soft: u32,
/// One-pixel rules between rows.
pub rule: u32,
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
/// A lit choice and its bright edge (the controller dial's sector).
pub lit: u32,
pub lit_edge: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sizes {
pub caption: f32,
pub label: f32,
pub ui: f32,
pub body: f32,
pub title: f32,
/// The boot screen's wordmark at its smallest, and its console lines.
pub display: f32,
pub micro: f32,
/// Fields; plates (hover rows, selection); a large control's height.
pub radius: f32,
pub radius_plate: f32,
pub control_large: f32,
}
/// TrueType faces (`ui_theme::Face`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Fonts {
pub regular: &'static [u8],
pub medium: &'static [u8],
pub semibold: &'static [u8],
pub mono: &'static [u8],
}
/// The look with nothing given: vanilla's greys and chat colours.
pub const NEUTRAL: MenuStyle = MenuStyle {
settings: SettingsColours {
page: 0x101010,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
muted: 0xa0a0a0,
outline: 0x000000,
accent: 0xffffff,
face: 0x555555,
face_hover: 0x6f6f6f,
success: 0x55ff55,
warn: 0xffff55,
danger: 0xff5555,
},
overlay: OverlayColours {
page: 0x101010,
backdrop_muted: 0xa0a0a0,
ink: 0xffffff,
ink_soft: 0xe0e0e0,
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
lit: 0xffffff,
lit_edge: 0xffffff,
},
sizes: Sizes {
caption: 12.0,
label: 13.0,
ui: 14.0,
body: 15.0,
title: 28.0,
display: 44.0,
micro: 11.0,
radius: 0.0,
radius_plate: 0.0,
control_large: 40.0,
},
fonts: None,
};
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
/// Give the menus their look: call it once at startup. A second call keeps the first look and
/// returns false.
pub fn install(style: MenuStyle) -> bool {
STYLE.set(style).is_ok()
}
/// The look given to [`install`], or [`NEUTRAL`].
#[inline]
pub fn get() -> &'static MenuStyle {
STYLE.get().unwrap_or(&NEUTRAL)
}
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
}
+35 -79
View File
@@ -18,6 +18,8 @@ use crate::{
ui,
};
mod shell_pages;
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
@@ -322,6 +324,8 @@ impl Opt {
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::PadRadialMode => "horizon.options.radial_mode",
Opt::PadRadialStick => "horizon.options.radial_stick",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -880,6 +884,7 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
@@ -1201,6 +1206,7 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.keyboard = all[usize::from(next(i, 3))];
}
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
_ => {}
}
if self.affects_preset() {
@@ -1352,6 +1358,11 @@ pub enum Page {
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
ControllerRadial,
ControllerRadialName,
/// The list of every action for one of its slots.
ControllerRadialPick,
Extras,
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
Pvp,
@@ -1683,6 +1694,10 @@ pub struct OptionsUi {
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
/// The radial menu's pages, on the settings shell.
radial: shell::Shell<crate::radial_editor::RadialScreen>,
/// The list of actions for one of the radial menu's slots.
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
@@ -1767,6 +1782,8 @@ impl Default for OptionsUi {
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
radial: Default::default(),
radial_pick: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
@@ -2112,6 +2129,7 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::ControllerRadialName => self.radial_name_typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2244,95 +2262,16 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
}
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
pub fn video_open(&self) -> bool {
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
@@ -2369,6 +2308,9 @@ impl OptionsUi {
"options.videoTitle"
}
Page::Controller | Page::PadBinds => "horizon.pages.controller",
Page::ControllerRadial => "horizon.pages.controller_radial",
Page::ControllerRadialName => "horizon.radial.name_title",
Page::ControllerRadialPick => "horizon.radial.pick_title",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
@@ -3602,6 +3544,9 @@ impl OptionsUi {
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
return;
}
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
// The keys lay the screen out at the size it was drawn at.
@@ -3953,6 +3898,9 @@ impl OptionsUi {
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.page() == Page::ControllerRadialName {
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
}
if self.shell().is_some() {
// Waiting on a controller button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -4648,6 +4596,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if self.page() == Page::ControllerRadialName {
self.type_radial_name(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
@@ -4683,6 +4633,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
@@ -4786,6 +4739,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
return Vec::new();
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_key(s, key, text);
}
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
@@ -0,0 +1,302 @@
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
use super::{Event, OptionsUi, Page, tr};
use crate::{
gamepad,
hud::{HudAtlas, HudBatch},
radial_editor::Named,
settings::{Opt, Settings},
settings_shell as shell,
text::Text,
};
impl OptionsUi {
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
Page::ControllerRadial => Some(&self.radial),
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
Page::ControllerRadial => Some(&mut self.radial),
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
self.shell()?.opened_focus()
}
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
/// settings shell steps, rather than opens.
pub fn pad_clicks_step(&mut self, on: bool) {
if on {
if let Some(shell) = self.shell_mut() {
shell.clicks_step(true);
}
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
];
for shell in shells {
shell.clicks_step(false);
}
}
/// The controller's focus landed there.
pub fn clear_shell_opened_focus(&mut self) {
if let Some(shell) = self.shell_mut() {
shell.clear_opened_focus();
}
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
pub(super) fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
pub(super) fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
pub(super) fn shell_events(
&mut self,
s: &mut Settings,
outputs: Vec<shell::Output>,
) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
// A slot's list, for the slot, opening on what it holds.
if page == Page::ControllerRadialPick
&& let Some(at) = self.radial.screen.picking.take()
{
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
self.radial_pick.focus_soon(row);
}
}
shell::Output::Done => {
// Back from a slot's list, the focus is on the slot.
let slot = (self.page() == Page::ControllerRadialPick)
.then(|| self.radial_pick.screen.at());
events.extend(self.back(s));
if let Some((page, slot)) = slot {
self.radial
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
}
}
}
}
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
if self.radial_pick.screen.take_picked() {
self.radial.changed_elsewhere();
}
// A radial page added or deleted: the one to show now.
if let Some(page) = self.radial.screen.take_shown() {
self.radial.show(page);
}
events
}
/// What a press on a radial page's name does here.
pub(super) fn name_events(
&mut self,
named: crate::radial_editor::Named,
s: &mut Settings,
) -> Vec<Event> {
use crate::radial_editor::Named;
let mut events = vec![Event::ButtonClick];
match named {
Named::Nothing => return Vec::new(),
Named::Done => {
let entry = self.radial.screen.naming.take();
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
self.radial.changed_elsewhere();
}
events.push(Event::Changed);
events.extend(self.back(s));
}
Named::Back => events.extend(self.back(s)),
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
/// in place of a shell: whether it was.
pub(super) fn draw_radial_name(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) -> bool {
self.radial.screen.glyphs = self.pad_glyphs;
if self.page() == Page::ControllerRadialName
&& let Some(entry) = &mut self.radial.screen.naming
{
entry.draw(batch, atlas, cursor, in_world);
return true;
}
false
}
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
pub(super) fn click_radial_name(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
at: (f32, f32),
size: (f32, f32),
ignored: bool,
) -> Vec<Event> {
let entry = self.radial.screen.naming.as_mut();
let named = match entry.filter(|_| !ignored) {
Some(entry) => entry.click(atlas, at, size),
None => Named::Nothing,
};
self.name_events(named, s)
}
/// Whether a radial page's name is being typed.
pub(super) fn radial_name_typing(&self) -> bool {
self.radial
.screen
.naming
.as_ref()
.is_some_and(|n| n.typing())
}
/// Text typed into a radial page's name.
pub(super) fn type_radial_name(&mut self, text: &str) {
if let Some(entry) = &mut self.radial.screen.naming {
entry.type_text(text);
}
}
/// An edit shortcut in a radial page's name: whether it did something.
pub(super) fn radial_name_shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
let entry = self.radial.screen.naming.as_mut();
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
}
/// A key on a radial page's name entry.
pub(super) fn radial_name_key(
&mut self,
s: &mut Settings,
key: winit::keyboard::KeyCode,
text: Option<&str>,
) -> Vec<Event> {
let named = match &mut self.radial.screen.naming {
Some(entry) => entry.key(key, text),
// Nothing to name: Escape still leaves.
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
None => Named::Nothing,
};
self.name_events(named, s)
}
}
impl Opt {
/// The radial menu's rows of Controller Settings: their value.
pub(super) fn radial_text(self, s: &Settings) -> Text {
match self {
Opt::PadRadialMode => tr(match s.controller.radial_mode {
gamepad::RadialMode::Hold => "options.key.hold",
gamepad::RadialMode::Toggle => "options.key.toggle",
}),
Opt::PadRadialStick => tr(match s.controller.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
}),
_ => Text::empty(),
}
}
/// The radial menu's rows of Controller Settings: a step, back or on.
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
let next = |n: u8, count: u8| {
if back {
(n + count - 1) % count
} else {
(n + 1) % count
}
};
match self {
Opt::PadRadialMode => {
let all = gamepad::RadialMode::ALL;
let i = all
.iter()
.position(|m| *m == s.controller.radial_mode)
.unwrap_or(0) as u8;
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
}
Opt::PadRadialStick => {
let all = gamepad::RadialStick::ALL;
let i = all
.iter()
.position(|k| *k == s.controller.radial_stick)
.unwrap_or(0) as u8;
// An opener under the thumb that now selects goes unbound.
s.controller
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
}
_ => {}
}
}
}
@@ -124,21 +124,15 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
self.added_entries(&mut rows);
rows
}
@@ -171,7 +165,7 @@ impl OptionsUi {
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
pack_palette::FluidFog {
color: fog.color,
radius: fog.radius,
fade: fog.fade,
}
}
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
fluid_fog: pack_palette::FluidFogs {
water: colors.fluid_fog.water.map(fluid),
lava: colors.fluid_fog.lava.map(fluid),
},
}
}
@@ -59,6 +59,24 @@ pub struct PackColors {
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
/// The fog the packs' fluid modifiers give water and lava.
pub fluid_fog: FluidFogs,
}
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
/// vanilla's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidFog {
pub color: Option<u32>,
pub radius: f32,
pub fade: f32,
}
/// The fog the packs' fluid modifiers give water and lava.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl Default for PackColors {
@@ -72,6 +90,7 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
@@ -801,6 +801,26 @@ impl Host for Ctx<'_, '_> {
_ => Err(format!("{name} is not available here")),
}
}
/// A particle's own fields, as `particle_method` answers them.
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
use horizon_polytone::expr::Field as F;
let Subject::Particle(p) = &self.subject else {
return None;
};
if root != Root::Object {
return None;
}
Some(V::Num(match field {
F::Age => f64::from(p.age),
F::Life => f64::from(p.lifetime),
F::Custom => *self.custom,
F::X => p.position[0],
F::Y => p.position[1],
F::Z => p.position[2],
F::Temperature | F::Downfall => return None,
}))
}
}
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
+152 -32
View File
@@ -7,7 +7,10 @@
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
//! a weak reference tells an edited or replaced section from the one the list
//! was built from (see `random_tick_sections`), without keeping its blocks.
use crate::{chunk::Section, fast_hash::FxHashMap};
use crate::{
chunk::{Chunk, Section},
fast_hash::FxHashMap,
};
use std::sync::{Arc, Mutex, Weak};
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
@@ -18,13 +21,16 @@ struct Cached<T> {
section: Weak<Section>,
pois: SectionPois<T>,
}
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
/// index: a search through a column looks it up once.
pub struct PoiSections<T> {
sections: Mutex<Lists<T>>,
}
struct Lists<T> {
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
/// Size above which lists of sections no longer resident are dropped.
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
/// How many section lists are kept, and the count above which the lists
/// of sections no longer resident are dropped.
len: usize,
prune_at: usize,
}
const MIN_PRUNE: usize = 4096;
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
fn default() -> Self {
Self {
sections: Mutex::new(Lists {
by_section: FxHashMap::default(),
by_column: FxHashMap::default(),
len: 0,
prune_at: MIN_PRUNE,
}),
}
}
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, whose block states `kind` classifies. `kind`
/// must give the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cached) = lists.by_section.get(&key)
&& cached.section.as_ptr() == Arc::as_ptr(section)
{
return cached.pois.clone();
impl<T: Copy> Lists<T> {
/// The column's lists, for sections up to `last`, after dropping the
/// lists of sections no longer resident if there are too many.
fn column(
&mut self,
column: (i32, i32),
last: usize,
) -> (&mut [Option<Cached<T>>], &mut usize) {
if self.len >= self.prune_at {
self.prune();
}
let lists = self.by_column.entry(column).or_default();
if lists.len() <= last {
lists.resize_with(last + 1, || None);
}
(lists, &mut self.len)
}
/// Drops the lists of sections no longer resident.
fn prune(&mut self) {
let mut len = 0;
self.by_column.retain(|_, lists| {
for c in lists.iter_mut() {
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
*c = None;
}
}
let kept = lists.iter().filter(|c| c.is_some()).count();
len += kept;
kept > 0
});
self.len = len;
self.prune_at = (len * 2).max(MIN_PRUNE);
}
}
/// The POIs of `section` from `slot`, listed again (and kept there) unless
/// `slot` was listed from this very section; `len` counts the slots filled.
fn list<'a, T: Copy>(
slot: &'a mut Option<Cached<T>>,
len: &mut usize,
section: &Arc<Section>,
kind: &mut impl FnMut(u32) -> Option<T>,
) -> &'a SectionPois<T> {
let fresh = slot
.as_ref()
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
if !fresh {
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
let mut states = [0u32; 4096];
section.unpack(&mut states);
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
} else {
Arc::from([])
};
if lists.by_section.len() >= lists.prune_at {
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
*len += usize::from(slot.is_none());
*slot = Some(Cached {
section: Arc::downgrade(section),
pois,
});
}
&slot.as_ref().expect("just listed").pois
}
impl<T: Copy> PoiSections<T> {
/// The POIs of `section`, the `key.1`-th section of the column
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
/// the same answer for a state on every call.
pub fn get(
&self,
key: (i32, i32, i32),
section: &Arc<Section>,
mut kind: impl FnMut(u32) -> Option<T>,
) -> SectionPois<T> {
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let s = usize::try_from(key.1).expect("a section index");
let (column, len) = lists.column((key.0, key.2), s);
list(&mut column[s], len, section, &mut kind).clone()
}
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
/// column.
pub fn column(
&self,
chunk: &Chunk,
min_y: i32,
(y0, y1): (i32, i32),
mut kind: impl FnMut(u32) -> Option<T>,
out: &mut Vec<(i32, SectionPois<T>)>,
) {
out.clear();
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
if first > last {
return;
}
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
for s in first as usize..=last as usize {
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
if !pois.is_empty() {
out.push((min_y + s as i32 * 16, pois.clone()));
}
}
lists.by_section.insert(
key,
Cached {
section: Arc::downgrade(section),
pois: pois.clone(),
},
);
pois
}
}
@@ -143,6 +221,48 @@ mod tests {
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
}
/// A column gives each section's list as `get` does, the sections
/// without any left out, and keeps them until a section changes.
#[test]
fn a_column_gives_what_each_section_gives() {
let index = PoiSections::<u8>::default();
let kind = |v: u32| (v == 5).then_some(5);
let mut chunk = Arc::new(Chunk::empty(2, -3));
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
Arc::make_mut(&mut chunk).set(x, y, z, 5);
}
let mut out = Vec::new();
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
for (base, pois) in &out {
let s = (base - MIN_Y) / 16;
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
assert!(Arc::ptr_eq(pois, &one));
}
// Kept: no rescan.
index.column(
&chunk,
MIN_Y,
(MIN_Y, MIN_Y + 63),
|_| panic!("rescanned"),
&mut out,
);
assert_eq!(out.len(), 2);
// An edit lists that section again.
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
let mut rescans = 0;
let counted = |v: u32| {
rescans += 1;
kind(v)
};
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
assert!(rescans > 0);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, MIN_Y + 32);
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
}
#[test]
fn unloaded_sections_are_dropped() {
let index = PoiSections::<u8>::default();
@@ -153,7 +273,7 @@ mod tests {
index.get((1, i, 0), &Arc::new(Section::default()), kind);
}
let lists = index.sections.lock().unwrap();
assert!(lists.by_section.len() <= 2);
assert!(lists.by_section.contains_key(&(0, 0, 0)));
assert!(lists.len <= 2);
assert!(lists.by_column[&(0, 0)][0].is_some());
}
}
@@ -0,0 +1,844 @@
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
//! page's own name is typed on a small page of its own, [`NameEntry`].
use crate::{
gamepad::{self, GlyphOf, MenuJob},
graphics_style::{self as style, BUTTON_H},
hud::{HudAtlas, HudBatch},
lang,
options_ui::{Event, Page},
pad_glyph::{Glyph, Glyphs},
radial::{self, Pages, RadialAction, SLOTS, Slot},
radial_overlay::{page_label, slot_caption},
settings::Settings,
settings_shell::{
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
},
text::Text,
ui,
};
const TITLE: &str = "horizon.pages.controller_radial";
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
const PAGE_GROUP: &str = "horizon.radial.group.page";
const ALL_GROUP: &str = "horizon.radial.group.all";
/// The opener's row binds the menu's own pad action, in place.
const OPENER: &str = gamepad::RADIAL;
/// A row of a page; `usize`s are the page and the slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RadialRow {
Slot(usize, usize),
Rename(usize),
NewPage,
Delete(usize),
Reset,
Opener,
}
/// A slot's action, that it holds none, or that its action is missing.
fn slot_label(slot: &Slot) -> String {
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
}
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
/// A missing action steps as Empty does.
fn step(slot: &Slot, back: bool) -> Slot {
let order: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.collect();
let at = order
.iter()
.position(|entry| *entry == slot.action())
.unwrap_or(0);
let next = if back { at + order.len() - 1 } else { at + 1 };
order[next % order.len()].into()
}
fn page_of(page: usize, pages: usize) -> String {
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
}
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
/// strip says what stands in the way (no button, or one another action shares); while it waits,
/// the shell says how to bind it.
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
let pad = &s.controller;
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
let warning = match pad.binding(OPENER) {
None => Some(lang::get("horizon.radial.unbound")),
Some(_) => pad
.conflicts(OPENER)
.then(|| lang::get("horizon.radial.conflict")),
};
let binding = Binding {
capture: Capture::Pad(OPENER),
shown: Shown::Pad {
glyph,
cancel: Glyph::menu(MenuJob::Start, glyphs),
},
shared: Vec::new(),
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
reset: Reset::None,
};
let name = lang::get("horizon.radial.opener");
Row {
chosen: warning,
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
.about(lang::get(opener_about(pad.radial_stick)))
}
}
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
match stick {
gamepad::RadialStick::Right => "horizon.radial.opener.about",
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
}
}
/// The page's rows: its slots, then what changes it.
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
let slots = (0..SLOTS).map(|slot| {
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
let cell = pages.page(page).slot(slot);
let value = Value::Choice(slot_label(cell));
let missing = match cell {
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
_ => None,
};
Row {
chosen: missing,
..Row::new(RadialRow::Slot(page, slot), name, value)
.about(lang::get("horizon.radial.slot.about"))
}
});
let full = (pages.len() >= radial::MAX_PAGES)
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
let changes = vec![
Row::new(
RadialRow::Rename(page),
lang::get("horizon.radial.rename"),
Value::Press,
)
.about(lang::get("horizon.radial.rename.about")),
Row::new(
RadialRow::NewPage,
lang::get(radial::NEW_PAGE_NAME),
Value::Press,
)
.about(lang::get("horizon.radial.new_page.about"))
.blocked(full),
Row::new(
RadialRow::Delete(page),
lang::get("horizon.radial.delete_page"),
Value::Press,
)
.about(lang::get("horizon.radial.delete_page.about"))
.blocked(last),
];
[
Group {
name: SLOTS_GROUP,
rows: slots.collect(),
},
Group {
name: PAGE_GROUP,
rows: changes,
},
]
}
/// The screen's own state; the pages are the player's settings.
#[derive(Default)]
pub(crate) struct RadialScreen {
/// The buttons' names follow the controller in use: the options screen says which.
pub(crate) glyphs: Glyphs,
/// The page to show next: one just added, or where a deleted one was.
show: Option<usize>,
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
pub(crate) naming: Option<NameEntry>,
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
pub(crate) picking: Option<(usize, usize)>,
}
impl RadialScreen {
/// The page the shell should show now, once.
pub(crate) fn take_shown(&mut self) -> Option<usize> {
self.show.take()
}
}
impl Screen for RadialScreen {
type Page = usize;
type Row = RadialRow;
fn title(&self) -> &'static str {
TITLE
}
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
/// named with.
fn stamp(&self, s: &Settings) -> u64 {
let pad = &s.controller;
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
let pages = &s.controller.radial;
let default = *pages == Pages::default();
let all = Group {
name: ALL_GROUP,
rows: vec![
Row::new(
RadialRow::Reset,
lang::get("horizon.radial.reset"),
Value::Press,
)
.about(lang::get("horizon.radial.reset.about"))
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
opener_row(s, self.glyphs),
],
};
(0..pages.len())
.map(|page| {
let [slots, changes] = page_groups(pages, page);
let all = Group {
name: all.name,
rows: all.rows.clone(),
};
Sheet::new(
page,
page_label(pages.page(page)),
page_of(page, pages.len()),
vec![slots, changes, all],
)
})
.collect()
}
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
let pages = &mut s.controller.radial;
match row {
RadialRow::Slot(page, slot) => {
let next = step(pages.page(page).slot(slot), back);
pages.set_slot(page, slot, next);
}
RadialRow::Rename(page) => {
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
return vec![Output::Push(Page::ControllerRadialName)];
}
RadialRow::NewPage => self.show = pages.add(),
RadialRow::Delete(page) => {
pages.remove(page);
self.show = Some(pages.clamp(page));
}
RadialRow::Reset => {
*pages = Pages::default();
self.show = Some(0);
}
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
RadialRow::Opener => return Vec::new(),
}
vec![Output::Event(Event::Changed)]
}
/// A press on a slot opens the list of every action for it; Left and Right still step it.
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
let RadialRow::Slot(page, slot) = row else {
return None;
};
self.picking = Some((page, slot));
Some(vec![Output::Push(Page::ControllerRadialPick)])
}
/// The wheel over a slot steps it, as a press does.
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
}
/// Undo is how a delete or a reset is taken back: they act at once.
fn undoes(&self) -> bool {
true
}
/// The pages, and the opener's button, which its row binds.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller.radial = before.controller.radial.clone();
s.controller.bind(OPENER, before.controller.binding(OPENER));
Vec::new()
}
}
const PICK_TITLE: &str = "horizon.radial.pick_title";
const PICK_GROUP: &str = "horizon.radial.group.actions";
/// A row of a slot's list: the line naming the slot, then Empty and every action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PickRow {
Slot,
Pick(Option<RadialAction>),
}
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
#[derive(Default)]
pub(crate) struct PickScreen {
/// The page and the slot.
at: (usize, usize),
picked: bool,
}
impl PickScreen {
/// For `at`; the row the focus opens on, what the slot holds.
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
*self = Self { at, picked: false };
self.current(pages).unwrap_or(PickRow::Pick(None))
}
/// The page and the slot it is for.
pub(crate) fn at(&self) -> (usize, usize) {
self.at
}
/// A pick changed the pages since the last call, which the editor's Undo takes back.
pub(crate) fn take_picked(&mut self) -> bool {
std::mem::take(&mut self.picked)
}
/// The row of what the slot holds; none for an action this game doesn't know.
fn current(&self, pages: &Pages) -> Option<PickRow> {
match pages.page(self.at.0).slot(self.at.1) {
Slot::Empty => Some(PickRow::Pick(None)),
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
Slot::Missing(_) => None,
}
}
}
impl Screen for PickScreen {
type Page = ();
type Row = PickRow;
fn title(&self) -> &'static str {
PICK_TITLE
}
fn stamp(&self, s: &Settings) -> u64 {
let (page, slot) = self.at;
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
let pages = &s.controller.radial;
let (page, slot) = self.at;
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
let named = lang::tr(
"horizon.radial.pick.slot",
&[&direction, &page_label(pages.page(page))],
);
let current = self.current(pages);
let about = lang::get("horizon.radial.pick.about");
let mut rows =
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
let id = PickRow::Pick(action);
let value = if current == Some(id) {
Value::Choice(lang::get("horizon.radial.pick.current"))
} else {
Value::Press
};
let name = slot_label(&action.into());
rows.push(Row::new(id, name, value).about(about.clone()));
}
vec![Sheet::new(
(),
named,
about,
vec![Group {
name: PICK_GROUP,
rows,
}],
)]
}
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
let PickRow::Pick(action) = row else {
return Vec::new();
};
let (page, slot) = self.at;
s.controller.radial.set_slot(page, slot, action.into());
self.picked = true;
vec![Output::Event(Event::Changed), Output::Done]
}
}
/// The name field and its buttons, centred in the screen but under the title, one row apart.
const NAME_W: f32 = 200.0;
const NAME_BLOCK_H: f32 = 100.0;
const NAME_TOP_MIN: f32 = 30.0;
const NAME_STEP: f32 = 25.0;
/// What a press on the name's page did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Named {
Nothing,
/// Done: the page takes the name.
Done,
/// Cancel, keeping the old name.
Back,
}
/// A page's name being typed.
pub(crate) struct NameEntry {
page: usize,
field: ui::EditBox,
}
impl NameEntry {
fn new(page: usize, name: &str) -> Self {
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
field.max_length = radial::NAME_MAX;
field.set_value(name);
field.focused = true;
Self { page, field }
}
/// Done and Cancel, under the field.
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
}
pub(crate) fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(lang::get("horizon.radial.name_title"));
style::title(batch, atlas, &title);
let field = &mut self.field;
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
field.draw(batch, atlas, None);
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
let (label, action) = match named {
Named::Done => ("gui.done", crate::widgets::Action::Done),
_ => ("gui.cancel", crate::widgets::Action::Cancel),
};
let hover = ui::hovered(cursor, x, y, bw, bh);
let text = Text::literal(lang::get(label));
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
}
}
/// A press at `(x, y)` on a screen `w` x `h`.
pub(crate) fn click(
&mut self,
atlas: &HudAtlas,
(x, y): (f32, f32),
(w, h): (f32, f32),
) -> Named {
let hit = Self::buttons(w, h)
.into_iter()
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
if let Some((_, named)) = hit {
return named;
}
let field = &mut self.field;
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
if field.focused {
field.click(atlas, x, false, false);
}
Named::Nothing
}
/// The name field takes the keys.
pub(crate) fn typing(&self) -> bool {
self.field.focused
}
pub(crate) fn type_text(&mut self, text: &str) {
if self.field.focused {
self.field.insert_text(text);
}
}
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
use winit::keyboard::KeyCode as K;
match key {
K::Escape => return Named::Back,
K::Enter | K::NumpadEnter => return Named::Done,
_ if self.field.focused => {
if !self.field.key(key, false, false)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
self.field.insert_text(&typed);
}
}
_ => {}
}
Named::Nothing
}
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
pub(crate) fn shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
if self.field.focused {
self.field.shortcut(shortcut, clipboard);
}
self.field.focused
}
/// The name into the pages. Left as it was, a default page keeps following the language.
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
let changed = self.field.value != page_label(pages.page(self.page));
if changed {
pages.rename(self.page, &self.field.value);
}
changed
}
}
/// The name field's top.
fn name_top(h: f32) -> f32 {
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gamepad::Button as PadButton;
use crate::radial::PageName;
use crate::settings_shell::{Hosted, Shell};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
let sheets = RadialScreen::default().sheets(s);
let groups = sheets.into_iter().nth(page).unwrap().groups;
groups.into_iter().flat_map(|group| group.rows).collect()
}
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
let page = match id {
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
_ => 0,
};
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
}
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
let at = shell.point_at(s, SIZE, id);
shell.click(s, at, SIZE, false, false, false)
}
/// Undo, at the foot over Done.
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
}
#[test]
fn a_slot_steps_through_empty_and_every_action_both_ways() {
let mut seen = vec![Slot::Empty];
let mut slot = Slot::Empty;
for _ in RadialAction::ALL {
slot = step(&slot, false);
seen.push(slot.clone());
}
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
assert_eq!(actions, RadialAction::ALL.to_vec());
assert_eq!(
step(&Slot::Empty, true).action(),
RadialAction::ALL.last().copied()
);
let chat = Slot::Action(RadialAction::Chat);
assert_eq!(step(&step(&chat, true), false), chat);
// A missing action steps on as Empty would.
let missing = Slot::Missing("mymod:dash".to_owned());
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
}
#[test]
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.clone();
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
assert_eq!(shell.screen.picking, Some((0, 2)));
assert_eq!(
s.controller.radial, before,
"the press itself changes nothing"
);
// Right on the focused row steps it.
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
let stepped = step(before.page(0).slot(2), false);
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
}
#[test]
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
let mut s = Settings::default();
let mut picker = PickScreen::default();
// General's right slot holds Commands.
let opens_on = picker.open_on((0, 2), &s.controller.radial);
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
let rows: Vec<_> = picker
.sheets(&s)
.into_iter()
.flat_map(|sheet| sheet.groups)
.flat_map(|group| group.rows)
.collect();
assert_eq!(rows[0].id, PickRow::Slot);
assert_eq!(rows[0].value, Value::Note);
// Empty, then every action, each once; only the slot's own marked.
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
let every: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.map(PickRow::Pick)
.collect();
assert_eq!(picks, every);
let marked: Vec<_> = rows
.iter()
.filter(|row| matches!(row.value, Value::Choice(_)))
.map(|row| row.id)
.collect();
assert_eq!(marked, [opens_on]);
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
assert_eq!(
*s.controller.radial.page(0).slot(2),
Slot::Action(RadialAction::Chat)
);
assert!(picker.take_picked() && !picker.take_picked());
// Empty empties it.
picker.press(&mut s, PickRow::Pick(None), false);
assert!(s.controller.radial.page(0).slot(2).is_empty());
}
#[test]
fn a_missing_action_says_which_it_is() {
let mut s = Settings::default();
s.controller
.radial
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
let english = lang::Language::load(&[], "en_us");
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
}
#[test]
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.page(0).clone();
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert!(outputs.contains(&Output::Event(Event::Changed)));
let after = s.controller.radial.page(0);
assert_eq!(*after.slot(0), step(before.slot(0), false));
assert_eq!(after.slots()[1..], before.slots()[1..]);
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, Pages::default());
assert!(!shell.changed());
}
#[test]
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let found = s.controller.radial.clone();
let mut shell = Shell::<RadialScreen>::default();
click(&mut shell, &mut s, RadialRow::Delete(0));
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
// One page left: it can't go, and the strip says why.
let delete = row(&s, RadialRow::Delete(0));
assert!(!delete.live && delete.why.is_some());
let reset = row(&s, RadialRow::Reset);
assert!(reset.live);
shell.screen.take_shown();
click(&mut shell, &mut s, RadialRow::Reset);
assert_eq!(s.controller.radial, Pages::default());
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
assert_eq!(shell.screen.take_shown(), Some(0));
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, found);
}
#[test]
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
while row(&s, RadialRow::NewPage).live {
screen.press(&mut s, RadialRow::NewPage, false);
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
}
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
assert!(row(&s, RadialRow::NewPage).why.is_some());
// Every page is a sheet, named as the overlay names it.
let sheets = screen.sheets(&s);
assert_eq!(sheets.len(), radial::MAX_PAGES);
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
}
#[test]
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
screen.press(&mut s, RadialRow::Delete(1), false);
assert_eq!(screen.take_shown(), Some(0));
}
#[test]
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
let general = Pages::default().page(0).name.clone();
let mut s = Settings::default();
let mut screen = RadialScreen::default();
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
let entry = screen.naming.as_mut().unwrap();
// Done with the name as it was: a default page keeps following the language.
assert_eq!(entry.key(K::Enter, None), Named::Done);
assert!(!entry.apply(&mut s.controller.radial));
assert_eq!(s.controller.radial.page(0).name, general);
entry.type_text("!");
assert_eq!(entry.key(K::Escape, None), Named::Back);
entry.field.set_value("Mine");
entry.type_text("!");
assert!(entry.apply(&mut s.controller.radial));
assert_eq!(
s.controller.radial.page(0).name,
PageName::Custom("Mine!".to_owned())
);
}
#[test]
fn undo_puts_back_a_name_given_on_the_name_page() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let mut shell = Shell::<RadialScreen>::default();
shell.show(1);
click(&mut shell, &mut s, RadialRow::Rename(1));
let entry = shell.screen.naming.as_mut().unwrap();
entry.field.set_value("Building");
// As the options screen does on Done: the name page changed what Undo puts back.
let entry = shell.screen.naming.take().unwrap();
assert!(entry.apply(&mut s.controller.radial));
shell.changed_elsewhere();
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
assert!(!shell.changed());
}
#[test]
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
let mut s = Settings::default();
// Selecting with the left stick, the top face button (North) can open it.
s.controller.radial_stick = gamepad::RadialStick::Left;
s.controller.bind(OPENER, Some(gamepad::Button::North));
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
let label = |s: &Settings| {
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
match opener_row(s, glyphs).value {
Value::Binding(Binding {
shown: Shown::Pad { glyph, .. },
..
}) => glyph.expect("bound").label().to_owned(),
other => panic!("{other:?}"),
}
};
// By places, the top button: printed X on a Nintendo pad.
s.controller.nintendo_letters = false;
assert_eq!(label(&s), "X");
// By letters, the part is played by the button printed Y (on the left).
s.controller.nintendo_letters = true;
assert_eq!(label(&s), "Y");
}
#[test]
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
let mut s = Settings::default();
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
let shown = |s: &Settings| match opener_row(s, glyphs).value {
Value::Binding(Binding {
capture,
shown: Shown::Pad { glyph, .. },
..
}) => {
assert_eq!(capture, Capture::Pad(OPENER));
glyph
}
other => panic!("{other:?}"),
};
let bound = s.controller.binding(OPENER).unwrap();
// The button on its glyph.
let glyph = shown(&s).expect("the opener's glyph");
assert_eq!(glyph.part(), bound);
assert_eq!(glyph.label(), "→");
assert_eq!(opener_row(&s, glyphs).chosen, None);
s.controller.bind("key.chat", Some(bound));
let shared = opener_row(&s, glyphs).chosen;
assert!(shared.is_some(), "a shared button");
s.controller.bind(OPENER, None);
let unbound = opener_row(&s, glyphs).chosen;
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
// A press waits for a button on the row, and Undo takes a new one back.
let mut shell = Shell::<RadialScreen>::default();
s.controller.bind(OPENER, Some(bound));
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
assert!(shell.changed());
s.controller.bind(OPENER, Some(PadButton::North));
undo(&mut shell, &mut s);
assert_eq!(s.controller.binding(OPENER), Some(bound));
}
#[test]
fn every_string_has_a_horizon_key() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let english = lang::Language::load(&[], "en_us");
let ((), missed) = lang::scoped(english, || {
let mut s = Settings::default();
s.controller.bind("key.chat", Some(PadButton::DPadRight));
let mut shell = Shell::<RadialScreen>::default();
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
let mut batch = HudBatch::new([854, 480]);
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
};
// Every page's rows are built, with what the strip says of each.
draw(&mut shell, &s);
s.controller.bind(OPENER, None);
s.controller.radial.remove(1);
while s.controller.radial.add().is_some() {}
draw(&mut shell, &s);
s.controller.radial = Pages::default();
s.controller.radial.remove(1);
draw(&mut shell, &s);
let mut batch = HudBatch::new([854, 480]);
let waiting = Some(Capture::Pad(OPENER));
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
let mut entry = NameEntry::new(0, "Mine");
let mut batch = HudBatch::new([854, 480]);
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
let mut picker = Shell::<PickScreen>::default();
picker.screen.open_on((0, 1), &s.controller.radial);
let mut batch = HudBatch::new([854, 480]);
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
});
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
let missed: Vec<_> = missed
.iter()
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
.collect();
assert!(missed.is_empty(), "no text for {missed:?}");
}
}
File diff suppressed because it is too large. Load diff
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -862,6 +869,10 @@ pub enum Opt {
PadNintendoButtons,
PadKeyboard,
PadGameplayGuide,
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
PadRadialMode,
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
PadRadialStick,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,815 @@
use super::*;
use crate::options_ui::Event;
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
const SHORT: (f32, f32) = (480.0, 240.0);
const CHANGED: Output = Output::Event(Event::Changed);
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
/// its own, and a row a wheel steps.
#[derive(Default)]
struct Probe {
slider: f32,
choice: usize,
pressed: Vec<(u32, bool)>,
/// How many times its rows were built.
built: std::cell::Cell<usize>,
}
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
const SLIDER: u32 = 0;
const CHOICE: u32 = 1;
const FIRST_FILLER: u32 = 10;
const FILLERS: u32 = 40;
const GREY: u32 = 100;
const WHY: &str = "Not here.";
impl Screen for Probe {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Probe"
}
/// Its rows read the field of view, besides its own state.
fn stamp(&self, s: &Settings) -> u64 {
stamp_of(&s.fov)
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
self.built.set(self.built.get() + 1);
let (value, chosen) = CHOICES[self.choice];
let mut rows = vec![
Row::new(
SLIDER,
"Level".to_owned(),
Value::Slider {
position: self.slider,
steps: Some(10),
text: s.fov.to_string(),
},
),
Row {
chosen: Some(chosen.to_owned()),
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
.about("Picks a colour.".to_owned())
},
Row::new(GREY, "Greyed".to_owned(), Value::Press)
.about("Does a thing.".to_owned())
.blocked(Some(WHY.to_owned())),
];
rows.extend((0..FILLERS).map(|i| {
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
.about("Does nothing.".to_owned())
}));
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
vec![
Sheet::new(
0,
"One".to_owned(),
"The first.".to_owned(),
vec![Group { name: "G", rows }],
),
Sheet::new(
1,
"Two".to_owned(),
"The second.".to_owned(),
vec![Group {
name: "H",
rows: elsewhere,
}],
),
]
}
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
self.pressed.push((row, back));
match row {
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
FIRST_FILLER => s.fov += 1.0,
_ => {}
}
vec![CHANGED]
}
fn undoes(&self) -> bool {
true
}
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.fov = before.fov;
Vec::new()
}
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
self.slider = position;
}
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
(row == CHOICE).then(|| {
self.choice = (self.choice + 1) % CHOICES.len();
vec![CHANGED]
})
}
}
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
shell.point_at(&Settings::default(), size, row)
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
/// away from any row, at the size it was drawn at.
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
let mut batch = HudBatch::new([1708, 960]);
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
let size = (batch.width, batch.height);
shell.scroll(s, size, -1.0);
shell.key(s, K::ArrowDown, None, false, size);
shell.wheel(s, size, (0.0, 0.0), 1.0);
size
}
#[test]
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
let atlas = atlas();
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let built = |shell: &Shell<Probe>| shell.screen.built.get();
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
let view = shell.view(s, size);
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
labels.any(|label| label.ends_with(end))
};
// Each of the four built them before: now the first frame builds them once, the next none.
let size = frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
// A press changes the screen's own state: its row reads the new value.
let at = middle_of(&mut shell, CHOICE, size);
shell.click(&mut s, at, size, false, false, false);
assert!(reads(&mut shell, &s, size, "Blue"));
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
s.fov = 90.0;
frame(&mut shell, &mut s, &atlas);
assert!(reads(&mut shell, &s, size, "90"));
let before = built(&shell);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), before);
// Searching, and another size, build them again.
shell.type_text("filler");
shell.view(&s, size);
assert_eq!(built(&shell), before + 1);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 2);
// So does another language, and coming back from it.
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
assert_eq!(built(&shell), before + 3);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 4);
}
#[test]
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let cols = Columns::of(SHORT.0, SHORT.1);
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
let cut = FIRST_FILLER + 20;
let view = shell.view(&s, SHORT);
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
let rect = cols.row(y - shell.scroll.amount);
assert!(
rect[1] + rect[3] / 2.0 >= cols.bottom,
"the row's middle is hidden"
);
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
// All in view, it stays put.
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
assert_eq!(
shell.click(&mut s, middle, SHORT, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
// Nothing changed: there is no Undo to press.
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(s.fov, 71.0);
// Another option changed meanwhile isn't the screen's to put back.
s.chat_opacity = 0.25;
assert_eq!(
shell.click(&mut s, on_undo, SIZE, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
shell.click(&mut s, at, SIZE, false, false, false);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focus == Some(Target::Undo)
});
assert!(reached);
assert_eq!(
shell.key(&mut s, K::Enter, None, false, SIZE),
vec![CLICK, CHANGED]
);
assert_eq!(s.fov, 70.0);
assert_eq!(shell.focus, Some(Target::Done));
}
#[test]
fn the_strip_says_why_a_greyed_row_cant_be_used() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
let size = (batch.width, batch.height);
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
shell.draw(&s, batch, &atlas, at, false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
let dock = Columns::of(size.0, size.1).dock;
let labelled = crate::widgets::last_labelled();
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
found.map(|w| w.label.clone())
};
let grey = middle_of(&mut shell, GREY, size);
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
let choice = middle_of(&mut shell, CHOICE, size);
assert_eq!(
strip(&mut batch, &mut shell, choice).as_deref(),
Some(CHOICES[0].1)
);
}
#[test]
fn growing_the_window_keeps_the_scroll_within_the_list() {
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
shell.scroll(&s, SIZE, -1000.0);
assert!(shell.scroll.amount > 0.0);
let tall = (SIZE.0, 4000.0);
let view = shell.view(&s, tall);
assert_eq!(shell.scroll.amount, 0.0);
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
assert!(first >= view.cols.top);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let rate = SCROLL_RATE;
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
assert_eq!(shell.scroll.amount, rate);
shell.scroll(&s, SIZE, -2.0);
assert_eq!(shell.scroll.amount, 3.0 * rate);
shell.scroll(&s, SIZE, 100.0);
assert_eq!(shell.scroll.amount, 0.0);
}
#[test]
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(shell.screen.choice, 0);
assert_eq!(shell.scroll.amount, SCROLL_RATE);
s.extras.wheel_cycles_settings = true;
shell.scroll.amount = 0.0;
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(outputs, vec![CHANGED]);
assert_eq!(shell.screen.choice, 1);
assert_eq!(shell.scroll.amount, 0.0);
// A row the screen doesn't step scrolls, the greyed one too.
let over_grey = middle_of(&mut shell, GREY, SIZE);
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
assert_eq!(shell.scroll.amount, SCROLL_RATE);
}
#[test]
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let about = |shell: &mut Shell<Probe>, s: &Settings| {
let view = shell.view(s, SIZE);
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
row.described().to_owned()
};
assert_eq!(about(&mut shell, &s), "Paints it red.");
let at = middle_of(&mut shell, CHOICE, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(about(&mut shell, &s), "Paints it blue.");
// The search reads the row's own description, on every page.
shell.type_text("colour");
let view = shell.view(&s, SIZE);
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
assert!(found.contains(&CHOICE) && !found.contains(&99));
shell.search.set_value("elsewhere");
shell.focus = None;
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
#[test]
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, SLIDER, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(shell.screen.slider, 0.0);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
let mid = (track + width / 2.0, at.1);
assert_eq!(
shell.click(&mut s, mid, SIZE, false, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 0.5);
let end = (track + width, at.1);
assert_eq!(
shell.click(&mut s, end, SIZE, true, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 1.0);
assert!(shell.release().is_some());
assert!(shell.release().is_none());
}
#[test]
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
// Tab: the search, the two pages, then the first row.
for _ in 0..4 {
key(&mut shell, K::Tab);
}
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
// A slider Tab reaches changes with the arrows, in its steps.
key(&mut shell, K::ArrowRight);
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
// The arrows go on past the greyed row and rows out of view, which scroll in.
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
for _ in 0..30 {
key(&mut shell, K::ArrowDown);
}
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
assert!(shell.scroll.amount > 0.0);
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let view = shell.view(&s, SIZE);
let [x, y, w, h] = view.cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert!(shell.typing());
Hosted::blur_search(&mut shell);
assert!(!shell.typing());
// Another focus isn't dropped.
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
Hosted::blur_search(&mut shell);
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_key_that_moves_nothing_shows_no_focus() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
}
#[test]
fn a_cut_description_says_so() {
assert_eq!(cut_off("off, it never "), "off, it never…");
}
#[test]
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
// The next key goes on from the row clicked, and shows where it went.
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
let (wide, base) = (
Columns::of(480.0, 270.0).with_note_lines(4),
Columns::of(480.0, 270.0),
);
assert_eq!(wide.note_lines, 4);
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
assert_eq!(wide.button(0), base.button(0));
// A 720p window at scale 4 has room for one more line, not two.
let short = Columns::of(320.0, 180.0).with_note_lines(4);
assert_eq!(short.note_lines, 3);
assert!(short.bottom - short.top >= MIN_LIST_H);
}
#[test]
fn shift_presses_a_row_backwards() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, true);
// The right button is not a press.
shell.click(&mut s, at, SIZE, false, true, false);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
}
#[test]
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, GREY, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK, Output::Done]
);
}
#[test]
fn a_page_picked_shows_alone() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK]
);
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
/// A screen with more pages than a short view's page list has room for: a page of its own,
/// then a section of `PAGES` under a line, one more page out of the list that the section's
/// first page links to, and a change that waits for Apply.
#[derive(Default)]
struct Pages {
pending: bool,
applied: usize,
}
const PAGES: u8 = 12;
const HOME: u8 = 0;
const DEEP: u8 = 100;
const LINK: u32 = 1000;
const WAITS: u32 = 1001;
const SECTION: &str = "Section";
impl Screen for Pages {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Pages"
}
fn stamp(&self, _: &Settings) -> u64 {
stamp_of(&self.pending)
}
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
let page = |id: u8, name: String, rows| {
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
};
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
for i in 1..=PAGES {
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
if i == 1 {
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
}
let mut sheet = page(i, format!("Page {i}"), rows);
sheet.heading = (i == 1).then(|| SECTION.to_owned());
sheets.push(sheet);
}
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
deeper.listed = false;
sheets.push(deeper);
sheets
}
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
self.pending |= row == WAITS;
vec![CHANGED]
}
fn link(&self, row: u32) -> Option<u8> {
(row == LINK).then_some(DEEP)
}
fn undoes(&self) -> bool {
true
}
fn pending(&self, _: &Settings) -> bool {
self.pending
}
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
self.pending = false;
self.applied += 1;
vec![CHANGED]
}
}
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
/// the page list has room for six places).
const TALL: [u32; 2] = [854, 960];
const SHORT_WINDOW: [u32; 2] = [854, 480];
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
let mut batch = HudBatch::new(window);
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
(batch.width, batch.height)
}
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Tall enough: each page under the last, the deeper page left out as it is in no list.
drawn(&mut shell, &s, TALL);
let tall = shell.page_places();
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
assert!(tall.iter().all(|p| p.2 == Part::Tab));
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
assert_eq!(shell.strip_ends(), None);
// Short: the first page, then the section in one row, its first page between nothing and
// its next. Every place is inside the column, the row's on one line under the section's.
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let short = shell.page_places();
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
assert_eq!(
parts,
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
);
assert_eq!(shell.strip_ends(), Some((false, true)));
let cols = Columns::of(size.0, size.1);
for (_, [x, _, w, _], _) in &short {
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
}
assert_eq!(short[1].1[1], short[2].1[1]);
assert_eq!(short[1].1[1], cols.page(2)[1]);
// The line naming the section stays over it.
let labelled = crate::widgets::last_labelled();
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
}
#[test]
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(1);
let mut seen = vec![1];
let size = loop {
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let places = shell.page_places();
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
break size;
};
assert_eq!(
shell.click(&mut s, middle(rect), size, false, false, false),
[CLICK]
);
assert_eq!(shell.open_page(), Some(next));
seen.push(next);
};
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
assert_eq!(shell.strip_ends(), Some((true, false)));
// At the last page, the right of the row picks nothing: its arrow is dimmed.
let cols = Columns::of(size.0, size.1);
let [x, y, w, h] = cols.page(2);
assert!(
shell
.click(
&mut s,
(x + w - 2.0, y + h / 2.0),
size,
false,
false,
false
)
.is_empty()
);
assert_eq!(shell.open_page(), Some(PAGES));
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
// it to the middle of the row.
let before = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(before, Part::Prev);
shell.set_focus(Some(Target::Page(PAGES - 1)));
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
drawn(&mut shell, &s, SHORT_WINDOW);
let now = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(now, Part::Shown);
}
#[test]
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
shell.set_search("row 1");
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
assert_eq!(pages(&shell), [HOME, 1, 10]);
assert_eq!(shell.strip_ends(), Some((false, true)));
// Its arrow goes to the next match, which has the first one before it and the last after.
let next = shell.page_places()[2].1;
shell.click(&mut s, middle(next), size, false, false, false);
drawn(&mut shell, &s, SHORT_WINDOW);
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
assert_eq!(shell.strip_ends(), Some((true, true)));
}
#[test]
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(5);
drawn(&mut shell, &s, SHORT_WINDOW);
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
.into_iter()
.filter_map(|t| match t.kind {
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
_ => None,
})
.collect();
// The first page, the page before, the open one (lit) and the one after.
assert_eq!(tabs.len(), 4, "{tabs:?}");
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
assert!(open.1 && before < open.0 && open.0 < after);
// The arrows are vanilla's Previous Page and Next Page to the narrator.
let labels = crate::widgets::last_labelled();
let named = |key| {
labels
.iter()
.any(|w| w.label == lang::get(key) && w.enabled)
};
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
}
#[test]
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Found by the search from another page: back is the page the link is on.
shell.show(HOME);
shell.set_search("deeper");
let at = shell.point_at(&s, SIZE, LINK);
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
// The page opens alone, the search cleared; the list lights the page the link was on.
assert_eq!(shell.open_page(), Some(DEEP));
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
drawn(&mut shell, &s, TALL);
assert_eq!(shell.lit_page(), Some(1));
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
assert_eq!(shell.open_page(), Some(1));
assert_eq!(
shell.key(&mut s, K::Escape, None, false, SIZE),
[Output::Done]
);
}
#[test]
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
let done = Columns::of(SIZE.0, SIZE.1).button(0);
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
let at = shell.point_at(&s, SIZE, WAITS);
shell.click(&mut s, at, SIZE, false, false, false);
let foot = shell.foot_at(&s, SIZE);
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
// Side by side over Done, as wide as it together.
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
assert_eq!(
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
[CLICK, CHANGED]
);
assert_eq!(shell.screen.applied, 1);
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Done]);
}
#[test]
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let choice = middle_of(&mut shell, CHOICE, SIZE);
assert!(
shell
.click(&mut s, choice, SIZE, false, true, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
}
@@ -913,6 +913,20 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Update volume, pitch, and position in a single backend call.
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
self.set_volume(channel, volume);
self.set_pitch(channel, pitch);
self.set_position(channel, position);
}
/// Filter which handles have stopped playing in a single pass.
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
for &(h, c) in live {
if self.stopped(c) {
stopped.push(h);
}
}
}
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
@@ -1494,13 +1508,14 @@ impl SoundEngine {
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
let mut stopped = std::mem::take(&mut self.mapped_scratch);
stopped.clear();
backend.filter_stopped(&live, &mut stopped);
for h in stopped.drain(..) {
self.release_handle(h, backend);
}
// Released handles nobody maps any more.
let mut mapped = std::mem::take(&mut self.mapped_scratch);
let mut mapped = stopped;
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
@@ -1550,9 +1565,7 @@ impl SoundEngine {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
backend.set_position(c, position);
backend.update_channel(c, volume, pitch, position);
}
let instance = self.instances.get_mut(&id).expect("present");
if instance.looping
@@ -304,19 +304,19 @@ impl StudioUi {
// Card background & highlight
if is_selected {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, rect, style::open());
} else if is_hovered {
style::fill(batch, atlas, rect, style::HOVER);
style::fill(batch, atlas, rect, style::hover());
} else {
style::well(batch, atlas, rect);
}
let border_color = if is_selected {
style::ACCENT
style::accent()
} else if is_hovered {
style::WHITE
} else {
style::ROW_RULE
style::row_rule()
};
style::ring(batch, atlas, rect, border_color);
+23 -1
View File
@@ -1251,6 +1251,9 @@ pub struct EditBox {
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
pub invert_highlighted_text_color: bool,
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
/// round the frame, not over the text.
pub frame: Option<[f32; 4]>,
@@ -1543,6 +1546,7 @@ impl EditBox {
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
invert_highlighted_text_color: true,
frame: None,
}
}
@@ -1918,7 +1922,11 @@ impl EditBox {
let right = self.x + self.width;
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
let (a, b) = (x0.min(x1), x0.max(x1));
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
batch.text_highlight(
atlas,
(a, ty - 1.0, b - a, 11.0),
self.invert_highlighted_text_color,
);
}
if show_cursor {
if insert {
@@ -2628,6 +2636,20 @@ mod tests {
);
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
assert!(edit.invert_highlighted_text_color);
edit.invert_highlighted_text_color = false;
edit.set_value("name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert_eq!(edit.selected_text(), "name");
assert!(!edit.invert_highlighted_text_color);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
+8 -6
View File
@@ -39,6 +39,8 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -117,14 +119,14 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Herbegin die speletjie om %s toe te pas: hulle voeg blokke, items of wêreldgenerering by.",
"horizon.shader_options.profile": "Profiel",
"horizon.agent.copy_details": "Kopieer verbindingbesonderhede",
"horizon.multiplayer.join": "Sluit aan",
"horizon.multiplayer.cancel_reconnecting": "Kanselleer Herkoppel",
"horizon.multiplayer.join_friend": "Sluit aan by 'n Vriend",
"horizon.multiplayer.invite_code": "Uitnodigingskode",
"horizon.multiplayer.relay": "Aflos (opsioneel)",
"Preset": "Vooraf ingesteld",
"LOD Distance": "LOD Afstand",
"Far Detail": "Verre Detail",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Vanlyn profiele behoort aan hierdie geval",
"horizon.splitscreen.profiles.move": "Beweeg waar jy speel",
"horizon.splitscreen.profiles.move_place": "Beweeg waar jy speel: %s",
"horizon.friends.title": "Speel met vriende",
"horizon.friends.hint": "Vriende plak die kode na die titelskerm se Vriende-knoppie, in Direkte verbinding, of op die lanseerder se Speel-bladsy.",
"horizon.friends.copied": "Gekopieer",
"horizon.friends.copy_code": "Kopieer kode",
"horizon.friends.stop_sharing": "Hou op deel",
"horizon.friends.none_yet": "Nog geen vriende hier nie.",
"horizon.friends.here_now": "Hier nou: %s",
"horizon.friends.share_intro": "Deel hierdie wêreld agter 'n uitnodigingskode: dit maak oop na LAN, en vriende op die internet sluit aan met die kode. Geen bediener van ons is betrokke nie.",
"horizon.friends.start_sharing": "Begin deel",
"horizon.friends.troubleshooting": "Foutsporing …",
"horizon.friends.stopped": "Het opgehou om die uitnodigingkode te deel.",
"horizon.friends.short_code_changed": "Iemand het aanhou om 'n verkeerde kort kode te probeer, so dit het verander.",
"horizon.friends.wrong_code": "Iemand het 'n verkeerde kode probeer.",
"horizon.friends.relay_stopped": "Te veel verkeerde kort kodes, so die aflos is af totdat jy weer deel. Vriende kan steeds met die volle kode aansluit.",
"horizon.friends.code_ready": "Jou uitnodigingskode is gereed: Speletjie-kieslys, Speel met vriende. Kopieer dit en stuur dit aan jou vriende.",
"horizon.friends.share_failed": "Kon nie die wêreld deel nie: %s",
"horizon.friends.finding_route": "Vind tans hoe vriende jou kan bereik …",
"horizon.osk.shift": "Verskuiwing",
"horizon.osk.space": "Ruimte",
"horizon.osk.delete": "Vee uit",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "أعد تشغيل اللعبة لتطبيق %s: فهي تضيف كتلًا أو عناصر أو توليد عالم.",
"horizon.shader_options.profile": "الملف الشخصي",
"horizon.agent.copy_details": "نسخ تفاصيل الاتصال",
"horizon.multiplayer.join": "انضم",
"horizon.multiplayer.cancel_reconnecting": "إلغاء إعادة الاتصال",
"horizon.multiplayer.join_friend": "انضم إلى صديق",
"horizon.multiplayer.invite_code": "رمز الدعوة",
"horizon.multiplayer.relay": "التتابع (اختياري)",
"Preset": "محددة مسبقا",
"LOD Distance": "مسافة اللد",
"Far Detail": "تفاصيل بعيدة",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "تنتمي الملفات الشخصية غير المتصلة بالإنترنت إلى هذه الحالة",
"horizon.splitscreen.profiles.move": "تحرك حيث تلعب",
"horizon.splitscreen.profiles.move_place": "تحرك حيث تلعب: %s",
"horizon.friends.title": "العب مع الأصدقاء",
"horizon.friends.hint": "يقوم الأصدقاء بلصق الرمز بعد زر الأصدقاء على شاشة العنوان، أو في الاتصال المباشر، أو على صفحة التشغيل الخاصة بالمشغل.",
"horizon.friends.copied": "منقول",
"horizon.friends.copy_code": "نسخ الرمز",
"horizon.friends.stop_sharing": "توقف عن المشاركة",
"horizon.friends.none_yet": "لا يوجد أصدقاء هنا بعد.",
"horizon.friends.here_now": "هنا الآن: %s",
"horizon.friends.share_intro": "شارك هذا العالم خلف رمز الدعوة: فهو يفتح على شبكة LAN، وينضم الأصدقاء على الإنترنت باستخدام الرمز. لا يوجد خادم خاص بنا متورط.",
"horizon.friends.start_sharing": "ابدأ المشاركة",
"horizon.friends.troubleshooting": "استكشاف الأخطاء وإصلاحها...",
"horizon.friends.stopped": "تم إيقاف مشاركة رمز الدعوة.",
"horizon.friends.short_code_changed": "استمر شخص ما في تجربة رمز قصير خاطئ، لذلك تغير.",
"horizon.friends.wrong_code": "حاول شخص ما رمزا خاطئا.",
"horizon.friends.relay_stopped": "رموز قصيرة خاطئة كثيرة، لذا أُوقف التتابع حتى تشارك مجددًا. يمكن للأصدقاء الانضمام بالرمز الكامل.",
"horizon.friends.code_ready": "رمز الدعوة الخاص بك جاهز: قائمة الألعاب، العب مع الأصدقاء. انسخها وأرسلها إلى أصدقائك.",
"horizon.friends.share_failed": "لا يمكن مشاركة العالم: %s",
"horizon.friends.finding_route": "التعرف على كيفية وصول الأصدقاء إليك...",
"horizon.osk.shift": "التحول",
"horizon.osk.space": "الفضاء",
"horizon.osk.delete": "حذف",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "%s tətbiq etmək üçün oyunu yenidən başladın: onlar bloklar, əşyalar və ya dünya yaradılması əlavə edir.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Bağlantı təfərrüatlarını kopyalayın",
"horizon.multiplayer.join": "Qoşulun",
"horizon.multiplayer.cancel_reconnecting": "Yenidən qoşulmanı ləğv edin",
"horizon.multiplayer.join_friend": "Dosta Qoşulun",
"horizon.multiplayer.invite_code": "Dəvət Kodu",
"horizon.multiplayer.relay": "Rele (isteğe bağlı)",
"Preset": "Əvvəlcədən təyin",
"LOD Distance": "LOD məsafəsi",
"Far Detail": "Uzaq Detal",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Oflayn profillər bu instansiyaya aiddir",
"horizon.splitscreen.profiles.move": "Oynadığınız yerdə hərəkət edin",
"horizon.splitscreen.profiles.move_place": "Oynadığınız yerə köçürün: %s",
"horizon.friends.title": "Dostlarla oynayın",
"horizon.friends.hint": "Dostlar kodu başlıq ekranının Dostlar düyməsindən sonra, Birbaşa Bağlantıda və ya başlatma qurğusunun Play səhifəsində yerləşdirin.",
"horizon.friends.copied": "Kopyalandı",
"horizon.friends.copy_code": "Kodu kopyalayın",
"horizon.friends.stop_sharing": "Paylaşmağı dayandırın",
"horizon.friends.none_yet": "Burada hələ dost yoxdur.",
"horizon.friends.here_now": "İndi burada: %s",
"horizon.friends.share_intro": "Bu dünyanı dəvət kodunun arxasında paylaşın: o, LAN-a açılır və internetdəki dostlar kodla qoşulur. Heç bir serverimiz iştirak etmir.",
"horizon.friends.start_sharing": "Paylaşmağa başlayın",
"horizon.friends.troubleshooting": "Problem həll olunur...",
"horizon.friends.stopped": "Dəvət kodunu paylaşmağı dayandırdı.",
"horizon.friends.short_code_changed": "Kimsə səhv qısa kodu sınamağa davam etdi, ona görə də dəyişdi.",
"horizon.friends.wrong_code": "Kimsə səhv kodu sınadı.",
"horizon.friends.relay_stopped": "Çox sayda yanlış qısa kod daxil edildi, ona görə yenidən paylaşana qədər rele söndürülüb. Dostlar hələ də tam kodla qoşula bilər.",
"horizon.friends.code_ready": "Dəvət kodunuz hazırdır: Oyun Menyu, Dostlarla Oyna. Kopyalayın və dostlarınıza göndərin.",
"horizon.friends.share_failed": "Dünyanı paylaşmaq mümkün olmadı: %s",
"horizon.friends.finding_route": "Dostların sizinlə necə əlaqə saxlaya biləcəyi tapılır...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Kosmos",
"horizon.osk.delete": "Sil",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "%s ҡулланыу өсөн уйынды яңынан эшләтегеҙ: улар блоктар, әйберҙәр йәки донъя генерацияһын өҫтәй.",
"horizon.shader_options.profile": "Профиль",
"horizon.agent.copy_details": "Тоташыу тураһында мәғлүмәтте күсерергә",
"horizon.multiplayer.join": "Ҡушылыу",
"horizon.multiplayer.cancel_reconnecting": "Яңынан тоташтырыуҙы юҡҡа сығарыу",
"horizon.multiplayer.join_friend": "Дуҫҡа ҡушыл",
"horizon.multiplayer.invite_code": "Саҡырыу коды",
"horizon.multiplayer.relay": "Реле (теләк буйынса)",
"Preset": "Алдан ҡуйылған",
"LOD Distance": "LOD арауығы",
"Far Detail": "Алыҫ деталь",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Офлайн профилдәр был данаға ҡарай",
"horizon.splitscreen.profiles.move": "Уйнаған ергә күсерегеҙ .",
"horizon.splitscreen.profiles.move_place": "Уйнаған урынға күсегеҙ: %s .",
"horizon.friends.title": "Дуҫтар менән уйнау .",
"horizon.friends.hint": "Дуҫтар кодты титул экранының «Дуҫтар» төймәһенән һуң, туранан-тура бәйләнештә йәки стартердың «Уйнау» битендә йәбештерә.",
"horizon.friends.copied": "Күсерелгән",
"horizon.friends.copy_code": "Код күсермәһе",
"horizon.friends.stop_sharing": "Бүлешеүҙе туҡтатығыҙ",
"horizon.friends.none_yet": "Бында әлегә дуҫтар юҡ.",
"horizon.friends.here_now": "Бында хәҙер: %s",
"horizon.friends.share_intro": "Был донъя менән бүлешергә артында саҡырыу коды: ул LAN асыла, һәм дуҫтар интернетта код менән ҡушыла. Беҙҙең бер сервер ҙа ҡатнашмай.",
"horizon.friends.start_sharing": "Бүлешергә башлағыҙ",
"horizon.friends.troubleshooting": "Неисправность...",
"horizon.friends.stopped": "Саҡырыу коды менән бүлешеүҙе туҡтатты.",
"horizon.friends.short_code_changed": "Кемдер дөрөҫ булмаған ҡыҫҡа кодты һынап ҡараны, шуға күрә ул үҙгәрҙе.",
"horizon.friends.wrong_code": "Кемдер дөрөҫ булмаған кодты ҡулланып ҡараған.",
"horizon.friends.relay_stopped": "Дөрөҫ булмаған ҡыҫҡа кодтар бик күп булды, шуға күрә ҡабат бүлешкәнгә тиклем реле һүндерелгән. Дуҫтар тулы код менән килеүен дауам итә ала.",
"horizon.friends.code_ready": "Һеҙҙең саҡырыу коды әҙер: Уйын Менюһы, Дуҫтар менән уйнау. Күсереп яҙып, дуҫтарығыҙға ебәрегеҙ.",
"horizon.friends.share_failed": "Донъяны бүлешә алманым: %s",
"horizon.friends.finding_route": "Һеҙгә дуҫтар нисек барып етә алыуын табыу...",
"horizon.osk.shift": "Смена",
"horizon.osk.space": "Йыһан",
"horizon.osk.delete": "Юйырға",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Перазапусціце гульню, каб ужыць %s: яны дадаюць блокі, прадметы або генерацыю свету.",
"horizon.shader_options.profile": "Профіль",
"horizon.agent.copy_details": "Скапіруйце звесткі аб падключэнні",
"horizon.multiplayer.join": "Далучайцеся",
"horizon.multiplayer.cancel_reconnecting": "Адмяніць паўторнае падключэнне",
"horizon.multiplayer.join_friend": "Далучайцеся да сяброў",
"horizon.multiplayer.invite_code": "Код запрашэння",
"horizon.multiplayer.relay": "Рэле (неабавязкова)",
"Preset": "Прадусталяваная",
"LOD Distance": "LOD Адлегласць",
"Far Detail": "Далёкая дэталь",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Афлайн-профілі належаць да гэтага асобніка",
"horizon.splitscreen.profiles.move": "Рухайцеся туды, дзе гуляеце",
"horizon.splitscreen.profiles.move_place": "Рухайцеся туды, дзе вы гуляеце: %s",
"horizon.friends.title": "Гуляць з сябрамі",
"horizon.friends.hint": "Сябры ўстаўце код пасля кнопкі \"Сябры\" на тытульным экране ў раздзеле \"Прамое злучэнне\" або на старонцы \"Прайграванне\" праграмы запуску.",
"horizon.friends.copied": "Скапіравана",
"horizon.friends.copy_code": "Скапіруйце код",
"horizon.friends.stop_sharing": "Спыніць абагульванне",
"horizon.friends.none_yet": "Тут пакуль няма сяброў.",
"horizon.friends.here_now": "Вось зараз: %s",
"horizon.friends.share_intro": "Падзяліцеся гэтым светам за кодам запрашэння: ён адкрываецца ў лакальнай сеткі, і сябры ў Інтэрнэце далучаюцца з кодам. Наш сервер не задзейнічаны.",
"horizon.friends.start_sharing": "Пачніце абагульваць",
"horizon.friends.troubleshooting": "Ліквідацыя непаладак...",
"horizon.friends.stopped": "Абагульванне кода запрашэння спынена.",
"horizon.friends.short_code_changed": "Хтосьці спрабаваў увесці няправільны кароткі код, таму ён змяніўся.",
"horizon.friends.wrong_code": "Хтосьці паспрабаваў увесці няправільны код.",
"horizon.friends.relay_stopped": "Занадта шмат няправільных кароткіх кодаў, таму рэле выключана, пакуль вы не падзеліцеся зноў. Сябры ўсё яшчэ могуць далучыцца з поўным кодам.",
"horizon.friends.code_ready": "Ваш код запрашэння гатовы: меню гульні, гульня з сябрамі. Скапіруйце яго і адпраўце сваім сябрам.",
"horizon.friends.share_failed": "Не атрымалася падзяліцца светам: %s",
"horizon.friends.finding_route": "Пошук, як сябры могуць звязацца з вамі...",
"horizon.osk.shift": "зрух",
"horizon.osk.space": "космас",
"horizon.osk.delete": "Выдаліць",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Рестартирайте играта, за да приложите %s: те добавят блокове, предмети или генериране на света.",
"horizon.shader_options.profile": "Профил",
"horizon.agent.copy_details": "Копиране на подробности за връзката",
"horizon.multiplayer.join": "Присъединете се",
"horizon.multiplayer.cancel_reconnecting": "Отказ Повторно свързване",
"horizon.multiplayer.join_friend": "Присъединете се към приятел",
"horizon.multiplayer.invite_code": "Код за покана",
"horizon.multiplayer.relay": "Реле (по избор)",
"Preset": "Предварителна настройка",
"LOD Distance": "LOD разстояние",
"Far Detail": "Далечен детайл",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Офлайн профилите принадлежат към този екземпляр",
"horizon.splitscreen.profiles.move": "Движете се там, където играете",
"horizon.splitscreen.profiles.move_place": "Преместете се там, където играете: %s",
"horizon.friends.title": "Играйте с приятели",
"horizon.friends.hint": "Приятели поставете кода след бутона Приятели на заглавния екран, в Direct Connection или на страницата за възпроизвеждане на стартовия панел.",
"horizon.friends.copied": "Копирано",
"horizon.friends.copy_code": "Копиране на код",
"horizon.friends.stop_sharing": "Спиране на споделянето",
"horizon.friends.none_yet": "Тук все още няма приятели.",
"horizon.friends.here_now": "Тук сега: %s",
"horizon.friends.share_intro": "Споделете този свят зад код за покана: той се отваря към LAN и приятели в интернет се присъединяват с кода. Не е включен наш сървър.",
"horizon.friends.start_sharing": "Започнете споделяне",
"horizon.friends.troubleshooting": "Отстраняване на неизправности...",
"horizon.friends.stopped": "Спря споделянето на кода за покана.",
"horizon.friends.short_code_changed": "Някой продължаваше да опитва грешен кратък код, така че той се промени.",
"horizon.friends.wrong_code": "Някой е опитал грешен код.",
"horizon.friends.relay_stopped": "Твърде много грешни кратки кодове, затова релето е изключено, докато не споделите отново. Приятелите все още могат да се присъединят с пълния код.",
"horizon.friends.code_ready": "Вашият код за покана е готов: Меню за игри, Играйте с приятели. Копирайте го и го изпратете на приятелите си.",
"horizon.friends.share_failed": "Не можах да споделя света: %s",
"horizon.friends.finding_route": "Търся как приятелите могат да се свържат с вас...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "пространство",
"horizon.osk.delete": "Изтриване",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Adloc'hit ar c'hoari evit arloañ %s : ouzhpennañ a reont bloc'hoù, elfennoù pe krouidigezh bed.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Eilañ munudoù ar c'hennask",
"horizon.multiplayer.join": "Kemer perzh",
"horizon.multiplayer.cancel_reconnecting": "Nullañ an adkennaskañ",
"horizon.multiplayer.join_friend": "Kemer perzh en ur mignon",
"horizon.multiplayer.invite_code": "Kod pediñ",
"horizon.multiplayer.relay": "Treuzkas (dibar)",
"Preset": "Raktreset",
"LOD Distance": "Pellder LOD",
"Far Detail": "Munudoù pell",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Ar profiloù ezlinenn a ya d'ober an ensavadur-mañ",
"horizon.splitscreen.profiles.move": "Dilec'hiañ el lec'h ma c'hoarier",
"horizon.splitscreen.profiles.move_place": "Dilec'hiañ el lec'h ma c'hoarier : %s",
"horizon.friends.title": "C'hoari gant mignoned",
"horizon.friends.hint": "Pegañ a ra ar vignoned ar c'hod goude bouton Mignoned ar skramm titl, e Kevreadur war-eeun, pe war bajenn C'hoari al lañser.",
"horizon.friends.copied": "Eilet",
"horizon.friends.copy_code": "Eilañ ar c'hod",
"horizon.friends.stop_sharing": "Paouez da rannañ",
"horizon.friends.none_yet": "Mignon ebet amañ c'hoazh.",
"horizon.friends.here_now": "Setu bremañ : %s",
"horizon.friends.share_intro": "Rannañ ar bed-mañ a-dreñv ur c'hod pediñ : digeriñ a ra d'ar LAN, ha mignoned war internet a gemer perzh gant ar c'hod. N'eus servijer ebet ac'hanomp e-barzh.",
"horizon.friends.start_sharing": "Kregiñ da rannañ",
"horizon.friends.troubleshooting": "Diskoulmañ kudennoù...",
"horizon.friends.stopped": "Paouezet da rannañ ar c'hod pediñ.",
"horizon.friends.short_code_changed": "Unan bennak a gendalc'has da glask ur c'hod berr fazius, setu m'eo cheñchet.",
"horizon.friends.wrong_code": "Unan bennak en deus klasket ur c'hod fall.",
"horizon.friends.relay_stopped": "Re a godoù berr fazius, setu m'eo diweredekaet an treuzkas betek ma rannit en-dro. Ar vignoned a c'hall c'hoazh mont e-barzh gant ar c'hod klok.",
"horizon.friends.code_ready": "Prest eo ho kod pediñ : Menü ar c'hoari, C'hoari gant mignoned. Eilañ anezhañ ha kas anezhañ d'ho mignoned.",
"horizon.friends.share_failed": "N'hall ket rannañ ar bed : %s",
"horizon.friends.finding_route": "Kavout penaos e c'hall mignoned tizhout ac'hanoc'h...",
"horizon.osk.shift": "Dilec'hiañ",
"horizon.osk.space": "Egor",
"horizon.osk.delete": "Dilemel",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Restartujte igru da primijenite %s: dodaju blokove, predmete ili generisanje svijeta.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Kopiraj detalje veze",
"horizon.multiplayer.join": "Pridružite se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
"horizon.multiplayer.join_friend": "Pridružite se prijatelju",
"horizon.multiplayer.invite_code": "Pozivni kod",
"horizon.multiplayer.relay": "Relej (opciono)",
"Preset": "Predložak",
"LOD Distance": "LOD Udaljenost",
"Far Detail": "Daleki detalj",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline profili pripadaju ovoj instanci",
"horizon.splitscreen.profiles.move": "Krećite se tamo gdje igrate",
"horizon.splitscreen.profiles.move_place": "Premjestite se gdje igrate: %s",
"horizon.friends.title": "Igrajte se s prijateljima",
"horizon.friends.hint": "Prijatelji zalijepe kod iza dugmeta Prijatelji na naslovnom ekranu, u Direktnu vezu ili na Play stranicu pokretača.",
"horizon.friends.copied": "Kopirano",
"horizon.friends.copy_code": "Kopiraj kod",
"horizon.friends.stop_sharing": "Prekini dijeljenje",
"horizon.friends.none_yet": "Ovdje još nema prijatelja.",
"horizon.friends.here_now": "Evo sada: %s",
"horizon.friends.share_intro": "Podijelite ovaj svijet iza pozivnog koda: otvara se na LAN, a prijatelji na internetu se pridružuju kodu. Nijedan naš server nije uključen.",
"horizon.friends.start_sharing": "Počnite dijeliti",
"horizon.friends.troubleshooting": "Rješavanje problema...",
"horizon.friends.stopped": "Zaustavljeno je dijeljenje pozivnog koda.",
"horizon.friends.short_code_changed": "Neko je nastavio pokušavati s pogrešnim kratkim kodom, pa se to promijenilo.",
"horizon.friends.wrong_code": "Neko je probao pogrešan kod.",
"horizon.friends.relay_stopped": "Previše pogrešnih kratkih kodova, pa je relej isključen dok ponovo ne podijelite. Prijatelji se još mogu pridružiti s punim kodom.",
"horizon.friends.code_ready": "Vaš pozivni kod je spreman: Meni igre, Igrajte se sa prijateljima. Kopirajte ga i pošaljite svojim prijateljima.",
"horizon.friends.share_failed": "Ne mogu dijeliti svijet: %s",
"horizon.friends.finding_route": "Pronalaženje načina na koji prijatelji mogu doći do vas…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Razmak",
"horizon.osk.delete": "Izbriši",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Reinicia el joc per aplicar-hi %s: afegeixen blocs, objectes o generació del món.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copia els detalls de la connexió",
"horizon.multiplayer.join": "Uneix-te",
"horizon.multiplayer.cancel_reconnecting": "Cancel·la la tornada a connectar",
"horizon.multiplayer.join_friend": "Uneix-te a un amic",
"horizon.multiplayer.invite_code": "Codi d'invitació",
"horizon.multiplayer.relay": "Relé (opcional)",
"Preset": "Preestablert",
"LOD Distance": "Distància LOD",
"Far Detail": "Detall llunyà",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Els perfils fora de línia pertanyen a aquesta instància",
"horizon.splitscreen.profiles.move": "Mou-te on jugues",
"horizon.splitscreen.profiles.move_place": "Mou on jugues: %s",
"horizon.friends.title": "Juga amb els amics",
"horizon.friends.hint": "Els amics enganxen el codi després del botó Amics de la pantalla del títol, a la connexió directa o a la pàgina de reproducció del llançador.",
"horizon.friends.copied": "Copiat",
"horizon.friends.copy_code": "Copia el codi",
"horizon.friends.stop_sharing": "Deixa de compartir",
"horizon.friends.none_yet": "Encara no hi ha amics.",
"horizon.friends.here_now": "Aquí ara: %s",
"horizon.friends.share_intro": "Comparteix aquest món darrere d'un codi d'invitació: s'obre a LAN i els amics d'Internet s'uneixen amb el codi. Cap servidor nostre està implicat.",
"horizon.friends.start_sharing": "Comenceu a compartir",
"horizon.friends.troubleshooting": "Resolució de problemes...",
"horizon.friends.stopped": "S'ha deixat de compartir el codi d'invitació.",
"horizon.friends.short_code_changed": "Algú va seguir provant un codi curt equivocat, així que va canviar.",
"horizon.friends.wrong_code": "Algú ha provat un codi incorrecte.",
"horizon.friends.relay_stopped": "Massa codis curts incorrectes, així que el relé està aturat fins que tornis a compartir. Els amics encara s'hi poden unir amb el codi complet.",
"horizon.friends.code_ready": "El teu codi d'invitació està preparat: Menú de joc, Juga amb els amics. Copia'l i envia'l als teus amics.",
"horizon.friends.share_failed": "No s'ha pogut compartir el món: %s",
"horizon.friends.finding_route": "Trobar com els amics et poden arribar...",
"horizon.osk.shift": "Canvi",
"horizon.osk.space": "Espai",
"horizon.osk.delete": "Suprimeix",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Restartujte hru, aby se %s projevilo: přidává bloky, předměty nebo generování světa.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Kopírovat podrobnosti o připojení",
"horizon.multiplayer.join": "Připojte se",
"horizon.multiplayer.cancel_reconnecting": "Zrušit opětovné připojení",
"horizon.multiplayer.join_friend": "Přidejte se k příteli",
"horizon.multiplayer.invite_code": "Kód pozvánky",
"horizon.multiplayer.relay": "relé (volitelné)",
"Preset": "Přednastaveno",
"LOD Distance": "LOD Vzdálenost",
"Far Detail": "Daleký detail",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline profily patří k této instanci",
"horizon.splitscreen.profiles.move": "Pohybujte se tam, kde hrajete",
"horizon.splitscreen.profiles.move_place": "Přesuňte se, kde hrajete: %s",
"horizon.friends.title": "Hrajte s přáteli",
"horizon.friends.hint": "Přátelé vloží kód za tlačítko Přátelé na titulní obrazovce, do Přímého připojení nebo na stránku Play spouštěče.",
"horizon.friends.copied": "Zkopírováno",
"horizon.friends.copy_code": "Kopírovat kód",
"horizon.friends.stop_sharing": "Přestat sdílet",
"horizon.friends.none_yet": "Zatím tu nejsou žádní přátelé.",
"horizon.friends.here_now": "Tady teď: %s",
"horizon.friends.share_intro": "Sdílejte tento svět pomocí zvacího kódu: otevře se na LAN a přátelé na internetu se připojí pomocí kódu. Není zapojen žádný náš server.",
"horizon.friends.start_sharing": "Začněte sdílet",
"horizon.friends.troubleshooting": "Odstraňování problémů...",
"horizon.friends.stopped": "Sdílení kódu pozvánky bylo zastaveno.",
"horizon.friends.short_code_changed": "Někdo neustále zkoušel špatný krátký kód, takže se to změnilo.",
"horizon.friends.wrong_code": "Někdo zkusil špatný kód.",
"horizon.friends.relay_stopped": "Příliš mnoho špatných krátkých kódů, takže relé je vypnuté, dokud nebudete sdílet znovu. Přátelé se stále mohou připojit s plným kódem.",
"horizon.friends.code_ready": "Váš zvací kód je připraven: Herní menu, Hrajte s přáteli. Zkopírujte jej a pošlete svým přátelům.",
"horizon.friends.share_failed": "Nepodařilo se sdílet svět: %s",
"horizon.friends.finding_route": "Zjištění, jak vás mohou přátelé kontaktovat…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Prostor",
"horizon.osk.delete": "Smazat",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "%s ӗҫлеттерес тесен вӑйӑна ҫӗнӗрен пуҫлӑр: вӗсем блоксем, япаласем е тӗнче генерацийӗ хушса параҫҫӗ.",
"horizon.shader_options.profile": "Профиль",
"horizon.agent.copy_details": "Ҫыхӑну даннӑйӗсене копилесе илӗр",
"horizon.multiplayer.join": "Пӗрлешӗр",
"horizon.multiplayer.cancel_reconnecting": "Ҫӗнӗрен ҫыхӑнассине пӑрахӑҫла",
"horizon.multiplayer.join_friend": "Тус ҫумне хутшӑн",
"horizon.multiplayer.invite_code": "Йыхрав кодӗ",
"horizon.multiplayer.relay": "Реле (опционально)",
"Preset": "Малтанах лартнӑ",
"LOD Distance": "LOD дистанцийӗ",
"Far Detail": "Инҫетри вак-тӗвек",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Офлайн профильсем ҫак экземпляра кӗреҫҫӗ",
"horizon.splitscreen.profiles.move": "Ӑҫта вылятӑн, ҫавӑнта куҫ",
"horizon.splitscreen.profiles.move_place": "Ӑҫта вылятӑн, ҫавӑнта куҫ: %s",
"horizon.friends.title": "Юлташсемпе выля",
"horizon.friends.hint": "Туссем ҫак кода титул экранӗнчи Туссем кнопки хыҫҫӑн, Тӳрӗ ҫыхӑнура е пуҫлаканӑн Play страницинче ҫыпӑҫтараҫҫӗ.",
"horizon.friends.copied": "Копиленӗ",
"horizon.friends.copy_code": "Код копийӗ",
"horizon.friends.stop_sharing": "Пайлама пӑрахӑр",
"horizon.friends.none_yet": "Кунта туссем ҫук-ха.",
"horizon.friends.here_now": "Акӑ халӗ: %s",
"horizon.friends.share_intro": "Ҫак тӗнчене йыхрав кодӗ хыҫӗнче пайла: вӑл LAN-а уҫӑлать, интернетри туссем вара ҫав кодпа хутшӑнаҫҫӗ. Пирӗн пӗр сервер та ҫак ӗҫе хутшӑнмасть.",
"horizon.friends.start_sharing": "Пайлама пуҫлӑр",
"horizon.friends.troubleshooting": "Ҫитменлӗхсене пӗтересси...",
"horizon.friends.stopped": "Чӗнӳ кодне пайлама пӑрахрӑм.",
"horizon.friends.short_code_changed": "Такам кӗске кода йӑнӑш ҫырса пӑхрӗ, ҫавӑнпа вӑл улшӑнчӗ.",
"horizon.friends.wrong_code": "Такам йӑнӑш кодпа усӑ курса пӑхнӑ.",
"horizon.friends.relay_stopped": "Ытла нумай тӗрӗс мар кӗске код, ҫавӑнпа реле эсир тепӗр хут пайласан ҫеҫ ӗҫлӗ. Тусем тулли кодпа кӗме пултараҫҫӗ.",
"horizon.friends.code_ready": "Сирӗн чӗнӳ кодӗ хатӗр: Вӑйӑ меню, Туссемпе выля. Ӑна копилесе юлташсене ярса парӑр.",
"horizon.friends.share_failed": "Тӗнчене пайлаймарӑм: %s",
"horizon.friends.finding_route": "Юлташсем сирӗн пата мӗнле ҫитме пултарнине тупасси...",
"horizon.osk.shift": "Смена",
"horizon.osk.space": "Космос",
"horizon.osk.delete": "Пӗтер",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Ailgychwynnwch y gêm i gymhwyso %s: maent yn ychwanegu blociau, eitemau neu gynhyrchu bydoedd.",
"horizon.shader_options.profile": "Proffil",
"horizon.agent.copy_details": "Copïo Manylion Cysylltiad",
"horizon.multiplayer.join": "Ymuno",
"horizon.multiplayer.cancel_reconnecting": "Canslo Ailgysylltu",
"horizon.multiplayer.join_friend": "Ymunwch â Ffrind",
"horizon.multiplayer.invite_code": "Cod Gwahodd",
"horizon.multiplayer.relay": "Cyfnewid (dewisol)",
"Preset": "Rhagosodedig",
"LOD Distance": "Pellter LOD",
"Far Detail": "Manylyn Pell",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Mae proffiliau all-lein yn perthyn i'r achos hwn",
"horizon.splitscreen.profiles.move": "Symudwch lle rydych chi'n chwarae",
"horizon.splitscreen.profiles.move_place": "Symudwch ble rydych chi'n chwarae: %s",
"horizon.friends.title": "Chwarae gyda Ffrindiau",
"horizon.friends.hint": "Mae ffrindiau'n gludo'r cod ar ôl botwm Friends y sgrin deitl, yn Direct Connection, neu ar dudalen Chwarae'r lansiwr.",
"horizon.friends.copied": "Wedi'i gopïo",
"horizon.friends.copy_code": "Copi Cod",
"horizon.friends.stop_sharing": "Stopio Rhannu",
"horizon.friends.none_yet": "Dim ffrindiau yma eto.",
"horizon.friends.here_now": "Yma nawr: %s",
"horizon.friends.share_intro": "Rhannwch y byd hwn y tu ôl i god gwahoddiad: mae'n agor i LAN, ac mae ffrindiau ar y rhyngrwyd yn ymuno â'r cod. Nid oes unrhyw weinydd ohonom yn cymryd rhan.",
"horizon.friends.start_sharing": "Dechrau Rhannu",
"horizon.friends.troubleshooting": "Datrys Problemau...",
"horizon.friends.stopped": "Wedi stopio rhannu'r cod gwahoddiad.",
"horizon.friends.short_code_changed": "Roedd rhywun yn ceisio cod byr anghywir o hyd, felly fe newidiodd.",
"horizon.friends.wrong_code": "Ceisiodd rhywun god anghywir.",
"horizon.friends.relay_stopped": "Gormod o godau byr anghywir, felly mae'r cyfnewidydd i ffwrdd nes i chi rannu eto. Gall ffrindiau ymuno o hyd â'r cod llawn.",
"horizon.friends.code_ready": "Mae eich cod gwahoddiad yn barod: Dewislen Gêm, Chwarae gyda Ffrindiau. Copïwch ef a'i anfon at eich ffrindiau.",
"horizon.friends.share_failed": "Methu rhannu'r byd: %s",
"horizon.friends.finding_route": "Darganfod sut y gall ffrindiau eich cyrraedd…",
"horizon.osk.shift": "Turn",
"horizon.osk.space": "Gofod",
"horizon.osk.delete": "Dileu",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Genstart spillet for at anvende %s: de tilføjer blokke, genstande eller verdensgenerering.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Kopier forbindelsesoplysninger",
"horizon.multiplayer.join": "Deltag",
"horizon.multiplayer.cancel_reconnecting": "Annuller Gentilslutning",
"horizon.multiplayer.join_friend": "Slut dig til en ven",
"horizon.multiplayer.invite_code": "Invitationskode",
"horizon.multiplayer.relay": "Relæ (valgfrit)",
"Preset": "Forudindstillet",
"LOD Distance": "LOD Afstand",
"Far Detail": "Fjern detalje",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offlineprofiler hører til denne instans",
"horizon.splitscreen.profiles.move": "Flyt hvor du spiller",
"horizon.splitscreen.profiles.move_place": "Flyt hvor du spiller: %s",
"horizon.friends.title": "Spil med venner",
"horizon.friends.hint": "Venner indsætter koden efter titelskærmens Venner-knap, i Direct Connection eller på launcherens Play-side.",
"horizon.friends.copied": "Kopieret",
"horizon.friends.copy_code": "Kopiér kode",
"horizon.friends.stop_sharing": "Stop deling",
"horizon.friends.none_yet": "Ingen venner her endnu.",
"horizon.friends.here_now": "Her nu: %s",
"horizon.friends.share_intro": "Del denne verden bag en invitationskode: den åbner til LAN, og venner på internettet deltager med koden. Ingen af vores servere er involveret.",
"horizon.friends.start_sharing": "Begynd at dele",
"horizon.friends.troubleshooting": "Fejlfinding...",
"horizon.friends.stopped": "Stoppet med at dele invitationskoden.",
"horizon.friends.short_code_changed": "Nogen blev ved med at prøve en forkert kort kode, så den ændrede sig.",
"horizon.friends.wrong_code": "Nogen prøvede en forkert kode.",
"horizon.friends.relay_stopped": "For mange forkerte kortkoder, så relæet er slået fra, indtil du deler igen. Venner kan stadig deltage med den fulde kode.",
"horizon.friends.code_ready": "Din invitationskode er klar: Spilmenu, Spil med venner. Kopier det og send det til dine venner.",
"horizon.friends.share_failed": "Kunne ikke dele verden: %s",
"horizon.friends.finding_route": "Finder, hvordan venner kan nå dig...",
"horizon.osk.shift": "Skift",
"horizon.osk.space": "Plads",
"horizon.osk.delete": "Slet",
@@ -209,7 +209,11 @@
"horizon.mods.switch.restart": "Starte das Spiel neu, um %s anzuwenden: Sie fügen Blöcke, Gegenstände oder Weltgenerierung hinzu.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
"horizon.multiplayer.join_friend": "Freund beitreten",
"horizon.multiplayer.invite_code": "Einladungscode",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD-Weite",
"Far Detail": "Ferndetails",
@@ -319,6 +323,7 @@
"horizon.extras.wheel_cycles_settings": "Mausrad ändert Einstellungen",
"horizon.extras.pack_browser": "Ressourcenpaket-Browser",
"horizon.extras.music_page.tooltip": "Musik & Geräusche erhält eine Seite „Musik …“: Spiele jeden eingebauten Titel jetzt ab oder schalte einen aus, damit das Spiel ihn nicht mehr wählt. Nichts ändert sich, bis du einen Titel ausschaltest.",
"horizon.friends.relay_stopped": "Zu viele falsche Kurzcodes, deshalb ist das Relay aus, bis du erneut teilst. Freunde können weiterhin mit dem vollständigen Code beitreten.",
"horizon.extras.pack_browser.tooltip": "Ressourcenpakete auswählen erhält eine Schaltfläche „Hinzufügen“, die Modrinth durchsucht und ein Paket in deinen Paketordner installiert. Aus kontaktiert das Spiel Modrinth nie.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "ein Monitor",
@@ -538,6 +543,22 @@
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
"horizon.splitscreen.profiles.move": "Spielort wechseln",
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
"horizon.friends.title": "Mit Freunden spielen",
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
"horizon.friends.copied": "Kopiert",
"horizon.friends.copy_code": "Code kopieren",
"horizon.friends.stop_sharing": "Teilen beenden",
"horizon.friends.none_yet": "Noch keine Freunde hier.",
"horizon.friends.here_now": "Gerade hier: %s",
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
"horizon.friends.start_sharing": "Teilen starten",
"horizon.friends.troubleshooting": "Fehlerbehebung …",
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
"horizon.osk.shift": "Umschalt",
"horizon.osk.space": "Leertaste",
"horizon.osk.delete": "Löschen",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Επανεκκινήστε το παιχνίδι για να εφαρμόσετε το %s: προσθέτουν μπλοκ, αντικείμενα ή παραγωγή κόσμου.",
"horizon.shader_options.profile": "Προφίλ",
"horizon.agent.copy_details": "Αντιγραφή στοιχείων σύνδεσης",
"horizon.multiplayer.join": "Εγγραφείτε",
"horizon.multiplayer.cancel_reconnecting": "Ακύρωση επανασύνδεσης",
"horizon.multiplayer.join_friend": "Γίνετε μέλος ενός φίλου",
"horizon.multiplayer.invite_code": "Κωδικός πρόσκλησης",
"horizon.multiplayer.relay": "Ρελέ (προαιρετικό)",
"Preset": "Προκαθορισμένη",
"LOD Distance": "Απόσταση LOD",
"Far Detail": "Μακρινή λεπτομέρεια",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Τα προφίλ εκτός σύνδεσης ανήκουν σε αυτήν την περίπτωση",
"horizon.splitscreen.profiles.move": "Μετακινηθείτε όπου παίζετε",
"horizon.splitscreen.profiles.move_place": "Μετακινηθείτε όπου παίζετε: %s",
"horizon.friends.title": "Παίξτε με φίλους",
"horizon.friends.hint": "Οι φίλοι επικολλούν τον κώδικα μετά το κουμπί Φίλοι της οθόνης τίτλου, στην Άμεση σύνδεση ή στη σελίδα Αναπαραγωγή του προγράμματος εκκίνησης.",
"horizon.friends.copied": "Αντιγράφηκε",
"horizon.friends.copy_code": "Αντιγραφή Κωδικού",
"horizon.friends.stop_sharing": "Διακοπή κοινής χρήσης",
"horizon.friends.none_yet": "Δεν υπάρχουν φίλοι εδώ ακόμα.",
"horizon.friends.here_now": "Εδώ τώρα: %s",
"horizon.friends.share_intro": "Μοιραστείτε αυτόν τον κόσμο πίσω από έναν κωδικό πρόσκλησης: ανοίγει σε LAN και οι φίλοι στο Διαδίκτυο συμμετέχουν με τον κωδικό. Κανένας διακομιστής μας δεν εμπλέκεται.",
"horizon.friends.start_sharing": "Ξεκινήστε την κοινή χρήση",
"horizon.friends.troubleshooting": "Αντιμετώπιση προβλημάτων...",
"horizon.friends.stopped": "Σταμάτησε η κοινή χρήση του κωδικού πρόσκλησης.",
"horizon.friends.short_code_changed": "Κάποιος συνέχισε να δοκιμάζει έναν λάθος σύντομο κωδικό, οπότε άλλαξε.",
"horizon.friends.wrong_code": "Κάποιος δοκίμασε λάθος κωδικό.",
"horizon.friends.relay_stopped": "Πολλοί λάθος σύντομοι κωδικοί, οπότε το ρελέ είναι κλειστό μέχρι να μοιραστείτε ξανά. Οι φίλοι μπορούν ακόμη να συνδεθούν με τον πλήρη κωδικό.",
"horizon.friends.code_ready": "Ο κωδικός πρόσκλησής σας είναι έτοιμος: Μενού παιχνιδιού, Παίξτε με φίλους. Αντιγράψτε το και στείλτε το στους φίλους σας.",
"horizon.friends.share_failed": "Δεν μπορούσα να μοιραστώ τον κόσμο: %s",
"horizon.friends.finding_route": "Βρείτε πώς μπορούν να σας φτάσουν οι φίλοι…",
"horizon.osk.shift": "Μετατόπιση",
"horizon.osk.space": "Διάστημα",
"horizon.osk.delete": "Διαγραφή",
@@ -11,6 +11,18 @@
"horizon.options.gameplay_guide.basic": "Basic",
"horizon.options.gameplay_guide.full": "Full",
"horizon.options.gameplay_guide.off": "Off",
"horizon.options.radial_mode": "Radial Menu",
"horizon.options.radial_mode.about": "Hold the opener for the radial menu, or press it once to open it and again to close it.",
"horizon.options.radial_mode.hold.about": "Hold the opener and let go to run what the stick points at.",
"horizon.options.radial_mode.toggle.about": "Press the opener to open it; A or the opener again runs what the stick points at, B closes it.",
"horizon.options.radial_stick": "Radial Menu Selects With",
"horizon.options.radial_stick.about": "The stick that points at the radial menu's slots.",
"horizon.options.radial_stick.right": "Right Stick",
"horizon.options.radial_stick.left": "Left Stick",
"horizon.options.radial_stick.either": "Either",
"horizon.options.radial_stick.right.about": "The right stick points at the slots; the left still walks.",
"horizon.options.radial_stick.left.about": "The left stick points and you stand still. D-pad Right is under that thumb: pick an opener on the Radial Menu page.",
"horizon.options.radial_stick.either.about": "The last stick pushed points; neither walks nor turns the view. Only a bumper, a trigger or View can open the menu.",
"horizon.controller.guide.use": "Use",
"horizon.controller.guide.open": "Open",
"horizon.controller.guide.ride": "Ride",
@@ -129,6 +141,82 @@
"horizon.pages.shader_options": "Shader Options",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.controller_radial": "Radial Menu",
"horizon.radial.page.general": "General",
"horizon.radial.page.camera": "Camera",
"horizon.radial.release_stick": "Let go of the stick",
"horizon.radial.action.key_inventory": "Inventory",
"horizon.radial.action.key_chat": "Chat",
"horizon.radial.action.key_command": "Commands",
"horizon.radial.action.key_advancements": "Advancements",
"horizon.radial.action.key_togglePerspective": "Perspective",
"horizon.radial.action.key_swapOffhand": "Swap Hands",
"horizon.radial.action.key_drop": "Drop",
"horizon.radial.action.key_pickItem": "Pick Block",
"horizon.radial.action.key_screenshot": "Screenshot",
"horizon.radial.action.key_toggleGui": "Hide HUD",
"horizon.radial.action.key_smoothCamera": "Cinematic Camera",
"horizon.radial.action.key_socialInteractions": "Social",
"horizon.radial.action.key_emote": "Emote",
"horizon.radial.action.pad_hotbarPrevious": "Previous Slot",
"horizon.radial.action.pad_hotbarNext": "Next Slot",
"horizon.radial.action.key_fullscreen": "Fullscreen",
"horizon.radial.action.key_quickActions": "Quick Actions",
"horizon.radial.action.key_debug_overlay": "Debug Screen",
"horizon.radial.missing": "Missing",
"horizon.radial.why.missing": "Unknown action",
"horizon.radial.why.cosmetics": "Needs cosmetics on",
"horizon.radial.why.quick_actions": "This server has no Quick Actions",
"horizon.radial.why.social": "Only on a server",
"horizon.radial.next": "→ %s",
"horizon.radial.state.first_person": "First Person",
"horizon.radial.state.third_person_back": "Third Person",
"horizon.radial.state.third_person_front": "Front View",
"horizon.radial.state.shown": "Shown",
"horizon.radial.state.hidden": "Hidden",
"horizon.radial.state.on": "On",
"horizon.radial.state.off": "Off",
"horizon.radial.pick_title": "Choose an Action",
"horizon.radial.group.actions": "Actions",
"horizon.radial.pick.slot": "%s slot of %s",
"horizon.radial.pick.about": "Puts this action in the slot and goes back. The slot's own is marked.",
"horizon.radial.pick.current": "Current",
"horizon.radial.missing.about": "This game doesn't know the action %s: the slot keeps it and runs nothing. Press or Right to put another here.",
"horizon.radial.page.new": "New Page",
"horizon.pages.open.controller_radial": "Radial Menu...",
"horizon.radial.group.slots": "Slots",
"horizon.radial.group.page": "Page",
"horizon.radial.group.all": "All Pages",
"horizon.radial.empty": "Empty",
"horizon.radial.page_of": "Page %s of %s. Each slot is a direction of the selecting stick while the opener is held.",
"horizon.radial.slot.0": "Up",
"horizon.radial.slot.1": "Up-Right",
"horizon.radial.slot.2": "Right",
"horizon.radial.slot.3": "Down-Right",
"horizon.radial.slot.4": "Down",
"horizon.radial.slot.5": "Down-Left",
"horizon.radial.slot.6": "Left",
"horizon.radial.slot.7": "Up-Left",
"horizon.radial.slot.about": "The action this direction runs. Press it for the list of actions, Right for the next one, Left or Shift-click for the one before.",
"horizon.radial.rename": "Rename Page",
"horizon.radial.rename.about": "Give this page a name of your own.",
"horizon.radial.name_title": "Name This Page",
"horizon.radial.new_page.about": "Adds an empty page after the last one, and shows it.",
"horizon.radial.new_page.full": "The menu holds at most %s pages.",
"horizon.radial.delete_page": "Delete Page",
"horizon.radial.delete_page.about": "Removes this page and its slots at once. Undo brings it back.",
"horizon.radial.delete_page.last": "The menu keeps at least one page.",
"horizon.radial.reset": "Reset All Pages",
"horizon.radial.reset.about": "Puts back the General and Camera pages and removes the rest, at once. Undo brings them back.",
"horizon.radial.reset.default": "The pages are the defaults already.",
"horizon.radial.opener": "Opener",
"horizon.radial.opener.about": "The button held to open the menu. Press, then press the controller button to use: a bumper or the D-pad, not A, B, X, Y or the right stick, which steer and cancel.",
"horizon.radial.opener.about.left": "The button held to open the menu. Press, then press the controller button to use: a bumper, A, X or Y, not the D-pad or the left stick, which steer, nor B, which cancels.",
"horizon.radial.opener.about.either": "The button held to open the menu. Press, then press the controller button to use: a bumper, a trigger or View; both thumbs steer, and B cancels.",
"horizon.radial.unbound": "No button opens the menu (one under the thumb that selects is let go): press, then press a controller button.",
"horizon.radial.conflict": "Shared with another action: in play the menu takes that button. Press to pick another.",
"Radial Menu": "Radial Menu",
"Named pages of eight actions, held open with a button.": "Named pages of eight actions, held open with a button.",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.studio": "Replay Studio",
@@ -213,7 +301,11 @@
"horizon.mods.switch.restart": "Restart the game to apply %s: they add blocks, items or world generation.",
"horizon.shader_options.profile": "Profile",
"horizon.agent.copy_details": "Copy Connection Details",
"horizon.multiplayer.join": "Join",
"horizon.multiplayer.cancel_reconnecting": "Cancel Reconnecting",
"horizon.multiplayer.join_friend": "Join a Friend",
"horizon.multiplayer.invite_code": "Invite Code",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD Distance",
"Far Detail": "Far Detail",
@@ -283,6 +375,7 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
@@ -461,6 +554,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
"horizon.splitscreen.profiles.move": "Move where you play",
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
"horizon.friends.title": "Play with Friends",
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
"horizon.friends.copied": "Copied",
"horizon.friends.copy_code": "Copy Code",
"horizon.friends.stop_sharing": "Stop Sharing",
"horizon.friends.none_yet": "No friends here yet.",
"horizon.friends.here_now": "Here now: %s",
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
"horizon.friends.start_sharing": "Start Sharing",
"horizon.friends.troubleshooting": "Troubleshooting...",
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.relay_stopped": "Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Space",
"horizon.osk.delete": "Delete",
@@ -998,6 +1108,7 @@
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
"horizon.pad.action.radial": "Radial Menu",
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Restartigu la ludon por apliki %s: ili aldonas blokojn, objektojn aŭ mondgeneradon.",
"horizon.shader_options.profile": "Profilo",
"horizon.agent.copy_details": "Kopiu Detalojn pri Konekto",
"horizon.multiplayer.join": "Aliĝu",
"horizon.multiplayer.cancel_reconnecting": "Nuligi Rekonekti",
"horizon.multiplayer.join_friend": "Aliĝu al Amiko",
"horizon.multiplayer.invite_code": "Inviti Kodon",
"horizon.multiplayer.relay": "Relajso (laŭvola)",
"Preset": "Antaŭdifinita",
"LOD Distance": "LOD Distanco",
"Far Detail": "Malproksima Detalo",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Senretaj profiloj apartenas al ĉi tiu kazo",
"horizon.splitscreen.profiles.move": "Movu kien vi ludas",
"horizon.splitscreen.profiles.move_place": "Movu kien vi ludas: %s",
"horizon.friends.title": "Ludu kun Amikoj",
"horizon.friends.hint": "Amikoj algluas la kodon post la butono Amikoj de la titolekrano, en Rekta Konekto aŭ sur la Luda paĝo de la lanĉilo.",
"horizon.friends.copied": "Kopiita",
"horizon.friends.copy_code": "Kopiu Kodon",
"horizon.friends.stop_sharing": "Ĉesu Kundividon",
"horizon.friends.none_yet": "Ankoraŭ neniuj amikoj ĉi tie.",
"horizon.friends.here_now": "Ĉi tie nun: %s",
"horizon.friends.share_intro": "Kunhavigu ĉi tiun mondon malantaŭ invitkodo: ĝi malfermiĝas al LAN, kaj amikoj en la interreto aliĝas kun la kodo. Neniu servilo nia estas implikita.",
"horizon.friends.start_sharing": "Komencu Kunhavigi",
"horizon.friends.troubleshooting": "Solvado de problemoj...",
"horizon.friends.stopped": "Ĉesis kunhavigi la invitkodon.",
"horizon.friends.short_code_changed": "Iu daŭre provis malĝustan mallongan kodon, do ĝi ŝanĝiĝis.",
"horizon.friends.wrong_code": "Iu provis malĝustan kodon.",
"horizon.friends.relay_stopped": "Tro da malĝustaj mallongaj kodoj, do la relajso estas malŝaltita ĝis vi denove kundividos. Amikoj ankoraŭ povas aliĝi per la plena kodo.",
"horizon.friends.code_ready": "Via invitkodo estas preta: Luda Menuo, Ludu kun Amikoj. Kopiu ĝin kaj sendu ĝin al viaj amikoj.",
"horizon.friends.share_failed": "Ne povis dividi la mondon: %s",
"horizon.friends.finding_route": "Trovi kiel amikoj povas atingi vin...",
"horizon.osk.shift": "Majusklo",
"horizon.osk.space": "Spaco",
"horizon.osk.delete": "Forigi",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Reinicia el juego para aplicar %s: añaden bloques, objetos o generación del mundo.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copiar datos de conexión",
"horizon.multiplayer.join": "Unirse",
"horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
"horizon.multiplayer.join_friend": "Unirse a un amigo",
"horizon.multiplayer.invite_code": "Código de invitación",
"horizon.multiplayer.relay": "Relay (opcional)",
"Preset": "Preajuste",
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lejano",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Los perfiles sin conexión pertenecen a esta instancia",
"horizon.splitscreen.profiles.move": "Mover dónde juegas",
"horizon.splitscreen.profiles.move_place": "Mover dónde juegas: %s",
"horizon.friends.title": "Jugar con amigos",
"horizon.friends.hint": "Tus amigos pegan el código tras el botón Amigos de la pantalla de título, en Conexión directa o en la página Jugar del launcher.",
"horizon.friends.copied": "Copiado",
"horizon.friends.copy_code": "Copiar código",
"horizon.friends.stop_sharing": "Dejar de compartir",
"horizon.friends.none_yet": "Aún no hay amigos aquí.",
"horizon.friends.here_now": "Aquí ahora: %s",
"horizon.friends.share_intro": "Comparte este mundo con un código de invitación: se abre a la LAN y tus amigos de internet se unen con el código. No interviene ningún servidor nuestro.",
"horizon.friends.start_sharing": "Empezar a compartir",
"horizon.friends.troubleshooting": "Solución de problemas...",
"horizon.friends.stopped": "Se ha dejado de compartir el código de invitación.",
"horizon.friends.short_code_changed": "Alguien intentó varias veces un código corto incorrecto, así que ha cambiado.",
"horizon.friends.wrong_code": "Alguien ha probado un código incorrecto.",
"horizon.friends.relay_stopped": "Demasiados códigos cortos incorrectos: el relé queda apagado hasta que vuelvas a compartir. Tus amigos aún pueden unirse con el código completo.",
"horizon.friends.code_ready": "Tu código de invitación está listo: Menú del juego, Jugar con amigos. Cópialo y envíaselo a tus amigos.",
"horizon.friends.share_failed": "No se pudo compartir el mundo: %s",
"horizon.friends.finding_route": "Buscando cómo pueden llegar tus amigos hasta ti…",
"horizon.osk.shift": "Mayús",
"horizon.osk.space": "Espacio",
"horizon.osk.delete": "Borrar",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Taaskäivita mäng, et rakendada %s: need lisavad plokke, esemeid või maailma genereerimist.",
"horizon.shader_options.profile": "Profiil",
"horizon.agent.copy_details": "Kopeerige ühenduse üksikasjad",
"horizon.multiplayer.join": "Liitu",
"horizon.multiplayer.cancel_reconnecting": "Tühista uuesti ühendamine",
"horizon.multiplayer.join_friend": "Liituge sõbraga",
"horizon.multiplayer.invite_code": "Kutsekood",
"horizon.multiplayer.relay": "Relee (valikuline)",
"Preset": "Eelseadistatud",
"LOD Distance": "LOD vahemaa",
"Far Detail": "Kauge detail",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Sellesse eksemplari kuuluvad võrguühenduseta profiilid",
"horizon.splitscreen.profiles.move": "Liikuge sinna, kus mängite",
"horizon.splitscreen.profiles.move_place": "Liikuge sinna, kus mängite: %s",
"horizon.friends.title": "Mängige sõpradega",
"horizon.friends.hint": "Sõbrad kleebivad koodi pealkirjaekraani nupu Sõbrad järele, otseühenduses või käivitusprogrammi Play lehel.",
"horizon.friends.copied": "Kopeeritud",
"horizon.friends.copy_code": "Kopeeri kood",
"horizon.friends.stop_sharing": "Lõpeta jagamine",
"horizon.friends.none_yet": "Siin pole veel sõpru.",
"horizon.friends.here_now": "Siin praegu: %s",
"horizon.friends.share_intro": "Jagage seda maailma kutsekoodi taga: see avaneb LAN-i ja Internetis olevad sõbrad liituvad koodiga. Ükski meie server ei ole sellega seotud.",
"horizon.friends.start_sharing": "Alusta jagamist",
"horizon.friends.troubleshooting": "Veaotsing...",
"horizon.friends.stopped": "Kutsekoodi jagamine lõpetati.",
"horizon.friends.short_code_changed": "Keegi proovis pidevalt valet lühikoodi, nii et see muutus.",
"horizon.friends.wrong_code": "Keegi proovis vale koodi.",
"horizon.friends.relay_stopped": "Liiga palju valesid lühikoode, seega relee on väljas, kuni uuesti jagad. Sõbrad saavad siiski täiskoodiga liituda.",
"horizon.friends.code_ready": "Teie kutsekood on valmis: Mängumenüü, Mängige sõpradega. Kopeerige see ja saatke see oma sõpradele.",
"horizon.friends.share_failed": "Ei saanud maailma jagada: %s",
"horizon.friends.finding_route": "Kuidas sõbrad teiega ühendust saavad…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Kosmos",
"horizon.osk.delete": "Kustuta",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Berrabiarazi jokoa %s aplikatzeko: blokeak, elementuak edo mundu-sorrera gehitzen dituzte.",
"horizon.shader_options.profile": "Profila",
"horizon.agent.copy_details": "Kopiatu konexioaren xehetasunak",
"horizon.multiplayer.join": "Sartu",
"horizon.multiplayer.cancel_reconnecting": "Utzi berriro konektatzea",
"horizon.multiplayer.join_friend": "Sartu Lagun batekin",
"horizon.multiplayer.invite_code": "Gonbidatu kodea",
"horizon.multiplayer.relay": "Erreleboa (aukerakoa)",
"Preset": "Aurrez ezarritakoa",
"LOD Distance": "LOD Distantzia",
"Far Detail": "Urruneko xehetasuna",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Lineaz kanpoko profilak instantzia honetakoak dira",
"horizon.splitscreen.profiles.move": "Mugitu jolasten duzun tokira",
"horizon.splitscreen.profiles.move_place": "Mugitu jolasten duzun tokira: %s",
"horizon.friends.title": "Jolastu Lagunekin",
"horizon.friends.hint": "Lagunek itsatsi dute kodea izenburu-pantailaren Lagunak botoian, Zuzeneko konexioan edo abiarazlearen Play orrian.",
"horizon.friends.copied": "Kopiatua",
"horizon.friends.copy_code": "Kopiatu kodea",
"horizon.friends.stop_sharing": "Utzi partekatzeari",
"horizon.friends.none_yet": "Hemen oraindik ez dago lagunik.",
"horizon.friends.here_now": "Hemen orain: %s",
"horizon.friends.share_intro": "Partekatu mundu hau gonbidapen-kode baten atzean: LANera irekitzen da eta Interneteko lagunak bat egiten dute kodearekin. Ez dago gure zerbitzaririk parte hartzen.",
"horizon.friends.start_sharing": "Hasi Partekatzen",
"horizon.friends.troubleshooting": "Arazoak konpontzen...",
"horizon.friends.stopped": "Gonbidapen kodea partekatzeari utzi dio.",
"horizon.friends.short_code_changed": "Norbaitek kode labur oker bat probatzen jarraitu zuen, beraz, aldatu egin zen.",
"horizon.friends.wrong_code": "Norbaitek kode oker batekin saiatu da.",
"horizon.friends.relay_stopped": "Kode labur oker gehiegi, beraz erreleboa itzalita dago berriro partekatu arte. Lagunek kode osoarekin sartzen jarrai dezakete.",
"horizon.friends.code_ready": "Zure gonbidapen-kodea prest dago: Jokoen menua, Jolastu lagunekin. Kopiatu eta bidali zure lagunei.",
"horizon.friends.share_failed": "Ezin izan da mundua partekatu: %s",
"horizon.friends.finding_route": "Lagunak zurekin nola irits daitezkeen aurkitzen...",
"horizon.osk.shift": "Txanda",
"horizon.osk.space": "Espazioa",
"horizon.osk.delete": "Ezabatu",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "برای اعمال %s بازی را دوباره راه‌اندازی کنید: آن‌ها بلوک، آیتم یا تولید دنیا می‌افزایند.",
"horizon.shader_options.profile": "نمایه",
"horizon.agent.copy_details": "کپی جزئیات اتصال",
"horizon.multiplayer.join": "بپیوندید",
"horizon.multiplayer.cancel_reconnecting": "لغو اتصال مجدد",
"horizon.multiplayer.join_friend": "به یک دوست بپیوندید",
"horizon.multiplayer.invite_code": "کد دعوت",
"horizon.multiplayer.relay": "رله (اختیاری)",
"Preset": "از پیش تعیین شده",
"LOD Distance": "فاصله LOD",
"Far Detail": "جزئیات دور",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "پروفایل های آفلاین متعلق به این نمونه است",
"horizon.splitscreen.profiles.move": "جایی که بازی می کنید حرکت کنید",
"horizon.splitscreen.profiles.move_place": "جایی را که بازی می کنید حرکت دهید: %s",
"horizon.friends.title": "با دوستان بازی کنید",
"horizon.friends.hint": "دوستان کد را بعد از دکمه Friends صفحه عنوان، در اتصال مستقیم یا در صفحه Play راه‌انداز قرار می‌دهند.",
"horizon.friends.copied": "کپی شده است",
"horizon.friends.copy_code": "کد را کپی کنید",
"horizon.friends.stop_sharing": "اشتراک گذاری را متوقف کنید",
"horizon.friends.none_yet": "هنوز دوستی اینجا نیست",
"horizon.friends.here_now": "اکنون اینجا: %s",
"horizon.friends.share_intro": "این دنیا را پشت یک کد دعوت به اشتراک بگذارید: به شبکه LAN باز می شود و دوستانی که در اینترنت هستند با این کد ملحق می شوند. هیچ سروری از ما درگیر نیست.",
"horizon.friends.start_sharing": "اشتراک گذاری را شروع کنید",
"horizon.friends.troubleshooting": "عیب یابی ...",
"horizon.friends.stopped": "اشتراک‌گذاری کد دعوت متوقف شد.",
"horizon.friends.short_code_changed": "کسی مدام یک کد کوتاه اشتباه را امتحان می‌کرد، بنابراین تغییر کرد.",
"horizon.friends.wrong_code": "شخصی کد اشتباهی را امتحان کرده است.",
"horizon.friends.relay_stopped": "کدهای کوتاه اشتباه بیش از حد بود، بنابراین رله تا اشتراک‌گذاری دوباره خاموش است. دوستان هنوز می‌توانند با کد کامل بپیوندند.",
"horizon.friends.code_ready": "کد دعوت شما آماده است: منوی بازی، بازی با دوستان. کپی کنید و برای دوستانتان ارسال کنید.",
"horizon.friends.share_failed": "نمی توان جهان را به اشتراک گذاشت: %s",
"horizon.friends.finding_route": "یافتن نحوه ارتباط دوستان با شما…",
"horizon.osk.shift": "شیفت",
"horizon.osk.space": "فضا",
"horizon.osk.delete": "حذف کنید",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Käynnistä peli uudelleen, jotta %s tulee voimaan: ne lisäävät lohkoja, esineitä tai maailmanluontia.",
"horizon.shader_options.profile": "Profiili",
"horizon.agent.copy_details": "Kopioi yhteystiedot",
"horizon.multiplayer.join": "Liity",
"horizon.multiplayer.cancel_reconnecting": "Peruuta yhteyden muodostaminen",
"horizon.multiplayer.join_friend": "Liity ystäväksi",
"horizon.multiplayer.invite_code": "Kutsukoodi",
"horizon.multiplayer.relay": "Rele (valinnainen)",
"Preset": "Esiasetus",
"LOD Distance": "LOD Etäisyys",
"Far Detail": "Kaukainen yksityiskohta",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline-profiilit kuuluvat tähän esiintymään",
"horizon.splitscreen.profiles.move": "Muuta missä pelaat",
"horizon.splitscreen.profiles.move_place": "Siirry paikkaan, jossa pelaat: %s",
"horizon.friends.title": "Pelaa ystävien kanssa",
"horizon.friends.hint": "Ystävät liittävät koodin otsikkonäytön Ystävät-painikkeen perään, Suoraan yhteyteen tai käynnistysohjelman Play-sivulle.",
"horizon.friends.copied": "Kopioitu",
"horizon.friends.copy_code": "Kopioi koodi",
"horizon.friends.stop_sharing": "Lopeta jakaminen",
"horizon.friends.none_yet": "Täällä ei ole vielä ystäviä.",
"horizon.friends.here_now": "Täällä nyt: %s",
"horizon.friends.share_intro": "Jaa tämä maailma kutsukoodin takana: se avautuu lähiverkkoon, ja ystävät Internetissä liittyvät koodin avulla. Yksikään palvelin ei ole mukana.",
"horizon.friends.start_sharing": "Aloita jakaminen",
"horizon.friends.troubleshooting": "Vianmääritys...",
"horizon.friends.stopped": "Kutsukoodin jakaminen lopetettiin.",
"horizon.friends.short_code_changed": "Joku yritti jatkuvasti käyttää väärää lyhytkoodia, joten se muuttui.",
"horizon.friends.wrong_code": "Joku yritti väärää koodia.",
"horizon.friends.relay_stopped": "Liian monta väärää lyhytkoodia, joten rele on pois päältä, kunnes jaat uudelleen. Ystävät voivat yhä liittyä täydellä koodilla.",
"horizon.friends.code_ready": "Kutsukoodisi on valmis: Pelivalikko, Pelaa ystävien kanssa. Kopioi se ja lähetä se ystävillesi.",
"horizon.friends.share_failed": "Ei voinut jakaa maailmaa: %s",
"horizon.friends.finding_route": "Selvitetään, kuinka ystävät voivat tavoittaa sinut…",
"horizon.osk.shift": "Vaihto",
"horizon.osk.space": "Avaruus",
"horizon.osk.delete": "Poista",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "I-restart ang laro para mailapat ang %s: nagdadagdag sila ng mga bloke, item o pagbuo ng mundo.",
"horizon.shader_options.profile": "Profile",
"horizon.agent.copy_details": "Kopyahin ang Mga Detalye ng Koneksyon",
"horizon.multiplayer.join": "Sumali",
"horizon.multiplayer.cancel_reconnecting": "Kanselahin ang Muling Pagkonekta",
"horizon.multiplayer.join_friend": "Sumali sa isang Kaibigan",
"horizon.multiplayer.invite_code": "Code ng Imbitasyon",
"horizon.multiplayer.relay": "Relay (opsyonal)",
"Preset": "Preset",
"LOD Distance": "LOD Distansya",
"Far Detail": "Malayong Detalye",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Ang mga offline na profile ay kabilang sa pagkakataong ito",
"horizon.splitscreen.profiles.move": "Lumipat kung saan ka naglalaro",
"horizon.splitscreen.profiles.move_place": "Ilipat kung saan ka naglalaro: %s",
"horizon.friends.title": "Makipaglaro sa Kaibigan",
"horizon.friends.hint": "I-paste ng mga kaibigan ang code pagkatapos ng button na Friends ng screen ng pamagat, sa Direktang Koneksyon, o sa Play page ng launcher.",
"horizon.friends.copied": "Kinopya",
"horizon.friends.copy_code": "Kopyahin ang Code",
"horizon.friends.stop_sharing": "Itigil ang Pagbabahagi",
"horizon.friends.none_yet": "Wala pang kaibigan dito.",
"horizon.friends.here_now": "Narito ngayon: %s",
"horizon.friends.share_intro": "Ibahagi ang mundong ito sa likod ng code ng imbitasyon: bumubukas ito sa LAN, at sumali ang mga kaibigan sa internet gamit ang code. Walang kasamang server namin.",
"horizon.friends.start_sharing": "Simulan ang Pagbabahagi",
"horizon.friends.troubleshooting": "Pag-troubleshoot...",
"horizon.friends.stopped": "Huminto sa pagbabahagi ng code ng imbitasyon.",
"horizon.friends.short_code_changed": "May sumubok ng maling maikling code, kaya nagbago ito.",
"horizon.friends.wrong_code": "May sumubok ng maling code.",
"horizon.friends.relay_stopped": "Napakaraming maling short code, kaya naka-off ang relay hanggang magbahagi ka muli. Nakakasali pa rin ang mga kaibigan gamit ang buong code.",
"horizon.friends.code_ready": "Handa na ang iyong code ng imbitasyon: Game Menu, Play with Friends. Kopyahin ito at ipadala sa iyong mga kaibigan.",
"horizon.friends.share_failed": "Hindi maibahagi ang mundo: %s",
"horizon.friends.finding_route": "Hinahanap kung paano ka maaabot ng mga kaibigan...",
"horizon.osk.shift": "Paglipat",
"horizon.osk.space": "kalawakan",
"horizon.osk.delete": "Tanggalin",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Endurbyrja spælið fyri at nýta %s: tey leggja afturat blokkar, lutir ella heimsframleiðslu.",
"horizon.shader_options.profile": "Vangamynd",
"horizon.agent.copy_details": "Avrita sambandsupplýsingar",
"horizon.multiplayer.join": "Ver við",
"horizon.multiplayer.cancel_reconnecting": "Avlýs aftursamband",
"horizon.multiplayer.join_friend": "Melda teg til ein vin",
"horizon.multiplayer.invite_code": "Boðkoda",
"horizon.multiplayer.relay": "Stafett (valfrítt)",
"Preset": "Forsett",
"LOD Distance": "LOD Fjarstøða",
"Far Detail": "Fjar Detalja",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline profilar hoyra til hesa instans",
"horizon.splitscreen.profiles.move": "Flyt har tú spælir",
"horizon.splitscreen.profiles.move_place": "Flyt har tú spælir: %s .",
"horizon.friends.title": "Spæl við vinum",
"horizon.friends.hint": "Vinir líma kotuna eftir Vinir knøttin á tittulskjálvtanum, í Beinleiðis sambandi ella á Spæla síðuni á sjósetaranum.",
"horizon.friends.copied": "Avritað",
"horizon.friends.copy_code": "Avritskotu",
"horizon.friends.stop_sharing": "Steðga við at deila",
"horizon.friends.none_yet": "Eingir vinir her enn.",
"horizon.friends.here_now": "Her nú: %s",
"horizon.friends.share_intro": "Deil hendan heimin aftanfyri eina boðkotu: hon letur upp fyri LAN, og vinir á internetinum melda seg til við koduni. Eingin servari hjá okkum er við.",
"horizon.friends.start_sharing": "Byrja at deila",
"horizon.friends.troubleshooting": "Trupulleikaloysn...",
"horizon.friends.stopped": "Steðgaði við at deila boðkotuna.",
"horizon.friends.short_code_changed": "Onkur bleiv við at royna eina skeiva stutta kodu, so hon broyttist.",
"horizon.friends.wrong_code": "Onkur royndi eina skeiva kodu.",
"horizon.friends.relay_stopped": "Ov nógvar skeivar stuttkour, so stafettin er sløkt, til tú deilir aftur. Vinir kunnu framvegis melda seg til við fullu koduni.",
"horizon.friends.code_ready": "Tín boðkoda er klár: Spælvalmynd, Spæl við vinum. Kopiera tað og send tað til tínar vinir.",
"horizon.friends.share_failed": "Kundi ikki deila heimin: %s",
"horizon.friends.finding_route": "At finna útav, hvussu vinir kunnu røkka tær...",
"horizon.osk.shift": "Vakt",
"horizon.osk.space": "Rúm",
"horizon.osk.delete": "Strika",
@@ -208,7 +208,11 @@
"horizon.mods.switch.restart": "Redémarre le jeu pour appliquer %s : ils ajoutent des blocs, des objets ou de la génération de monde.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Copier les détails de connexion",
"horizon.multiplayer.join": "Rejoindre",
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
"horizon.multiplayer.join_friend": "Rejoindre un ami",
"horizon.multiplayer.invite_code": "Code d'invitation",
"horizon.multiplayer.relay": "Relais (facultatif)",
"Preset": "Préréglage",
"LOD Distance": "Distance LOD",
"Far Detail": "Détails lointains",
@@ -298,6 +302,7 @@
"horizon.extras.zoom_mod_install": "Zoom : à installer depuis Mods",
"horizon.extras.pack_browser": "Navigateur de packs de ressources",
"horizon.extras.music_page.tooltip": "Musique et sons gagne une page Musique... : lancez n'importe quelle piste intégrée maintenant, ou désactivez-en une pour que le jeu arrête de la choisir. Rien ne change tant que vous ne désactivez pas une piste.",
"horizon.friends.relay_stopped": "Trop de codes courts erronés : le relais est désactivé jusqu'à ce que vous partagiez à nouveau. Vos amis peuvent toujours rejoindre avec le code complet.",
"horizon.extras.pack_browser.tooltip": "La sélection des packs de ressources gagne un bouton Ajouter qui cherche sur Modrinth et installe un pack dans votre dossier de packs. Désactivé, le jeu ne contacte jamais Modrinth.",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"Opaque Ice": "Glace opaque",
@@ -427,6 +432,22 @@
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
"horizon.friends.title": "Jouer avec des amis",
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
"horizon.friends.copied": "Copié",
"horizon.friends.copy_code": "Copier le code",
"horizon.friends.stop_sharing": "Arrêter le partage",
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
"horizon.friends.here_now": "Présents : %s",
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
"horizon.friends.start_sharing": "Commencer le partage",
"horizon.friends.troubleshooting": "Dépannage...",
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
"horizon.osk.shift": "Maj.",
"horizon.osk.space": "Espace",
"horizon.osk.delete": "Effacer",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Torne a inviâ il zûc par aplicâ %s: a zontin blocs, ogjets o generazion dal mont.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Copiâ i detais de conession",
"horizon.multiplayer.join": "Iscrivitvi",
"horizon.multiplayer.cancel_reconnecting": "Annulâ la riconession",
"horizon.multiplayer.join_friend": "Iscrivitvi a un amì",
"horizon.multiplayer.invite_code": "Codiç di invit",
"horizon.multiplayer.relay": "Relê (opzionâl)",
"Preset": "Preset",
"LOD Distance": "Distance LOD",
"Far Detail": "Detai lontan",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "I profîi fûr linie a apartegnin a cheste esemplificazion",
"horizon.splitscreen.profiles.move": "Spostâsi dulà che tu zuiis",
"horizon.splitscreen.profiles.move_place": "Spostâsi dulà che si zuie: %s",
"horizon.friends.title": "Zuegâ cui amîs",
"horizon.friends.hint": "I amîs a incolin il codiç dopo dal botôn Amîs de scherme dal titul, in Conession direte, o su la pagjine Play dal lançadôr.",
"horizon.friends.copied": "Copiât",
"horizon.friends.copy_code": "Codiç di copie",
"horizon.friends.stop_sharing": "Fermâ di condividi",
"horizon.friends.none_yet": "Ancjemò nissun amì culì.",
"horizon.friends.here_now": "Eco cumò: %s",
"horizon.friends.share_intro": "Condividi chest mont daûr di un codiç di invit: al si vierç ae LAN, e i amîs su internet a si unissin cul codiç. Nissun nestri servidôr al è coinvolt.",
"horizon.friends.start_sharing": "Scomencia a condividi",
"horizon.friends.troubleshooting": "Risoluzion dai problemis...",
"horizon.friends.stopped": "Fermât di condividi il codiç di invit.",
"horizon.friends.short_code_changed": "Cualchidun al continuave a provâ un codiç curt sbalît, cussì al cambià.",
"horizon.friends.wrong_code": "Cualchidun al à provât un codiç sbalît.",
"horizon.friends.relay_stopped": "Masse codiçs curts sbalâts, cussì il relê al è spent fin che no tu condividis di gnûf. I amîs a puedin ancjemò jentrâ cul codiç complet.",
"horizon.friends.code_ready": "Il to codiç di invit al è pront: Menu dal zûc, Zugâ cui amîs. Copielu e mandâlu ai tois amiis.",
"horizon.friends.share_failed": "No rivave a condividi il mont: %s",
"horizon.friends.finding_route": "Cjatâ cemût che i amîs a puedin rivâ a te...",
"horizon.osk.shift": "Cambi",
"horizon.osk.space": "Spazi",
"horizon.osk.delete": "Elimina",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Start it spul opnij om %s tapasse te litten: se foegje blokken, items of wrâldgeneraasje ta.",
"horizon.shader_options.profile": "Profyl",
"horizon.agent.copy_details": "Kopiearje Ferbiningsdetails",
"horizon.multiplayer.join": "Meidwaan",
"horizon.multiplayer.cancel_reconnecting": "Ofbrekke opnij ferbinen",
"horizon.multiplayer.join_friend": "Doch mei oan in freon",
"horizon.multiplayer.invite_code": "Koade útnoegje",
"horizon.multiplayer.relay": "Relais (opsjoneel)",
"Preset": "Foarynsteld",
"LOD Distance": "LOD Ofstân",
"Far Detail": "Fier Detail",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Offline profilen hearre ta dit eksimplaar",
"horizon.splitscreen.profiles.move": "Ferpleats wêr't jo spylje",
"horizon.splitscreen.profiles.move_place": "Ferpleats wêr't jo spylje: %s",
"horizon.friends.title": "Boartsje mei freonen",
"horizon.friends.hint": "Freonen plakke de koade nei de knop Freonen fan it titelskerm, yn Direkte ferbining, of op 'e Play-side fan 'e launcher.",
"horizon.friends.copied": "Kopiearre",
"horizon.friends.copy_code": "Kopiearje Code",
"horizon.friends.stop_sharing": "Stopje mei dielen",
"horizon.friends.none_yet": "Gjin freonen hjir noch.",
"horizon.friends.here_now": "Hjir no: %s",
"horizon.friends.share_intro": "Diel dizze wrâld efter in útnoegingskoade: it iepenet nei LAN, en freonen op it ynternet dogge mei mei de koade. Gjin tsjinner fan ús is belutsen.",
"horizon.friends.start_sharing": "Begjin te dielen",
"horizon.friends.troubleshooting": "Problemen oplosse...",
"horizon.friends.stopped": "Stoppe mei it dielen fan de útnoegingskoade.",
"horizon.friends.short_code_changed": "Immen besocht hieltyd in ferkearde koarte koade, dus it feroare.",
"horizon.friends.wrong_code": "Immen besocht in ferkearde koade.",
"horizon.friends.relay_stopped": "Te folle ferkearde koadekoarten, dus de estafette stiet út oant jo wer diele. Freonen kinne noch altyd mei de folsleine koade meidwaan.",
"horizon.friends.code_ready": "Jo útnoegingskoade is klear: Game Menu, Boartsje mei freonen. Kopiearje it en stjoer it nei jo freonen.",
"horizon.friends.share_failed": "Koe de wrâld net diele: %s",
"horizon.friends.finding_route": "Fyn hoe't freonen jo kinne berikke ...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Romte",
"horizon.osk.delete": "Wiskje",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Atosaigh an cluiche chun %s a chur i bhfeidhm: cuireann siad bloic, míreanna nó giniúint domhain leis.",
"horizon.shader_options.profile": "Próifíl",
"horizon.agent.copy_details": "Cóipeáil Sonraí Ceangail",
"horizon.multiplayer.join": "Bígí",
"horizon.multiplayer.cancel_reconnecting": "Cealaigh Athnascadh",
"horizon.multiplayer.join_friend": "Bígí le Cara",
"horizon.multiplayer.invite_code": "Cód Cuireadh",
"horizon.multiplayer.relay": "Sealaíocht (roghnach)",
"Preset": "Réamhshocraithe",
"LOD Distance": "LOD Fad",
"Far Detail": "Far Sonraigh",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Baineann próifílí as líne leis an gcás seo",
"horizon.splitscreen.profiles.move": "Bog áit a imríonn tú",
"horizon.splitscreen.profiles.move_place": "Bog áit a imríonn tú: %s",
"horizon.friends.title": "Seinn le Cairde",
"horizon.friends.hint": "Greamaigh Cairde an cód tar éis cnaipe Cairde an scáileáin teidil, i Ceangal Díreach, nó ar leathanach Súgartha an Tosaitheora.",
"horizon.friends.copied": "Cóipeáladh",
"horizon.friends.copy_code": "Cóipeáil Cód",
"horizon.friends.stop_sharing": "Stop Roinnt",
"horizon.friends.none_yet": "Níl aon chairde anseo fós.",
"horizon.friends.here_now": "Anseo anois: %s",
"horizon.friends.share_intro": "Roinn an domhan seo taobh thiar de chód cuirí: osclaíonn sé go LAN, agus glacann cairde ar an idirlíon leis an gcód. Níl aon fhreastalaí dár gcuid i gceist.",
"horizon.friends.start_sharing": "Tosaigh ag Roinnt",
"horizon.friends.troubleshooting": "Fabhtcheartú...",
"horizon.friends.stopped": "Stopadh an cód cuirí a roinnt.",
"horizon.friends.short_code_changed": "Lean duine éigin ag iarraidh cód gearr mícheart, mar sin d'athraigh sé.",
"horizon.friends.wrong_code": "Bhain duine éigin triail as cód mícheart.",
"horizon.friends.relay_stopped": "An iomarca cóid ghearra míchearta, mar sin tá an sealaíocht as go dtí go roinnfidh tú arís. Is féidir le cairde teacht isteach fós leis an gcód iomlán.",
"horizon.friends.code_ready": "Tá do chód cuireadh réidh: Roghchlár Cluiche, Spraoi le Cairde. Cóipeáil é agus seol chuig do chairde é.",
"horizon.friends.share_failed": "Níorbh fhéidir an domhan a roinnt: %s",
"horizon.friends.finding_route": "Ag fáil amach conas is féidir le cairde dul i dteagmháil leat…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Spás",
"horizon.osk.delete": "Scrios",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Ath-thòisich an geama gus %s a chur an sàs: tha iad a' cur blocaichean, nithean no gineadh saoghail ris.",
"horizon.shader_options.profile": "Pròifil",
"horizon.agent.copy_details": "Dèan lethbhreac den fhiosrachadh ceangail",
"horizon.multiplayer.join": "Thig còmhla",
"horizon.multiplayer.cancel_reconnecting": "Sguir dhen ath-cheangal",
"horizon.multiplayer.join_friend": "Thig còmhla ri Caraid",
"horizon.multiplayer.invite_code": "Còd cuireadh",
"horizon.multiplayer.relay": "Ath-chraoladh (roghainneil)",
"Preset": "Ro-shuidhichte",
"LOD Distance": "Astar LOD",
"Far Detail": "Fada mionaideach",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Buinidh pròifilean far-loidhne don t-suidheachadh seo",
"horizon.splitscreen.profiles.move": "Gluais far a bheil thu a 'cluich",
"horizon.splitscreen.profiles.move_place": "Gluais far a bheil thu a’ cluich: %s",
"horizon.friends.title": "Cluich le caraidean",
"horizon.friends.hint": "Bidh caraidean a’ cuir a-steach an còd às deidh putan Caraidean an sgrion tiotal, ann an Direct Connection, no air duilleag Cluich an lannsair.",
"horizon.friends.copied": "Rinn lethbhreac",
"horizon.friends.copy_code": "Dèan lethbhreac den Chòd",
"horizon.friends.stop_sharing": "Stad air roinneadh",
"horizon.friends.none_yet": "Chan eil caraidean an seo fhathast.",
"horizon.friends.here_now": "Seo agaibh a-nis: %s",
"horizon.friends.share_intro": "Roinn an saoghal seo air cùl còd cuireadh: bidh e a’ fosgladh gu LAN, agus bidh caraidean air an eadar-lìn a’ tighinn còmhla ris a’ chòd. Chan eil seirbheisiche sam bith againn an sàs.",
"horizon.friends.start_sharing": "Tòisich a 'roinneadh",
"horizon.friends.troubleshooting": "Fuasgladh thrioblaidean...",
"horizon.friends.stopped": "Sguir a bhith a’ roinneadh a’ chòd cuireadh.",
"horizon.friends.short_code_changed": "Chùm cuideigin a’ feuchainn còd goirid ceàrr, agus mar sin dh’atharraich e.",
"horizon.friends.wrong_code": "Dh’fheuch cuideigin còd ceàrr.",
"horizon.friends.relay_stopped": "Cus de chòdan goirid ceàrr, agus mar sin tha an t-sealaidheachd dheth gus an roinn thu a-rithist. Faodaidh caraidean a dhol còmhla leis a' chòd slàn fhathast.",
"horizon.friends.code_ready": "Tha an còd cuireadh agad deiseil: Clàr Gèam, Cluich le Caraidean. Dèan lethbhreac dheth agus cuir gu do charaidean e.",
"horizon.friends.share_failed": "Cha b’ urrainn dhuinn an saoghal a cho-roinn: %s",
"horizon.friends.finding_route": "Faigh a-mach mar as urrainn do charaidean ruighinn ort…",
"horizon.osk.shift": "Gluasad",
"horizon.osk.space": "Àite",
"horizon.osk.delete": "Sguab às",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Reinicia o xogo para aplicar %s: engaden bloques, obxectos ou xeración do mundo.",
"horizon.shader_options.profile": "Perfil",
"horizon.agent.copy_details": "Copiar os detalles da conexión",
"horizon.multiplayer.join": "Únete",
"horizon.multiplayer.cancel_reconnecting": "Cancelar a conexión",
"horizon.multiplayer.join_friend": "Únete a un amigo",
"horizon.multiplayer.invite_code": "Código de invitación",
"horizon.multiplayer.relay": "Relé (opcional)",
"Preset": "Predefinido",
"LOD Distance": "Distancia LOD",
"Far Detail": "Detalle lonxano",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Os perfís sen conexión pertencen a esta instancia",
"horizon.splitscreen.profiles.move": "Móvete onde xogas",
"horizon.splitscreen.profiles.move_place": "Move onde xogas: %s",
"horizon.friends.title": "Xoga cos amigos",
"horizon.friends.hint": "Os amigos pegan o código despois do botón Amigos da pantalla de título, na Conexión directa ou na páxina Play do iniciador.",
"horizon.friends.copied": "Copiouse",
"horizon.friends.copy_code": "Copiar código",
"horizon.friends.stop_sharing": "Deixa de compartir",
"horizon.friends.none_yet": "Aínda non hai amigos aquí.",
"horizon.friends.here_now": "Aquí agora: %s",
"horizon.friends.share_intro": "Comparte este mundo detrás dun código de invitación: ábrese a LAN e os amigos de Internet únense co código. Ningún servidor noso está implicado.",
"horizon.friends.start_sharing": "Comeza a compartir",
"horizon.friends.troubleshooting": "Solución de problemas...",
"horizon.friends.stopped": "Deixou de compartir o código de invitación.",
"horizon.friends.short_code_changed": "Alguén seguiu probando un código curto incorrecto, polo que cambiou.",
"horizon.friends.wrong_code": "Alguén intentou un código incorrecto.",
"horizon.friends.relay_stopped": "Demasiados códigos curtos incorrectos, polo que o relé está desactivado ata que volvas compartir. Os amigos aínda poden unirse co código completo.",
"horizon.friends.code_ready": "O teu código de invitación está listo: Menú Xogo, Xoga cos amigos. Cópiao e envíao aos teus amigos.",
"horizon.friends.share_failed": "Non se puido compartir o mundo: %s",
"horizon.friends.finding_route": "Buscando como os amigos poden contactar contigo...",
"horizon.osk.shift": "Cambio",
"horizon.osk.space": "Espazo",
"horizon.osk.delete": "Eliminar",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "E hoʻomaka hou i ka pāʻani e hoʻohana iā %s: hoʻohui lākou i nā poloka, nā mea a i ʻole ka hana honua.",
"horizon.shader_options.profile": "Pilikino",
"horizon.agent.copy_details": "E kope i nā kikoʻī pili",
"horizon.multiplayer.join": "Hui",
"horizon.multiplayer.cancel_reconnecting": "Hoʻopau i ka hoʻohui hou ʻana",
"horizon.multiplayer.join_friend": "E hui me kahi Hoa",
"horizon.multiplayer.invite_code": "Code kono",
"horizon.multiplayer.relay": "Relay (koho)",
"Preset": "Hoʻonohonoho mua",
"LOD Distance": "LOD mamao",
"Far Detail": "ʻIke lōʻihi",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "No kēia laʻana nā ʻaoʻao pahemo",
"horizon.splitscreen.profiles.move": "E neʻe i kahi āu e pāʻani ai",
"horizon.splitscreen.profiles.move_place": "E neʻe i kahi āu e pāʻani ai: %s",
"horizon.friends.title": "E pāʻani me nā hoa",
"horizon.friends.hint": "Hoʻopili nā hoa i ke code ma hope o ke pihi Hoa o ka pale poʻo inoa, ma Direct Connection, a i ʻole ma ka ʻaoʻao Play o ka mea hoʻolaha.",
"horizon.friends.copied": "Kope ʻia",
"horizon.friends.copy_code": "Kāhea kope",
"horizon.friends.stop_sharing": "Hooki i ka Kaʻana like",
"horizon.friends.none_yet": "ʻAʻohe hoa ma ʻaneʻi.",
"horizon.friends.here_now": "Eia i kēia manawa: %s",
"horizon.friends.share_intro": "E kaʻana like i kēia ao ma hope o kahi code kono: wehe ia i LAN, a hui pū nā hoaaloha ma ka pūnaewele me ke code. ʻAʻohe kikowaena o kā mākou pili.",
"horizon.friends.start_sharing": "Hoʻomaka Kaʻana like",
"horizon.friends.troubleshooting": "Hoʻoponopono pilikia...",
"horizon.friends.stopped": "Ua ho'ōki i ka hāʻawi ʻana i ke code kono.",
"horizon.friends.short_code_changed": "Ke hoʻāʻo nei kekahi i kahi code pōkole hewa, no laila ua loli.",
"horizon.friends.wrong_code": "Ua hoʻāʻo kekahi i kahi code hewa.",
"horizon.friends.relay_stopped": "Nui loa nā code pōkole hewa, no laila ua paʻa ka relay a hiki i kou kaʻana hou ʻana. Hiki nō i nā hoaaloha ke hui me ka code piha.",
"horizon.friends.code_ready": "Ua mākaukau kāu code kono: Game Menu, Play with Friends. E kope a hoʻouna aku i kāu mau hoaaloha.",
"horizon.friends.share_failed": "ʻAʻole hiki ke kaʻana like i ka honua: %s",
"horizon.friends.finding_route": "Ke ʻimi nei pehea e hiki ai i nā hoaaloha ke loaʻa iā ʻoe…",
"horizon.osk.shift": "Hoʻololi",
"horizon.osk.space": "Pā Hakahaka",
"horizon.osk.delete": "Holoi",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "הפעל מחדש את המשחק כדי להחיל %s: הם מוסיפים בלוקים, פריטים או יצירת עולם.",
"horizon.shader_options.profile": "פרופיל",
"horizon.agent.copy_details": "העתק את פרטי החיבור",
"horizon.multiplayer.join": "הצטרף",
"horizon.multiplayer.cancel_reconnecting": "בטל חיבור מחדש",
"horizon.multiplayer.join_friend": "הצטרף לחבר",
"horizon.multiplayer.invite_code": "קוד הזמנה",
"horizon.multiplayer.relay": "ממסר (אופציונלי)",
"Preset": "מוגדר מראש",
"LOD Distance": "מרחק LOD",
"Far Detail": "פרט רחוק",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "פרופילים לא מקוונים שייכים למופע הזה",
"horizon.splitscreen.profiles.move": "זז לאן שאתה משחק",
"horizon.splitscreen.profiles.move_place": "זז לאן שאתה משחק: %s",
"horizon.friends.title": "שחקו עם חברים",
"horizon.friends.hint": "חברים מדביקים את הקוד אחרי כפתור החברים של מסך הכותרת, בחיבור ישיר או בדף ההפעלה של המשגר.",
"horizon.friends.copied": "הועתק",
"horizon.friends.copy_code": "העתק קוד",
"horizon.friends.stop_sharing": "תפסיק לשתף",
"horizon.friends.none_yet": "אין כאן חברים עדיין.",
"horizon.friends.here_now": "כאן עכשיו: %s",
"horizon.friends.share_intro": "שתף את העולם הזה מאחורי קוד הזמנה: הוא נפתח ל-LAN, וחברים באינטרנט מצטרפים עם הקוד. אף שרת שלנו לא מעורב.",
"horizon.friends.start_sharing": "התחל לשתף",
"horizon.friends.troubleshooting": "פתרון בעיות...",
"horizon.friends.stopped": "הפסיק לשתף את קוד ההזמנה.",
"horizon.friends.short_code_changed": "מישהו כל הזמן ניסה קוד קצר שגוי, אז זה השתנה.",
"horizon.friends.wrong_code": "מישהו ניסה קוד שגוי.",
"horizon.friends.relay_stopped": "יותר מדי קודים קצרים שגויים, ולכן הממסר כבוי עד שתשתף שוב. חברים עדיין יכולים להצטרף עם הקוד המלא.",
"horizon.friends.code_ready": "קוד ההזמנה שלך מוכן: תפריט משחק, שחק עם חברים. העתק אותו ושלח אותו לחברים שלך.",
"horizon.friends.share_failed": "לא יכולתי לשתף את העולם: %s",
"horizon.friends.finding_route": "מוצא כיצד חברים יכולים להגיע אליך...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "חלל",
"horizon.osk.delete": "מחק",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "%s लागू करने के लिए गेम रीस्टार्ट करें: वे ब्लॉक, आइटम या विश्व जनरेशन जोड़ते हैं।",
"horizon.shader_options.profile": "प्रोफ़ाइल",
"horizon.agent.copy_details": "कनेक्शन विवरण कॉपी करें",
"horizon.multiplayer.join": "शामिल हों",
"horizon.multiplayer.cancel_reconnecting": "पुनः कनेक्ट करना रद्द करें",
"horizon.multiplayer.join_friend": "मित्र से जुड़ें",
"horizon.multiplayer.invite_code": "आमंत्रण कोड",
"horizon.multiplayer.relay": "रिले (वैकल्पिक)",
"Preset": "प्रीसेट",
"LOD Distance": "LOD दूरी",
"Far Detail": "दूर के विवरण",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "ऑफ़लाइन प्रोफ़ाइल इस इंस्टेंस के हैं",
"horizon.splitscreen.profiles.move": "स्थानांतरित करें जहाँ आप खेलते हैं",
"horizon.splitscreen.profiles.move_place": "स्थानांतरित करें जहाँ आप खेलते हैं: %s",
"horizon.friends.title": "मित्रों के साथ खेलें",
"horizon.friends.hint": "मित्र शीर्षक स्क्रीन के फ्रेंड्स बटन के बाद, डायरेक्ट कनेक्शन में, या लॉन्चर के प्ले पेज पर कोड पेस्ट करते हैं।",
"horizon.friends.copied": "कॉपी किया गया",
"horizon.friends.copy_code": "कोड कॉपी करें",
"horizon.friends.stop_sharing": "साझा करना बंद करें",
"horizon.friends.none_yet": "यहाँ अभी कोई मित्र नहीं है।",
"horizon.friends.here_now": "अभी यहाँ हैं: %s",
"horizon.friends.share_intro": "इस दुनिया को आमंत्रण कोड के पीछे साझा करें: यह LAN के लिए खुलता है, और इंटरनेट पर मित्र कोड के साथ जुड़ते हैं। इसमें हमारा कोई सर्वर शामिल नहीं है।",
"horizon.friends.start_sharing": "साझा करना शुरू करें",
"horizon.friends.troubleshooting": "समस्या निवारण...",
"horizon.friends.stopped": "आमंत्रण कोड साझा करना बंद कर दिया।",
"horizon.friends.short_code_changed": "किसी ने बार-बार गलत शॉर्ट कोड आजमाया, इसलिए यह बदल गया।",
"horizon.friends.wrong_code": "किसी ने गलत कोड आजमाया।",
"horizon.friends.relay_stopped": "बहुत अधिक गलत शॉर्ट कोड, इसलिए दोबारा साझा करने तक रिले बंद है। मित्र अभी भी पूर्ण कोड से जुड़ सकते हैं।",
"horizon.friends.code_ready": "आपका आमंत्रण कोड तैयार है: गेम मेनू, प्ले विद फ्रेंड्स। इसे कॉपी करें और अपने मित्रों को भेजें।",
"horizon.friends.share_failed": "दुनिया साझा नहीं की जा सकी: %s",
"horizon.friends.finding_route": "ढूंढ रहे हैं कि मित्र आप तक कैसे पहुँच सकते हैं…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Space",
"horizon.osk.delete": "Delete",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Ponovno pokrenite igru da biste primijenili %s: dodaju blokove, predmete ili generiranje svijeta.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Kopiraj pojedinosti veze",
"horizon.multiplayer.join": "Pridruži se",
"horizon.multiplayer.cancel_reconnecting": "Otkaži ponovno povezivanje",
"horizon.multiplayer.join_friend": "Pridruži se prijatelju",
"horizon.multiplayer.invite_code": "Pozivni kod",
"horizon.multiplayer.relay": "Prijenos (neobavezno)",
"Preset": "Predložak",
"LOD Distance": "LOD udaljenost",
"Far Detail": "Udaljeni detalji",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Izvanmrežni profili pripadaju ovoj instanci",
"horizon.splitscreen.profiles.move": "Premjesti mjesto igranja",
"horizon.splitscreen.profiles.move_place": "Premjesti mjesto igranja: %s",
"horizon.friends.title": "Igraj s prijateljima",
"horizon.friends.hint": "Prijatelji lijepe kod nakon gumba Prijatelji na naslovnom zaslonu, u izravnom povezivanju ili na stranici za igranje pokretača.",
"horizon.friends.copied": "Kopirano",
"horizon.friends.copy_code": "Kopiraj kod",
"horizon.friends.stop_sharing": "Zaustavi dijeljenje",
"horizon.friends.none_yet": "Ovdje još nema prijatelja.",
"horizon.friends.here_now": "Ovdje sada: %s",
"horizon.friends.share_intro": "Podijelite ovaj svijet iza pozivnog koda: otvara se za LAN, a prijatelji na internetu pridružuju se s kodom. Nijedan naš poslužitelj nije uključen.",
"horizon.friends.start_sharing": "Započni dijeljenje",
"horizon.friends.troubleshooting": "Rješavanje problema...",
"horizon.friends.stopped": "Zaustavljeno dijeljenje pozivnog koda.",
"horizon.friends.short_code_changed": "Netko je stalno pokušavao unijeti pogrešan kratki kod, pa se on promijenio.",
"horizon.friends.wrong_code": "Netko je pokušao unijeti pogrešan kod.",
"horizon.friends.relay_stopped": "Previše pogrešnih kratkih kodova, pa je prijenos isključen dok ponovno ne podijelite. Prijatelji se još mogu pridružiti s punim kodom.",
"horizon.friends.code_ready": "Vaš pozivni kod je spreman: Izbornik igre, Igraj s prijateljima. Kopirajte ga i pošaljite svojim prijateljima.",
"horizon.friends.share_failed": "Nije moguće dijeliti svijet: %s",
"horizon.friends.finding_route": "Pronalaženje načina na koji vas prijatelji mogu dosegnuti…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Razmaknica",
"horizon.osk.delete": "Izbriši",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Indítsa újra a játékot a(z) %s érvényesítéséhez: blokkokat, tárgyakat vagy világgenerálást ad hozzá.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Másolja a kapcsolat részleteit",
"horizon.multiplayer.join": "Csatlakozz",
"horizon.multiplayer.cancel_reconnecting": "Az újracsatlakozás megszakítása",
"horizon.multiplayer.join_friend": "Csatlakozz egy baráthoz",
"horizon.multiplayer.invite_code": "Meghívó kód",
"horizon.multiplayer.relay": "Relé (opcionális)",
"Preset": "Előre beállított",
"LOD Distance": "LOD távolság",
"Far Detail": "Távoli részlet",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Az offline profilok ehhez a példányhoz tartoznak",
"horizon.splitscreen.profiles.move": "Menj oda, ahol játszol",
"horizon.splitscreen.profiles.move_place": "Menjen oda, ahol játszik: %s",
"horizon.friends.title": "Játssz a barátokkal",
"horizon.friends.hint": "Az ismerősök beillesztik a kódot a címképernyő Barátok gombja mögé, a Közvetlen kapcsolatba vagy az indító Play oldalára.",
"horizon.friends.copied": "Másolva",
"horizon.friends.copy_code": "Kód másolása",
"horizon.friends.stop_sharing": "Megosztás leállítása",
"horizon.friends.none_yet": "Még nincsenek itt barátok.",
"horizon.friends.here_now": "Itt most: %s",
"horizon.friends.share_intro": "Oszd meg ezt a világot egy meghívókód mögött: megnyílik a LAN-hoz, és az interneten lévő ismerősök is csatlakoznak a kódhoz. A mi szerverünk nem érintett.",
"horizon.friends.start_sharing": "Indítsa el a Megosztást",
"horizon.friends.troubleshooting": "Hibaelhárítás...",
"horizon.friends.stopped": "A meghívókód megosztása leállt.",
"horizon.friends.short_code_changed": "Valaki folyamatosan rossz rövid kóddal próbálkozott, ezért megváltozott.",
"horizon.friends.wrong_code": "Valaki rossz kóddal próbálkozott.",
"horizon.friends.relay_stopped": "Túl sok rossz rövid kód érkezett, ezért a relé ki van kapcsolva, amíg újra nem osztja meg. A barátok a teljes kóddal továbbra is csatlakozhatnak.",
"horizon.friends.code_ready": "A meghívókód készen áll: Játékmenü, Játssz a barátokkal. Másold ki és küldd el ismerőseidnek.",
"horizon.friends.share_failed": "Nem sikerült megosztani a világot: %s",
"horizon.friends.finding_route": "Megtudja, hogyan érhetnek el barátaid…",
"horizon.osk.shift": "Váltás",
"horizon.osk.space": "Tér",
"horizon.osk.delete": "Törölje",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Վերագործարկեք խաղը՝ %s-ը կիրառելու համար. դրանք ավելացնում են բլոկներ, իրեր կամ աշխարհի գեներացիա։",
"horizon.shader_options.profile": "Անձնագիր",
"horizon.agent.copy_details": "Պատճենել կապի մանրամասները",
"horizon.multiplayer.join": "Միանալ",
"horizon.multiplayer.cancel_reconnecting": "Չեղարկել վերամիացումը",
"horizon.multiplayer.join_friend": "Միացեք ընկերոջը",
"horizon.multiplayer.invite_code": "Հրավիրել կոդը",
"horizon.multiplayer.relay": "Ռելե (ըստ ցանկության)",
"Preset": "Նախադրված",
"LOD Distance": "LOD Հեռավորություն",
"Far Detail": "Հեռավոր մանրամասն",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Անցանց պրոֆիլները պատկանում են այս օրինակին",
"horizon.splitscreen.profiles.move": "Տեղափոխեք այնտեղ, որտեղ խաղում եք",
"horizon.splitscreen.profiles.move_place": "Տեղափոխեք այնտեղ, որտեղ խաղում եք՝ %s",
"horizon.friends.title": "Խաղացեք ընկերների հետ",
"horizon.friends.hint": "Ընկերները տեղադրում են կոդը վերնագրի էկրանի Ընկերներ կոճակից հետո, ուղիղ միացումում կամ գործարկողի Play էջում:",
"horizon.friends.copied": "Պատճենվել է",
"horizon.friends.copy_code": "Պատճենել կոդը",
"horizon.friends.stop_sharing": "Դադարեցնել համօգտագործումը",
"horizon.friends.none_yet": "Այստեղ դեռ ընկերներ չկան:",
"horizon.friends.here_now": "Ահա հիմա: %s",
"horizon.friends.share_intro": "Կիսվեք այս աշխարհով հրավերի կոդով. այն բացվում է LAN-ի համար, և ինտերնետում գտնվող ընկերները միանում են կոդով: Մեր ոչ մի սերվեր ներգրավված չէ:",
"horizon.friends.start_sharing": "Սկսեք համօգտագործել",
"horizon.friends.troubleshooting": "Անսարքությունների վերացում...",
"horizon.friends.stopped": "Դադարեցվել է հրապարակել հրավերի կոդը:",
"horizon.friends.short_code_changed": "Ինչ-որ մեկը շարունակում էր սխալ կարճ կոդ փորձել, ուստի այն փոխվեց:",
"horizon.friends.wrong_code": "Ինչ-որ մեկը սխալ կոդ է փորձել:",
"horizon.friends.relay_stopped": "Չափից շատ սխալ կարճ կոդեր, ուստի ռելեն անջատված է, մինչև նորից կիսվեք։ Ընկերները դեռ կարող են միանալ ամբողջական կոդով։",
"horizon.friends.code_ready": "Ձեր հրավերի կոդը պատրաստ է. Խաղի ընտրացանկ, Խաղացեք ընկերների հետ: Պատճենեք այն և ուղարկեք ձեր ընկերներին։",
"horizon.friends.share_failed": "Չեմ կարող կիսվել աշխարհով. %s",
"horizon.friends.finding_route": "Գտեք, թե ինչպես ընկերները կարող են կապվել ձեզ հետ…",
"horizon.osk.shift": "Հերթափոխ",
"horizon.osk.space": "Տիեզերք",
"horizon.osk.delete": "Ջնջել",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Mulai ulang game untuk menerapkan %s: mod ini menambahkan blok, item, atau pembuatan dunia.",
"horizon.shader_options.profile": "Profil",
"horizon.agent.copy_details": "Salin Detail Koneksi",
"horizon.multiplayer.join": "Gabung",
"horizon.multiplayer.cancel_reconnecting": "Batalkan Menyambungkan Ulang",
"horizon.multiplayer.join_friend": "Gabung dengan Teman",
"horizon.multiplayer.invite_code": "Kode Undangan",
"horizon.multiplayer.relay": "Relay (opsional)",
"Preset": "Prasetel",
"LOD Distance": "Jarak LOD",
"Far Detail": "Detail Jauh",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Profil offline milik instans ini",
"horizon.splitscreen.profiles.move": "Pindahkan posisi bermain",
"horizon.splitscreen.profiles.move_place": "Pindahkan posisi bermain: %s",
"horizon.friends.title": "Bermain dengan Teman",
"horizon.friends.hint": "Teman menempelkan kode setelah mengeklik tombol Teman di layar judul, melalui Koneksi Langsung (Direct Connection), atau di halaman Main peluncur.",
"horizon.friends.copied": "Disalin",
"horizon.friends.copy_code": "Salin Kode",
"horizon.friends.stop_sharing": "Berhenti Berbagi",
"horizon.friends.none_yet": "Belum ada teman di sini.",
"horizon.friends.here_now": "Di sini sekarang: %s",
"horizon.friends.share_intro": "Bagikan dunia ini di balik kode undangan: dunia akan terbuka untuk LAN, dan teman di internet dapat bergabung menggunakan kode tersebut. Tidak ada server kami yang dilibatkan.",
"horizon.friends.start_sharing": "Mulai Berbagi",
"horizon.friends.troubleshooting": "Pemecahan Masalah...",
"horizon.friends.stopped": "Berhenti berbagi kode undangan.",
"horizon.friends.short_code_changed": "Seseorang terus mencoba kode pendek yang salah, sehingga kode telah diubah.",
"horizon.friends.wrong_code": "Seseorang mencoba memasukkan kode yang salah.",
"horizon.friends.relay_stopped": "Terlalu banyak kode pendek yang salah, sehingga relay nonaktif hingga Anda berbagi lagi. Teman tetap dapat bergabung dengan kode lengkap.",
"horizon.friends.code_ready": "Kode undangan Anda sudah siap: Menu Game -> Bermain dengan Teman. Salin dan kirimkan ke teman-teman Anda.",
"horizon.friends.share_failed": "Tidak dapat membagikan dunia: %s",
"horizon.friends.finding_route": "Mencari cara agar teman dapat menjangkau Anda…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Spasi",
"horizon.osk.delete": "Hapus",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Malitegharịa egwuregwu ka itinye %s n'ọrụ: ha na-agbakwunye blọk, ihe ma ọ bụ imepụta ụwa.",
"horizon.shader_options.profile": "Profaịlụ",
"horizon.agent.copy_details": "Detuo nkọwa njikọ",
"horizon.multiplayer.join": "Soro",
"horizon.multiplayer.cancel_reconnecting": "Kagbuo njikọ ọzọ",
"horizon.multiplayer.join_friend": "Soro enyi",
"horizon.multiplayer.invite_code": "Koodu ịkpọ oku",
"horizon.multiplayer.relay": "Ntugharị (nhọrọ)",
"Preset": "Ntọala",
"LOD Distance": "Ogologo LOD",
"Far Detail": "Nkọwa zuru oke",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Profaịlụ ndị na-anọghị n'ịntanetị bụ nke ihe atụ a",
"horizon.splitscreen.profiles.move": "Bugharịa ebe ị na-egwu",
"horizon.splitscreen.profiles.move_place": "Bugharịa ebe ị na-egwu: %s",
"horizon.friends.title": "Soro ndị enyi gwuo egwu",
"horizon.friends.hint": "Ndị enyi na-amanye koodu ahụ ka bọtịnụ ndị enyi gachara aha ihuenyo ahụ, na njikọ Direct, ma ọ bụ na ibe Play nke onye mbido.",
"horizon.friends.copied": "Eṅomiri",
"horizon.friends.copy_code": "Detuo koodu",
"horizon.friends.stop_sharing": "Kwụsị ikekọrịta",
"horizon.friends.none_yet": "Onweghi enyi ebea.",
"horizon.friends.here_now": "Ebe a ugbu a: %s",
"horizon.friends.share_intro": "Kekọrịta ụwa a n'azụ koodu ịkpọ òkù: ọ ga-emepe na LAN, ndị enyi na ịntanetị sonye na koodu ahụ. Ọ nweghị ihe nkesa nke anyị metụtara.",
"horizon.friends.start_sharing": "Malite ikekọrịta",
"horizon.friends.troubleshooting": "Nchọpụta nsogbu...",
"horizon.friends.stopped": "Kwụsị ikesa koodu ịkpọ òkù.",
"horizon.friends.short_code_changed": "Mmadụ nọgidere na-agbalị koodu dị mkpirikpi na-ezighi ezi, ya mere ọ gbanwere.",
"horizon.friends.wrong_code": "Mmadụ nwara koodu na-ezighi ezi.",
"horizon.friends.relay_stopped": "Koodu mkpụmkpụ na-ezighi ezi karịrị oke, ya mere relay agbanyụrụ ruo mgbe i ga-ekerịta ọzọ. Ndị enyi ka nwere ike iso na koodu zuru oke.",
"horizon.friends.code_ready": "Koodu ịkpọ oku gị adịla njikere: NchNhr Egwuregwu, Soro ndị enyi gwuo egwu. Detuo ya ma ziga ya ndị enyi gị.",
"horizon.friends.share_failed": "Enweghị ike ịkekọrịta ụwa: %s",
"horizon.friends.finding_route": "Ịchọta ka ndị enyi ga-esi nweta gị…",
"horizon.osk.shift": "Mgbanwe",
"horizon.osk.space": "Oghere",
"horizon.osk.delete": "Hichapụ",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Endurræstu leikinn til að virkja %s: þau bæta við blokkum, hlutum eða heimsmyndun.",
"horizon.shader_options.profile": "Prófíll",
"horizon.agent.copy_details": "Afritaðu tengingarupplýsingar",
"horizon.multiplayer.join": "Vertu með",
"horizon.multiplayer.cancel_reconnecting": "Hætta við að tengjast aftur",
"horizon.multiplayer.join_friend": "Vertu með í vini",
"horizon.multiplayer.invite_code": "Boðskóði",
"horizon.multiplayer.relay": "Relay (valfrjálst)",
"Preset": "Forstillt",
"LOD Distance": "LOD Fjarlægð",
"Far Detail": "Langt smáatriði",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "Prófílar án nettengingar tilheyra þessu tilviki",
"horizon.splitscreen.profiles.move": "Færðu þig þangað sem þú spilar",
"horizon.splitscreen.profiles.move_place": "Færðu þig þar sem þú spilar: %s",
"horizon.friends.title": "Spilaðu með vinum",
"horizon.friends.hint": "Vinir líma kóðann aftan við Vinahnapp titilskjásins, í beinni tengingu eða á Play síðu ræsiforritsins.",
"horizon.friends.copied": "Afritað",
"horizon.friends.copy_code": "Afritaðu kóða",
"horizon.friends.stop_sharing": "Hættu að deila",
"horizon.friends.none_yet": "Engir vinir hér ennþá.",
"horizon.friends.here_now": "Hér núna: %s",
"horizon.friends.share_intro": "Deildu þessum heimi á bak við boðskóða: hann opnast fyrir staðarnet og vinir á internetinu tengjast með kóðanum. Enginn þjónn okkar kemur við sögu.",
"horizon.friends.start_sharing": "Byrjaðu að deila",
"horizon.friends.troubleshooting": "Úrræðaleit...",
"horizon.friends.stopped": "Hætti að deila boðskóðanum.",
"horizon.friends.short_code_changed": "Einhver hélt áfram að reyna rangan stuttkóða, svo hann breyttist.",
"horizon.friends.wrong_code": "Einhver reyndi rangan kóða.",
"horizon.friends.relay_stopped": "Of margir rangir stuttkóðar, svo gengið er slökkt þar til þú deilir aftur. Vinir geta samt tengst með fullum kóða.",
"horizon.friends.code_ready": "Boðskóðinn þinn er tilbúinn: Leikjavalmynd, Spilaðu með vinum. Afritaðu það og sendu það til vina þinna.",
"horizon.friends.share_failed": "Gat ekki deilt heiminum: %s",
"horizon.friends.finding_route": "Að finna hvernig vinir geta náð í þig...",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Rými",
"horizon.osk.delete": "Eyða",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "Riavvia il gioco per applicare %s: aggiungono blocchi, oggetti o generazione del mondo.",
"horizon.shader_options.profile": "Profilo",
"horizon.agent.copy_details": "Copia i dettagli della connessione",
"horizon.multiplayer.join": "Partecipa",
"horizon.multiplayer.cancel_reconnecting": "Annulla riconnessione",
"horizon.multiplayer.join_friend": "Unisciti a un amico",
"horizon.multiplayer.invite_code": "Codice invito",
"horizon.multiplayer.relay": "Relè (opzionale)",
"Preset": "Preimpostato",
"LOD Distance": "Distanza LOD",
"Far Detail": "Dettaglio lontano",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "I profili offline appartengono a questa istanza",
"horizon.splitscreen.profiles.move": "Muoviti dove giochi",
"horizon.splitscreen.profiles.move_place": "Spostati dove giochi: %s",
"horizon.friends.title": "Gioca con gli amici",
"horizon.friends.hint": "Gli amici incollano il codice dopo il pulsante Amici della schermata del titolo, in Connessione diretta o nella pagina di riproduzione del launcher.",
"horizon.friends.copied": "Copiato",
"horizon.friends.copy_code": "Copia codice",
"horizon.friends.stop_sharing": "Interrompi la condivisione",
"horizon.friends.none_yet": "Nessun amico qui ancora.",
"horizon.friends.here_now": "Ecco adesso: %s",
"horizon.friends.share_intro": "Condividi questo mondo dietro un codice di invito: si apre alla LAN e gli amici su Internet si uniscono con il codice. Nessun nostro server è coinvolto.",
"horizon.friends.start_sharing": "Inizia a condividere",
"horizon.friends.troubleshooting": "Risoluzione dei problemi...",
"horizon.friends.stopped": "Interrotta la condivisione del codice di invito.",
"horizon.friends.short_code_changed": "Qualcuno continuava a provare un codice funzione sbagliato, quindi è cambiato.",
"horizon.friends.wrong_code": "Qualcuno ha provato un codice sbagliato.",
"horizon.friends.relay_stopped": "Troppi codici brevi errati, quindi il relè è spento finché non condividi di nuovo. Gli amici possono ancora unirsi con il codice completo.",
"horizon.friends.code_ready": "Il tuo codice di invito è pronto: Menu di gioco, Gioca con gli amici. Copialo e invialo ai tuoi amici.",
"horizon.friends.share_failed": "Impossibile condividere il mondo: %s",
"horizon.friends.finding_route": "Scoprire come gli amici possono contattarti...",
"horizon.osk.shift": "Cambio",
"horizon.osk.space": "Spazio",
"horizon.osk.delete": "Elimina",
@@ -236,7 +236,11 @@
"horizon.mods.open_folder": "Modフォルダーを開く",
"horizon.shader_options.profile": "プロフィール",
"horizon.agent.copy_details": "接続詳細のコピー",
"horizon.multiplayer.join": "参加する",
"horizon.multiplayer.cancel_reconnecting": "再接続のキャンセル",
"horizon.multiplayer.join_friend": "友達に加わる",
"horizon.multiplayer.invite_code": "招待コード",
"horizon.multiplayer.relay": "リレー(オプション)",
"Preset": "プリセット",
"LOD Distance": "LOD距離",
"Far Detail": "遠くの詳細",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "オフライン プロファイルはこのインスタンスに属します",
"horizon.splitscreen.profiles.move": "遊ぶ場所を移動する",
"horizon.splitscreen.profiles.move_place": "プレイする場所を移動: %s",
"horizon.friends.title": "友達と遊ぶ",
"horizon.friends.hint": "フレンドは、タイトル画面のフレンドボタンの後、ダイレクト接続、またはランチャーのプレイページにコードを貼り付けます。",
"horizon.friends.copied": "コピーされました",
"horizon.friends.copy_code": "コードをコピーする",
"horizon.friends.stop_sharing": "共有を停止する",
"horizon.friends.none_yet": "ここにはまだ友達がいません。",
"horizon.friends.here_now": "今ここ: %s",
"horizon.friends.share_intro": "招待コードを使用してこの世界を共有します。コードは LAN に開かれ、インターネット上の友人はコードを使用して参加します。弊社のサーバーは関与しておりません。",
"horizon.friends.start_sharing": "共有を開始する",
"horizon.friends.troubleshooting": "トラブルシューティング中...",
"horizon.friends.stopped": "招待コードの共有を停止しました。",
"horizon.friends.short_code_changed": "誰かが間違ったショートコードを試し続けたため、コードが変更されました。",
"horizon.friends.wrong_code": "誰かが間違ったコードを試みました。",
"horizon.friends.relay_stopped": "短いコードの入力ミスが多すぎるため、再び共有するまでリレーはオフです。友達は完全なコードなら参加できます。",
"horizon.friends.code_ready": "招待コードの準備が整いました: ゲーム メニュー、友達とプレイ。コピーして友達に送信してください。",
"horizon.friends.share_failed": "世界を共有できませんでした: %s",
"horizon.friends.finding_route": "友達があなたに連絡する方法を調べています…",
"horizon.osk.shift": "シフト",
"horizon.osk.space": "宇宙",
"horizon.osk.delete": "削除",
@@ -213,7 +213,11 @@
"horizon.mods.switch.restart": "გადაუშვით თამაში %s-ის გამოსაყენებლად: ისინი ამატებენ ბლოკებს, ნივთებს ან სამყაროს გენერაციას.",
"horizon.shader_options.profile": "პროფილი",
"horizon.agent.copy_details": "დააკოპირეთ კავშირის დეტალები",
"horizon.multiplayer.join": "შეუერთდი",
"horizon.multiplayer.cancel_reconnecting": "ხელახლა დაკავშირების გაუქმება",
"horizon.multiplayer.join_friend": "შეუერთდი მეგობარს",
"horizon.multiplayer.invite_code": "მოწვევის კოდი",
"horizon.multiplayer.relay": "რელე (სურვილისამებრ)",
"Preset": "წინასწარ დაყენებული",
"LOD Distance": "LOD მანძილი",
"Far Detail": "შორეული დეტალი",
@@ -458,6 +462,23 @@
"horizon.splitscreen.profiles.offline_note": "ოფლაინ პროფილები ეკუთვნის ამ შემთხვევას",
"horizon.splitscreen.profiles.move": "იმოძრავეთ იქ, სადაც თამაშობთ",
"horizon.splitscreen.profiles.move_place": "გადაიტანეთ სადაც თამაშობთ: %s",
"horizon.friends.title": "ითამაშეთ მეგობრებთან ერთად",
"horizon.friends.hint": "მეგობრებმა ჩასვით კოდი სათაურის ეკრანის მეგობრების ღილაკის შემდეგ, პირდაპირი კავშირში ან გამშვების Play გვერდზე.",
"horizon.friends.copied": "კოპირებულია",
"horizon.friends.copy_code": "დააკოპირეთ კოდი",
"horizon.friends.stop_sharing": "შეწყვიტე გაზიარება",
"horizon.friends.none_yet": "აქ მეგობრები ჯერ არ არის.",
"horizon.friends.here_now": "აქ არის ახლა: %s",
"horizon.friends.share_intro": "გააზიარეთ ეს სამყარო მოწვევის კოდის მიღმა: ის იხსნება LAN-ზე და ინტერნეტში მყოფი მეგობრები უერთდებიან კოდით. არც ერთი ჩვენი სერვერი არ არის ჩართული.",
"horizon.friends.start_sharing": "დაიწყეთ გაზიარება",
"horizon.friends.troubleshooting": "პრობლემების აღმოფხვრა...",
"horizon.friends.stopped": "შეწყვიტა მოწვევის კოდის გაზიარება.",
"horizon.friends.short_code_changed": "ვიღაც ცდილობდა არასწორ მოკლე კოდს, ამიტომ ის შეიცვალა.",
"horizon.friends.wrong_code": "ვიღაცამ არასწორი კოდი სცადა.",
"horizon.friends.relay_stopped": "ძალიან ბევრი არასწორი მოკლე კოდი, ამიტომ რელე გამორთულია, სანამ ხელახლა არ გააზიარებთ. მეგობრებს მაინც შეუძლიათ შეერთება სრული კოდით.",
"horizon.friends.code_ready": "თქვენი მოწვევის კოდი მზად არის: თამაშის მენიუ, თამაში მეგობრებთან ერთად. დააკოპირეთ და გაუგზავნეთ თქვენს მეგობრებს.",
"horizon.friends.share_failed": "ვერ ვიზიარებ სამყაროს: %s",
"horizon.friends.finding_route": "იპოვნეთ როგორ დაგიკავშირდნენ მეგობრები…",
"horizon.osk.shift": "ცვლა",
"horizon.osk.space": "სივრცე",
"horizon.osk.delete": "წაშლა",
Loaded 100 of 518 files, more files were not shown because too many files have changed in this diff. Show more