Compare commits

...
Author SHA1 Message Date
Horizon agent 911e23fb55 Merge remote-tracking branch 'origin/split/friends-share-view' into merge/friends-page
# Conflicts:
#	crates/horizon-platform/src/lib.rs
#	docs/forking.md
2026-11-09 23:57:38 +01:00
Horizon agent ffaaa63836 Merge remote-tracking branch 'origin/main' into tmp/share-view-merge
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 23:45:52 +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
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
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
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
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
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
Horizon agentandClaude Opus 5.5 4e22c1ad9a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:27:08 +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
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
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
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
Horizon agentandClaude Opus 5.5 ee60188af7 Play with Friends' screen becomes a page at Place::PauseFriends (spec #14167 step 2a)
The Friends screen is now Play with Friends' page, registered at a new
Place::PauseFriends (vanilla's pause-menu Friends icon) and drawn by the
engine as its own screen (game/page_screen.rs) with today's layout: grey and
white text, the code broken between its groups, Copy and Stop side by side,
Troubleshooting beside Done. Its rows are read when drawn (the code is polled
then, as before); a press runs on the next Hooks::share tick, which has the
game's view. horizon_hooks::tr gives crates the player's language.
game/friends/screen.rs goes; Screen::Friends stays until step 2b.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:06:46 +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 f518ef4c07 Merge branch 'split/pages-plain-rows' into split/friends-page 2026-11-09 20:56:42 +01:00
Horizon agent 6d5b520591 Merge the drag hook fixture fix into layers/replay-move 2026-11-09 20:56:41 +01:00
Horizon agentandClaude Opus 5.5 2719b22258 Friends reads its code while the screen is drawn, as before
The invite code and the host's events are polled while the Friends screen
is drawn or horizon-ctl friends status asks, as on main: Hooks::share stays
dispatched for the screen's page (#14167 step 2a), which is its first user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:50:04 +01:00
Horizon agentandClaude Opus 5.5 3da374ef18 Play with Friends hosts through Hooks::share and Hooks::lan_closed (spec #14167 step 1)
The engine shows the window's world to crates that share it past the LAN
through horizon_hooks::share::ShareView, ticks them next to social::tick()
(also while paused) and tells them when the world closes to LAN. Only the
window's own player dispatches: never the serving Game or a splitscreen guest.
game/friends.rs keeps its logic but goes through the view, its state in its
own static instead of a Game field, ready to move to horizon-friends.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:40:22 +01: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 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 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 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 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 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 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 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 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 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 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
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 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
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 agent 7bfa76577f Merge remote-tracking branch 'origin/main' into w14011 2026-11-09 18:57:29 +01: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 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 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
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 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
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 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
Tran Anh Van e2ad02a58a Complete ArmorStand.hurtServer game events, freeze clearing, and damage immunity 2026-11-09 22:46:25 +07: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
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
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
77 changed files with 5444 additions and 2753 deletions

No files matched your search

+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
+10
View File
@@ -1650,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"
@@ -1793,6 +1802,7 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
+1
View File
@@ -34,6 +34,7 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
+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 -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 = []
+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 -6
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,16 +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 still in this crate (docs/forking.md, docs/layers.md): the features below.
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.
@@ -130,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"] }
+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));
+6 -4
View File
@@ -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
@@ -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);
}
@@ -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);
@@ -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]
+1
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;
@@ -0,0 +1,145 @@
//! 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,
}
/// `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,
},
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)
}
@@ -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
}
+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());
}
}
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
}
/// The mark and the screen's name over the search field.
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
let (x, y) = (cols.side_x, cols.side_y);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
let horizon = Text::literal("Horizon");
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
let title = Text::literal(title);
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
}
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
}
if open {
style::fill(batch, atlas, rect, style::OPEN);
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
style::fill(batch, atlas, rect, style::open());
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
}
let color = match (open || hover, has) {
(true, _) => style::WHITE,
(false, true) => style::SOFT,
(false, false) => style::MUTED,
(false, true) => style::soft(),
(false, false) => style::muted(),
};
let ty = py + ((ph - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
search.draw(batch, atlas, None);
if search.value.is_empty() && !focused {
let hint = Text::literal(search.message.clone());
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
}
if !search.value.is_empty() {
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
crate::widgets::widget_cursor(hover, true);
let color = if hover { style::WHITE } else { style::MUTED };
let color = if hover { style::WHITE } else { style::muted() };
let cy = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(
batch,
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
batch,
atlas,
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
style::HEAD_RULE,
style::head_rule(),
);
}
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
}
let Some(setting) = line.setting else {
let (name, color, dy) = match line.group {
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
None => (page_name(line.page), style::ACCENT, 4.0),
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
None => (page_name(line.page), style::accent(), 4.0),
};
let name = Text::literal(name);
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
};
if !after_name {
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
style::fill(batch, atlas, rule, style::ROW_RULE);
style::fill(batch, atlas, rule, style::row_rule());
}
row(batch, setting, y);
}
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
&none,
cols.list_x + 2.0,
cols.top + 4.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let active = live && (hover || focused || held);
if active {
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::HOVER);
style::fill(batch, atlas, lit, style::hover());
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
if live {
crate::widgets::target_as(rect, kind);
}
let color = if live { style::WHITE } else { style::MUTED };
let soft = if live { style::SOFT } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let soft = if live { style::soft() } else { style::muted() };
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let lit = live && (hover || held);
let left = rect[0] + style::INSET;
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
batch,
atlas,
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
style::HOVER,
style::hover(),
);
}
@@ -644,7 +644,7 @@ fn read_row(
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
let value = style::fit(atlas, value, room);
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
if focused {
style::ring(
batch,
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
}
let left = rect[0] + style::INSET;
let color = if live { style::WHITE } else { style::MUTED };
let color = if live { style::WHITE } else { style::muted() };
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
batch.text_styled(atlas, &shown, left, ty, color, true);
if focused {
@@ -799,7 +799,7 @@ fn side_lines(
];
crate::widgets::record(line, rect, false);
let shown = Text::literal(style::fit(atlas, line, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
}
/// A line under the page list: its inset, and its height.
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
crate::widgets::record(text, rect, false);
let shown = Text::literal(style::fit(atlas, text, rect[2]));
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
}
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
// The row's page is open: lit as the list's open page is.
if list.lit == Some(*shown) {
let row @ [x, y, _, h] = strip.area;
style::fill(batch, atlas, row, style::OPEN);
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, row, style::open());
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
}
tabs::draw(batch, atlas, strip);
}
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
batch.text_styled(
atlas,
&name,
rect[0] + style::INSET,
ty,
style::muted(),
true,
);
return;
}
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
crate::widgets::record(about, cols.dock, false);
let color = if why.is_some() {
style::MUTED
style::muted()
} else {
style::GREY
};
@@ -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);
+3 -6
View File
@@ -119,14 +119,11 @@ 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", "friends"]
# 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"]
+2 -4
View File
@@ -17,8 +17,8 @@ pub use horizon_client_render::{
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
@@ -58,8 +58,6 @@ pub mod debug_overlay;
pub mod debugworld;
pub mod dialog;
pub mod dialog_screen;
#[cfg(feature = "discord")]
pub mod discord_presence;
pub mod end_crystal;
pub mod end_gateway;
pub mod entities;
+8
View File
@@ -13,6 +13,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
crate::horizon_look::give();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
@@ -23,6 +24,13 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates' text in the player's language (`horizon_hooks::tr`).
horizon_hooks::engine::set_language(|key| {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
});
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
#[cfg(feature = "friends")]
game::register_friends();
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
+13 -23
View File
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -128,8 +129,6 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -357,6 +356,7 @@ mod pad;
mod pad_actions;
mod pad_craft;
mod pad_menu;
mod page_screen;
mod paintings;
mod pandas;
mod particle_culling;
@@ -423,6 +423,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod share_view;
mod sheep_dye;
mod shelf;
mod shield;
@@ -599,6 +600,8 @@ use resource_loader::{
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
run_loading, write_report,
};
#[cfg(feature = "friends")]
pub(crate) use share_view::register_friends;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -652,7 +655,7 @@ enum Screen {
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (game/friends.rs, game/friends/screen.rs).
/// (the page at `Place::PauseFriends`, game/page_screen.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -1499,7 +1502,7 @@ impl LevelAccess for GameLevel<'_> {
};
}
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal;
return signal * i32::from(books::has_book_state(self.registry, state));
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -2895,9 +2898,6 @@ struct Game<'a> {
level_shapes: horizon_client::chunk::LevelShapes,
/// This game is that server thread's (game/integrated.rs).
serving: Option<integrated::Serving>,
/// The open world is shared behind an invite code (game/friends.rs).
#[cfg(feature = "friends")]
friends: Option<friends::Sharing>,
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
social: social::Social,
world_options: Option<lan_screen::WorldOptions>,
@@ -5729,7 +5729,6 @@ impl Game<'_> {
if let Some(log) = self.world.block_log.as_mut() {
log.extend(held);
}
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
self.announce_sleep_status();
@@ -6717,8 +6716,8 @@ impl Game<'_> {
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
// What crates draw around players: first person, see-through (game/player_hooks.rs).
self.players_after_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
@@ -11253,8 +11252,7 @@ impl Game<'_> {
self.heal_hold_latches();
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
horizon_hooks::engine::published(self.is_published());
#[cfg(feature = "discord")]
self.update_discord_presence();
self.report_activity();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -11262,6 +11260,7 @@ impl Game<'_> {
// the world's own ticks.
horizon_client::social::set_presence_status(self.presence_status());
horizon_client::social::tick();
self.share_tick();
// Minecraft.tick: ClientTickEvent.Pre before any of it.
self.native_client_tick_pre();
if self.title.is_none() && !self.pause {
@@ -12015,8 +12014,6 @@ impl<'a> Game<'a> {
test_defs_snapshot: false,
test_highlights: HashMap::new(),
game_tests: game_tests::GameTests::default(),
#[cfg(feature = "friends")]
friends: None,
social: Default::default(),
debug_keys: Default::default(),
debug_charts: Default::default(),
@@ -12529,15 +12526,8 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
#[cfg(feature = "discord")]
if options.dedicated.is_none() {
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
// Snapshots of derived tables live in the game folder, never beside the
// shared resources.
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
@@ -0,0 +1,68 @@
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
//! layer's Discord presence shows it).
use super::*;
use horizon_client::command::args::GameMode;
use horizon_hooks::{Activity, Client, Dimension, Play};
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
/// `Hooks::client_started`.
pub(super) fn client_started(options: &Options) {
if options.dedicated.is_some() {
return;
}
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_hooks::engine::activity(Activity::Menus);
horizon_hooks::engine::client_started(&Client::new(automated));
}
impl Game<'_> {
/// Each frame: only a change reaches the hooks.
pub(super) fn report_activity(&self) {
// One status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
horizon_hooks::engine::activity(self.activity());
}
fn activity(&self) -> Activity {
if self.title.is_some() {
return Activity::Menus;
}
let max = horizon_client::lan::MAX_PLAYERS;
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Activity::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: match self.mode {
GameMode::Survival => horizon_hooks::GameMode::Survival,
GameMode::Creative => horizon_hooks::GameMode::Creative,
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
},
}
}
}
+93 -14
View File
@@ -1278,10 +1278,21 @@ impl Game<'_> {
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
let a = &self.armor_stands[i];
if (invulnerable && !creative) || a.invisible || a.marker {
if (invulnerable && !creative)
|| a.invisible
|| a.marker
|| self.stand_immune_to_damage(i, damage_type, causing)
{
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
@@ -1292,21 +1303,26 @@ impl Game<'_> {
tagged("minecraft:always_kills_armor_stands"),
);
if explosion {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
return false;
}
if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15);
self.cause_stand_damage(i, 0.15, by);
} else {
// igniteForSeconds(5.0f): at least 100 ticks.
// igniteForSeconds(5.0f): at least 100 ticks, and
// Entity.igniteForTicks calls clearFreeze.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
{
map.remove(&"TicksFrozen".into());
}
}
return false;
}
if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0);
self.cause_stand_damage(i, 4.0, by);
return false;
}
if !breaks && !always_kills {
@@ -1321,22 +1337,85 @@ impl Game<'_> {
if !may_build {
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
/// effect whose requirements hold for `damage_type` and `causing`.
fn stand_immune_to_damage(
&mut self,
i: usize,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let mut checks = Vec::new();
let equipment = &self.armor_stands[i].equipment;
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
let Some(stack) = stack else { continue };
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
let immunity = &def.effects["minecraft:damage_immunity"];
for entry in immunity.as_array().into_iter().flatten() {
checks.push((entry.get("requirements").cloned(), *level));
}
}
}
if checks.is_empty() {
return false;
}
let attacker = match causing {
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
Some(Target::Mob(m_id)) => self
.mobs
.iter()
.find(|m| m.id == m_id)
.map(|m| m.kind.id.clone()),
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
_ => None,
};
let canonical = if damage_type.contains(':') {
damage_type.to_owned()
} else {
format!("minecraft:{damage_type}")
};
let ctx = horizon_client::enchant::EffectContext {
items: Some(self.res.items()),
damage_type: Some(&canonical),
is_direct: attacker.is_some(),
this: Some("minecraft:armor_stand"),
attacker: attacker.as_deref(),
direct_attacker: attacker.as_deref(),
..Default::default()
};
let ench = &self.res.stations().enchantments;
let rng = &mut self.world.random;
for (req, level) in checks {
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
return true;
}
}
false
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_by_anything(i);
self.break_armor_stand_with_source(i, by);
} else {
let at = self.armor_stands[i].position;
self.armor_stands[i].health = health;
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
+60
View File
@@ -1608,6 +1608,16 @@ impl Game<'_> {
}
}
}
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
pub(super) fn has_book_state(
registry: &'static horizon_world::registry::BlockRegistry,
state: horizon_world::registry::BlockStateId,
) -> bool {
registry
.state(state)
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
}
/// `LecternBlockEntity.getRedstoneSignal`.
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
@@ -1721,6 +1731,7 @@ mod lectern_signal_tests {
items::ItemStack,
};
use horizon_core::position::BlockPos;
use horizon_world::level::LevelAccess;
use horizon_world::registry::BlockStateId;
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
@@ -1738,6 +1749,55 @@ mod lectern_signal_tests {
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
}
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
/// the state's `HAS_BOOK` is true.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_lectern_without_the_has_book_state_feeds_no_comparator() {
let mut game = server_game();
let key = (8, 64, 8);
let state = game
.pack
.assets
.registry
.default_state("minecraft:lectern")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world.block_entities.insert(
key,
BlockEntity::Lectern(Lectern {
book: Some(ItemStack::new("minecraft:writable_book", 1)),
page: 0,
}),
);
let signal = |game: &mut super::Game<'_>, state| {
game.with_level(|_, level, _| {
level.block_entity_analog_signal(
BlockPos::new(key.0, key.1, key.2),
state,
horizon_core::geometry::Direction::North,
)
})
};
assert_eq!(
signal(&mut game, state),
0,
"the block state says the lectern holds no book"
);
let registry = game.pack.assets.registry;
let mut with_book = registry.state(state).unwrap().clone();
with_book
.properties
.insert("has_book".into(), "true".into());
let with_book = registry.id(&with_book).unwrap();
game.world.set(key.0, key.1, key.2, with_book).unwrap();
assert_eq!(
signal(&mut game, with_book),
15,
"with HAS_BOOK the book's page signal"
);
}
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
/// component, so a stack that only sits in the lectern (a non-book item a
+382 -381
View File
@@ -13,18 +13,12 @@ mod fx;
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part, Polygon};
use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
PlayerModels, SpriteStore, Sprites,
Badge, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Look, Mark, Mesh, NameDecoration,
Pass, PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
@@ -32,46 +26,10 @@ use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
const MARK: Mark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
item: Arc<Item>,
/// A model per bone it rides (a shirt's sleeves are on the arms).
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
/// Its texture's sprite (a 64×64 skin slot of the atlas).
location: String,
/// The frame it was last marked drawn in (its slot is kept).
marked: Cell<u32>,
}
impl Baked {
fn new(item: &Arc<Item>, frame: u32) -> Self {
Self {
item: item.clone(),
models: item
.roots()
.map(|(bone, root)| (bone, Arc::new(part(root))))
.collect(),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
}
}
}
#[derive(Default)]
pub(super) struct Cosmetics {
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
uploads: RefCell<Vec<Arc<Item>>>,
frame: Cell<u32>,
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
emotes: emotes::Emotes,
/// The emote wheel (`cosmetics/emote_wheel.rs`).
@@ -130,49 +88,6 @@ fn load_owned_emotes(
)
}
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// Full light, for what glows.
const FULL_LIGHT: u32 = 240 | 240 << 8;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped), and the frame's trails and auras, drawn after the
/// players (`cosmetics_fx_geometry`).
#[derive(Default)]
struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
/// Trails and auras: see-through.
clear: Vec<Vertex>,
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
fn frame(&mut self, frame: u32) {
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
self.clear.clear();
}
}
/// Whether the signed-in account owns a cosmetic: the answer kept from the last run first
/// (`prefs::owned`), then the service's, asked once a run and retried after a failure like Show my
/// cosmetics. Not known yet, or no session: not owning.
@@ -334,15 +249,11 @@ impl Cosmetics {
/// choice, or by owning none). Hidden, the service is never asked about them.
fn worn_of(
client: &horizon_cosmetics::Cosmetics,
extras: &horizon_client::extras::Extras,
said: &Said,
own: bool,
uuid: impl FnOnce() -> Option<u128>,
) -> Option<horizon_cosmetics::Worn> {
let shown = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
let shown = if own { said.mine } else { !said.others_hidden };
if !shown {
return None;
}
@@ -369,24 +280,6 @@ fn own_uuid(
/// How often a looping emote is said again (the service keeps one a minute).
const LOOP_AGAIN: Duration = Duration::from_secs(30);
/// The game's model of an item's part.
fn part(p: &horizon_cosmetics::Part) -> Part {
Part {
name: p.name.clone(),
pose: p.pose,
polygons: p
.faces
.iter()
.map(|f| Polygon {
normal: f.normal,
vertices: f.vertices,
cull_back: false,
})
.collect(),
children: p.children.iter().map(part).collect(),
}
}
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
fn slot_image(item: &Item) -> Vec<u8> {
let t = &item.texture;
@@ -463,12 +356,45 @@ struct Seat {
models: Vec<PlayerModel>,
/// The world is the window's own single-player one (`Frame::solo`).
solo: bool,
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
/// held so the address isn't reused while it is there.
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
/// The items the hooks show, by the item's address.
items: FxHashMap<usize, Kept>,
last_sprite: u64,
/// Sprites drawing missed, put in the atlas next frame.
wanted: Vec<u64>,
/// Companions, trails and auras (`cosmetics/fx.rs`).
fx: fx::Fx,
}
/// An item the hooks show: its sprite's id (never reused) and a mesh per bone it rides, held
/// with the item so its address isn't reused while it is here.
struct Kept {
sprite: u64,
item: Arc<Item>,
meshes: Vec<(Bone, Arc<Mesh>)>,
}
/// The hooks' model of an item's part.
fn mesh(p: &horizon_cosmetics::Part) -> Mesh {
let quads = p.faces.iter().map(|f| Quad::new(f.normal, f.vertices));
Mesh::new(
p.name.clone(),
p.pose,
quads.collect(),
p.children.iter().map(mesh).collect(),
)
}
/// The hooks' name of a bone.
fn bone(b: horizon_cosmetics::Bone) -> Bone {
use horizon_cosmetics::Bone as B;
match b {
B::Head => Bone::Head,
B::Body => Bone::Body,
B::LeftArm => Bone::LeftArm,
B::RightArm => Bone::RightArm,
B::LeftLeg => Bone::LeftLeg,
B::RightLeg => Bone::RightLeg,
}
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
@@ -483,6 +409,8 @@ struct Said {
others_hidden: bool,
/// Horizon's mark by Horizon players' names.
marks: bool,
/// Other players' trails and auras are hidden.
hide_fx: bool,
/// Cosmetics in single player: the signed-in account's on the player's own model.
solo: bool,
/// My emote pulled the camera back: when it started (the own model's age).
@@ -493,6 +421,19 @@ struct Said {
first: Option<Arc<Item>>,
}
impl Said {
/// What the game's settings say.
fn settings(&mut self, extras: &horizon_client::extras::Extras) {
self.mine = extras.cosmetics_mine;
self.listed = extras.horizon_listed;
self.others_shown = extras.effective_others() == Others::Shown;
self.others_hidden = extras.effective_others() == Others::Hidden;
self.marks = extras.horizon_marks;
self.hide_fx = extras.cosmetics_hide_fx;
self.solo = extras.cosmetics_solo;
}
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
@@ -515,6 +456,7 @@ impl horizon_hooks::Hooks for Hooks {
service(|s| {
s.solo = frame.solo;
s.frame();
s.fx.frame();
s.put_sprites(sprites);
});
}
@@ -539,6 +481,15 @@ impl horizon_hooks::Hooks for Hooks {
}
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
fn player_layers(&self, model: &PlayerModel, draw: &mut dyn PlayerDraw) {
service(|s| s.player_layers(model, draw));
}
/// The player's own companion in first person.
fn first_person(&self, own: &PlayerModel, draw: &mut dyn PlayerDraw) {
service(|s| s.first_person(own, draw));
}
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
let start = service(|s| s.said.pull)?;
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
@@ -664,14 +615,7 @@ impl Seat {
if !(styles || said.marks) {
return None;
}
let uuid = match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
Kind::Player => m.uuid,
_ => None,
}?;
let uuid = self.uuid_of(m)?;
let mut frame = self.client.as_ref()?.frame();
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
let worn = if styles { frame.worn(uuid) } else { None };
@@ -687,42 +631,152 @@ impl Seat {
Some(NameDecoration::new(mark, badge, top))
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas (it is put there next frame).
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
fn uuid_of(&self, m: &PlayerModel) -> Option<u128> {
match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, self.said.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
Kind::Player => m.uuid,
_ => None,
}
}
/// `item` as the hooks show it, kept from its first sight.
fn kept(&mut self, item: &Arc<Item>) -> &Kept {
let next = self.last_sprite + 1;
let (id, _) = *self
.sprites
let kept = self
.items
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| (next, item.clone()));
self.last_sprite = self.last_sprite.max(id);
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
.or_insert_with(|| Kept {
sprite: next,
item: item.clone(),
meshes: item
.roots()
.map(|(b, root)| (bone(b), Arc::new(mesh(root))))
.collect(),
});
self.last_sprite = self.last_sprite.max(kept.sprite);
kept
}
/// Where `item`'s texture is in the atlas (`get` reads the crate's sprites): the top left
/// corner of its slot. None until it is there (it is put there next frame).
fn uv(&mut self, item: &Arc<Item>, get: impl Fn(u64) -> Option<[f32; 4]>) -> Option<[f32; 4]> {
let id = self.kept(item).sprite;
let Some([u0, v0, u1, v1]) = get(id) else {
if !self.wanted.contains(&id) {
self.wanted.push(id);
}
return None;
};
// The mark sits in the slot's top left corner.
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
Some(Badge::new(
[u0, v0, u1, v1],
u32::from_be_bytes([0, r, g, b]),
))
let (w, h) = (
item.texture.width as f32 / 64.0,
item.texture.height as f32 / 64.0,
);
Some([u0, v0, u0 + (u1 - u0) * w, v0 + (v1 - v0) * h])
}
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
/// The name tag style in `worn`: its badge and its line's colour. None until the badge is in
/// the atlas.
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let uv = self.uv(item, |id| sprites.get(id))?;
Some(Badge::new(uv, u32::from_be_bytes([0, r, g, b])))
}
/// Puts the sprites drawing missed in the atlas; forgets the items no one wears.
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
for id in self.wanted.drain(..) {
let item = self.sprites.values().find(|(i, _)| *i == id);
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
store.put(id, slot_image(item));
let kept = self.items.values().find(|k| k.sprite == id);
if let Some(kept) = kept.filter(|_| !store.has(id)) {
store.put(id, slot_image(&kept.item));
}
}
self.sprites
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
self.items.retain(|_, k| Arc::strong_count(&k.item) > 1);
}
/// The cosmetics `m` wears, on its bones and around it in the world (companions, trails,
/// auras: `cosmetics/fx.rs`), and an emote's props.
fn player_layers(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
if m.invisible {
return;
}
let said = &self.said;
let own = m.kind == Kind::Own;
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || said.others_shown;
let ticks = draw.ticks();
// An emote's props: its time and weight while it plays.
let emote = emotes::pose_playing(draw.body_pose()).and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then(|| (p.item.clone(), t, w))
});
let client = self.client.as_ref().filter(|_| bodies);
let worn = client.and_then(|c| worn_of(c, &self.said, own, || self.uuid_of(m)));
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
// matrix: the head, the body and the two arms at most.
let mut bones: [Option<(Bone, [[f32; 4]; 4])>; 4] = [None; 4];
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
let emote_items = emote.iter().map(|(i, t, w)| (i, Some((*t, *w))));
for (item, emoting) in worn_items.chain(emote_items) {
// On no bone, around the model in the world.
if let (Slot::Companion | Slot::Trail | Slot::Aura, None) = (item.slot, emoting) {
if draw.pass() == Pass::World {
self.fx(m, item, draw);
}
continue;
}
// Covered by gear, or drawn by no bone (a name tag style).
if emoting.is_none() && covered(m.gear, item.slot) {
continue;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
continue;
};
for i in 0..self.kept(item).meshes.len() {
let (bone_of, mesh) = self.kept(item).meshes[i].clone();
let known = bones.iter().flatten().find(|(b, _)| *b == bone_of);
let matrix = match known {
Some(&(_, matrix)) => matrix,
None => {
let Some(matrix) = draw.bone(bone_of) else {
continue;
};
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
*free = Some((bone_of, matrix));
}
matrix
}
};
draw.draw(&mesh, matrix, Look::new(uv), &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
e.pose_props(t, |n| item.rest(n), |n, pose| *p.pose(n) = pose);
// Props grow in and shrink out with the emote's blend.
let root = p.pose(&mesh.name);
for s in &mut root[6..9] {
*s *= w;
}
}
});
}
}
}
/// The window's own companion while its body is out of view.
fn first_person(&mut self, m: &PlayerModel, draw: &mut dyn PlayerDraw) {
let Some(client) = self.client.as_ref().filter(|_| !m.invisible) else {
return;
};
let Some(worn) = worn_of(client, &self.said, true, || self.uuid_of(m)) else {
return;
};
if let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) {
self.fx(m, item, draw);
}
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
@@ -824,12 +878,7 @@ impl Game<'_> {
let pull = self.cosmetics.emotes.pull();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
said.others_hidden = extras.effective_others() == Others::Hidden;
said.marks = extras.horizon_marks;
said.solo = extras.cosmetics_solo;
said.settings(extras);
said.pull = pull;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
@@ -840,36 +889,16 @@ impl Game<'_> {
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// textures the last frame wanted into the atlas.
/// Once a frame, after the hooks: what the game reads of them (the settings' owner flag,
/// whether items come from a folder).
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
}
s.owner.owns
});
let (owns, local) = service(|s| (s.owner.owns, s.local));
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
if !self.gui.atlas.sprites.contains_key(&location) {
self.store_skin(location, slot_image(&item));
}
}
// Items no player wears any more.
self.cosmetics
.baked
.borrow_mut()
.retain(|_, b| Arc::strong_count(&b.item) > 1);
self.cosmetics.local = local;
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
@@ -895,210 +924,6 @@ impl Game<'_> {
})
.ok();
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
worn_of(client, &self.settings.extras, own, || {
self.cosmetics_uuid(m, own)
})
}
/// The UUID the service knows `m` by: a player's, or a mannequin's id with local items.
fn cosmetics_uuid(&self, m: &Mob, own: bool) -> Option<u128> {
if own {
own_uuid(
self.remote.as_ref().map(|r| r.profile()),
self.integrated.is_some(),
self.settings.extras.cosmetics_solo,
|| horizon_client::auth::current().map(|s| s.uuid),
)
} else if &*m.kind.id == "minecraft:player" {
self.remote_player_uuid(m.id)
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
Some(u128::from(m.id))
} else {
None
}
}
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
/// go to `out`. `camera`: where the world view's camera is; None in a picture of the player
/// (the inventory), which shows no companion, trail or aura.
#[allow(clippy::too_many_arguments)]
pub(super) fn cosmetics_geometry(
&self,
m: &Mob,
own: bool,
base: glam::Mat4,
camera: Option<glam::DVec3>,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
use horizon_client::mobs::{anim, render};
let worn = self.worn(m, own);
// An emote's props: its time and weight while it plays.
let ticks = m.age as f32 + alpha;
let emote = emotes::playing(m).and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then_some((&p.item, t, w))
});
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
return None;
}
// The avatar's body, posed as it is drawn.
let (key, _) = render::body(m);
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
let mut scratch = self.cosmetics.poses.borrow_mut();
let (body_poses, poses) = &mut *scratch;
body_poses.reset(body);
let data = self.res.mob_data();
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
let mut bounds = None;
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
// matrix: the head, the body and the two arms at most.
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
// "Name tags only": other players' styles beside their names, nothing on their bodies.
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
let worn_items = worn.iter().flat_map(|w| w.iter()).map(|i| (i, None));
let emote_items = emote.map(|(i, t, w)| (i, Some((t, w))));
for (item, emoting) in worn_items.chain(emote_items) {
if emoting.is_none() && !bodies {
continue;
}
if let (Some(camera), Slot::Companion | Slot::Trail | Slot::Aura) = (camera, item.slot)
{
// The player's own in first person: only its companion, drawn after the drain
// of the first-person model (`cosmetics_fx_geometry`).
if !(own && !self.own_body_in_view()) {
let at = fx::Wearer {
m,
own,
key: if own { OWN } else { m.id },
base,
camera,
alpha,
light,
};
bounds = self.cosmetics_fx(&at, item, gpu, bounds, poses, out);
}
continue;
}
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
continue;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
continue;
};
for (bone_of, model) in &models {
let known = bones.iter().flatten().find(|(b, _)| b == bone_of);
let bone = match known {
Some(&(_, matrix)) => matrix,
None => {
let Some(matrix) =
render::part_matrix(body, base, body_poses, bone_of.part(), 0.0)
else {
continue;
};
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
*free = Some((*bone_of, matrix));
}
matrix
}
};
poses.reset(model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if let (Some((t, w)), Some(e)) = (emoting, &item.emote) {
e.pose_props(t, |n| item.rest(n), |n, pose| *poses.get(n) = pose);
// Props grow in and shrink out with the emote's blend.
let root = poses.get(&model.name);
for s in &mut root[6..9] {
*s *= w;
}
}
bounds = self.cosmetics_model(model, bone, poses, uv, light, gpu, bounds, out);
}
}
bounds
}
/// An item's models and its texture's corners in the atlas, once its texture is there (it is
/// asked for meanwhile).
#[allow(clippy::type_complexity)]
fn cosmetics_baked(
&self,
item: &Arc<Item>,
) -> Option<(Vec<(horizon_cosmetics::Bone, Arc<Part>)>, [f32; 4])> {
let frame = self.cosmetics.frame.get();
let mut baked = self.cosmetics.baked.borrow_mut();
let b = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked::new(item, frame));
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
};
if b.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&b.location);
}
// The texture fills the top-left of its 64×64 slot.
let (w, h) = (
item.texture.width as f32 / 64.0,
item.texture.height as f32 / 64.0,
);
let uv = [
rect.u0,
rect.v0,
rect.u0 + (rect.u1 - rect.u0) * w,
rect.v0 + (rect.v1 - rect.v0) * h,
];
Some((b.models.clone(), uv))
}
/// One model at `matrix`, posed by `poses`: on the GPU's skin path when `gpu` (the bounds
/// grow to hold it), else its vertices go to `out`.
#[allow(clippy::too_many_arguments)]
fn cosmetics_model(
&self,
model: &Arc<Part>,
matrix: glam::Mat4,
poses: &horizon_client::mobs::anim::Poses,
uv: [f32; 4],
light: u32,
gpu: bool,
bounds: Option<(glam::Vec3, glam::Vec3)>,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
};
if skin.draw(model, matrix, poses, paint) {
return skin.bounds;
}
}
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
out,
);
bounds
}
}
#[cfg(test)]
@@ -1165,6 +990,13 @@ mod tests {
const OTHER: u128 = 0x1234;
/// What `extras` tell the hooks.
fn said_of(extras: &Extras) -> Said {
let mut said = Said::default();
said.settings(extras);
said
}
/// What another player wears as `extras` shows it, drawn frame after frame for up to
/// `patience` (the service answers on its own thread), and what was asked of the service.
fn other_player(
@@ -1182,7 +1014,7 @@ mod tests {
let client = horizon_cosmetics::Cosmetics::new(service.clone(), None);
let deadline = Instant::now() + patience;
let worn = loop {
let worn = worn_of(&client, extras, false, || Some(OTHER));
let worn = worn_of(&client, &said_of(extras), false, || Some(OTHER));
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1243,7 +1075,7 @@ mod tests {
let uuid = || own_uuid(Some(OFFLINE), solo, extras.cosmetics_solo, || Some(ACCOUNT));
let deadline = Instant::now() + patience;
let worn = loop {
let worn = worn_of(&client, &extras, true, uuid);
let worn = worn_of(&client, &said_of(&extras), true, uuid);
if worn.is_some() || Instant::now() >= deadline {
break worn;
}
@@ -1398,4 +1230,173 @@ mod tests {
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
/// A player model as `player_layers` draws on it: one bone, the sprites it is given; logs
/// each draw (mesh, matrix, uv, its root's scale once posed).
#[derive(Default)]
struct Canvas {
pose: Option<Arc<dyn BodyPose>>,
sprite: Option<[f32; 4]>,
drawn: Vec<(String, [[f32; 4]; 4], [f32; 4], [f32; 3])>,
/// The model's matrix (`LEFT_ARM` if None) and how it moves.
base: Option<glam::Mat4>,
motion: horizon_hooks::players::Motion,
/// Each draw's look: see-through alpha and light.
looks: Vec<(Option<f32>, horizon_hooks::players::Light)>,
}
const LEFT_ARM: [[f32; 4]; 4] = [[2.0, 0.0, 0.0, 0.0]; 4];
impl PlayerDraw for Canvas {
fn pass(&self) -> horizon_hooks::players::Pass {
horizon_hooks::players::Pass::World
}
fn ticks(&self) -> f32 {
102.0
}
fn base(&self) -> [[f32; 4]; 4] {
self.base.map_or(LEFT_ARM, |b| b.to_cols_array_2d())
}
fn motion(&self) -> horizon_hooks::players::Motion {
self.motion
}
fn view(&self) -> horizon_hooks::players::View {
Default::default()
}
fn particles(
&mut self,
_: [f32; 4],
_: &[horizon_hooks::players::Particle],
_: horizon_hooks::players::Light,
) {
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
(bone == Bone::LeftArm).then_some(LEFT_ARM)
}
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
self.pose.as_ref()
}
fn sprite(&self, _: u64) -> Option<[f32; 4]> {
self.sprite
}
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn horizon_hooks::players::PartPoses),
) {
struct Rest(std::collections::HashMap<String, [f32; 9]>);
impl horizon_hooks::players::PartPoses for Rest {
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
self.0.entry(name.to_owned()).or_insert([0.0; 9])
}
}
let mut rest = Rest([(mesh.name.clone(), mesh.pose)].into());
pose(&mut rest);
let root = rest.0[&mesh.name];
self.looks.push((look.see_through, look.light));
self.drawn.push((
mesh.name.clone(),
matrix,
look.uv,
[root[6], root[7], root[8]],
));
}
}
#[test]
fn an_emotes_prop_goes_on_its_bone_once_its_texture_is_in_scaled_by_the_blend() {
let pop = CROWN
.replace(
r#""slot": "head", "bone": "head""#,
r#""slot": "emote", "bone": "left_arm""#,
)
.replace(
r#""uv": [0, 0]}]}]}"#,
r#""uv": [0, 0]}]}],
"emote": {"length": 40, "blend": 4, "tracks": [
{"part": "right_arm", "keys": [{"t": 0}, {"t": 40}]}]}}"#,
);
let item = Arc::new(Item::parse(pop.as_bytes()).unwrap());
let playing = horizon_cosmetics::Playing {
item: item.clone(),
start: 100.0,
stop: None,
};
let (_, weight) = playing.at(102.0).unwrap();
assert!(weight > 0.0 && weight < 1.0, "{weight}");
let player = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
let mut view = View(vec![(player, None)]);
emotes::set_model_playing(&mut view, 9, Some(playing));
let mut canvas = Canvas {
pose: view.0[0].1.clone(),
..Canvas::default()
};
// Others' cosmetics hidden, no service: the player's own emote still shows.
let mut seat = Seat {
said: Said {
others_hidden: true,
..Said::default()
},
..Seat::default()
};
// Its texture not in the atlas yet: nothing drawn, the sprite asked for.
seat.player_layers(&player, &mut canvas);
assert!(canvas.drawn.is_empty());
assert_eq!(seat.wanted.len(), 1);
canvas.sprite = Some([0.0, 0.0, 0.5, 0.5]);
seat.player_layers(&player, &mut canvas);
// A 16×16 texture: the slot's top left quarter of it.
let quarter = 0.5 * 16.0 / 64.0;
assert_eq!(canvas.drawn.len(), 1);
let (_, matrix, uv, scale) = &canvas.drawn[0];
assert_eq!((matrix, uv), (&LEFT_ARM, &[0.0, 0.0, quarter, quarter]));
for s in scale {
assert!((s - weight).abs() < 1e-6, "{scale:?} by {weight}");
}
}
#[test]
fn an_aura_goes_see_through_at_the_feet_unless_swimming_far_or_hidden() {
use horizon_hooks::players::{Light, Motion};
let ring = CROWN
.replace(r#""slot": "head", "bone": "head""#, r#""slot": "aura""#)
// At the feet: an aura reaches at most 6 pixels above them.
.replace(r#""attach": {"offset": [0, -18, 0]},"#, "")
.replace(
r#""uv": [0, 0]}]}]}"#,
r#""uv": [0, 0]}]}], "aura": {"at": "feet", "alpha": 0.5}}"#,
);
let ring = Arc::new(Item::parse(ring.as_bytes()).unwrap());
let at = glam::Vec3::new(3.0, 0.0, 4.0);
let mut canvas = Canvas {
sprite: Some([0.0, 0.0, 0.5, 0.5]),
base: Some(glam::Mat4::from_translation(at)),
..Canvas::default()
};
let other = PlayerModel::new(9, Kind::Player, Some(5), 102, false);
let own = PlayerModel::new(1, Kind::Own, None, 102, false);
let mut seat = Seat::default();
seat.fx(&other, &ring, &mut canvas);
// Half see-through, lit as the wearer, 24 pixels under the neck.
assert_eq!(canvas.looks, [(Some(0.5), Light::Model)]);
let feet = glam::Mat4::from_cols_array_2d(&canvas.drawn[0].1).w_axis;
assert_eq!(feet.truncate(), at + glam::Vec3::new(0.0, 1.5, 0.0));
// Swimming, it would tip over with the body: none.
canvas.motion = Motion::default().poses(false, false, false, true);
seat.fx(&other, &ring, &mut canvas);
canvas.motion = Motion::default();
// Past 48 blocks: none.
canvas.base = Some(glam::Mat4::from_translation(glam::Vec3::X * 49.0));
seat.fx(&other, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 1);
// Others' trails and auras hidden: theirs go, the player's own stays.
canvas.base = Some(glam::Mat4::from_translation(at));
seat.said.hide_fx = true;
seat.fx(&other, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 1);
seat.fx(&own, &ring, &mut canvas);
assert_eq!(canvas.looks.len(), 2);
}
}
@@ -71,11 +71,6 @@ pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
+160 -255
View File
@@ -1,47 +1,78 @@
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
//! to a server.
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
//! or is sent to a server.
use super::{FULL_LIGHT, OWN};
use crate::game::Game;
use super::Seat;
use glam::{DVec3, Mat4, Vec3};
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part};
use horizon_client::fast_hash::FxHashMap;
use horizon_cosmetics::follow::{self, Follow};
use horizon_cosmetics::trail::Emitter;
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
use std::sync::Arc;
use std::time::Instant;
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
const FAR: f32 = 48.0;
/// Who wears it, as drawn this frame.
pub(super) struct Wearer<'a> {
pub m: &'a Mob,
/// The player's own model.
pub own: bool,
/// `OWN`, or the entity id.
pub key: u32,
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
pub base: Mat4,
pub camera: DVec3,
pub alpha: f32,
pub light: u32,
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped).
#[derive(Default)]
pub(super) struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
frame: u32,
/// Kept so a frame of trails allocates none.
particles: Vec<Particle>,
}
/// `m`'s head yaw this frame (degrees).
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
m.previous_head_yaw + turn * alpha
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
pub(super) fn frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let frame = self.frame;
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
}
/// Whether `m` lies in a bed.
fn asleep(m: &Mob) -> bool {
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let (frame, dt) = (self.frame, self.dt);
let (f, mark) = self
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
}
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
}
impl Game<'_> {
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn cosmetics_fx(
&self,
at: &Wearer,
item: &Arc<Item>,
gpu: bool,
bounds: Option<(Vec3, Vec3)>,
poses: &mut Poses,
out: &mut Vec<Vertex>,
) -> Option<(Vec3, Vec3)> {
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
return bounds;
/// Full light for what glows, else `otherwise`.
fn glowing(glow: bool, otherwise: Light) -> Light {
if glow { Light::Full } else { otherwise }
}
impl Seat {
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
/// trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
let own = m.kind == Kind::Own;
if !own && item.slot.is_effect() && self.said.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
// budget).
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
return bounds;
let base = Mat4::from_cols_array_2d(&draw.base());
if base.w_axis.truncate().length_squared() > FAR * FAR {
return;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
return;
};
let key = if own { OWN } else { m.id };
match item.slot {
Slot::Companion => {
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
return bounds;
};
self.pose_companion(item, at.m, at.alpha, poses, &model);
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
let light = if glow { FULL_LIGHT } else { at.light };
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
}
Slot::Trail => {
self.trail(at, item);
bounds
}
Slot::Aura => {
self.aura(at, item, poses);
bounds
}
_ => bounds,
Slot::Companion => self.companion(key, item, uv, base, draw),
Slot::Trail => self.trail(key, item, uv, draw),
Slot::Aura => self.aura(item, uv, base, draw),
_ => {}
}
}
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
/// `first_person`: the camera's forward and left, for the player's own in first person: the
/// companion keeps out of the middle of the view.
fn companion_at(
&self,
at: &Wearer,
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
/// out of the middle of the view, lit where it floats.
fn companion(
&mut self,
key: u32,
item: &Arc<Item>,
first_person: Option<(Vec3, Vec3)>,
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
let c = item.companion.as_ref()?;
let (models, uv) = self.cosmetics_baked(item)?;
let model = models.first()?.1.clone();
let scale = at.base.x_axis.truncate().length();
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = at
.base
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let yaw = head_yaw(at.m, at.alpha);
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
uv: [f32; 4],
base: Mat4,
draw: &mut dyn PlayerDraw,
) {
let Some(c) = item.companion.as_ref() else {
return;
};
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
let first_person = draw.pass() == Pass::FirstPerson;
let (motion, view) = (draw.motion(), draw.view());
let camera = DVec3::from(view.camera);
let scale = base.x_axis.truncate().length();
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let follow = self
.fx
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
let mut rel = (follow.pos - at.camera).as_vec3();
if let Some((forward, left)) = first_person {
rel = follow::out_of_view(rel, forward, left);
let mut rel = (follow.pos - camera).as_vec3();
if first_person {
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
}
rel = follow::clear_of(rel, head, clearance);
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
}
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let dt = fx.dt;
let (f, mark) = fx
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
/// The companion's animations, and its `look` part turned with the wearer's pitch.
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
poses.reset(model);
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
}
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
let near = if first_person {
Light::AtPlace
} else {
Light::Model
};
let look = Look::new(uv).lit(glowing(c.glow, near));
let ticks = draw.ticks();
// Its animations, and its `look` part turned with the wearer's pitch.
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
draw.draw(&mesh, matrix, look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let Some(part) = c.look.as_deref() {
p.pose(part)[3] += pitch;
}
});
}
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
/// asleep), facing the camera.
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
let Some(t) = &item.trail else {
return;
};
let Some((_, uv)) = self.cosmetics_baked(item) else {
return;
};
let m = at.m;
let a = f64::from(at.alpha);
let feet = DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
);
let moves = !m.extra.crouching && !asleep(m);
let q = self.camera_orientation();
let (right, up) = (q * Vec3::X, q * Vec3::Y);
let light = if t.glow { FULL_LIGHT } else { at.light };
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let super::Fx {
let (motion, view) = (draw.motion(), draw.view());
let moves = !motion.crouching && !motion.asleep;
let Fx {
trails,
budget,
now,
clear,
frame,
particles,
..
} = &mut *fx;
} = &mut self.fx;
let (e, mark) = trails
.entry(at.key)
.entry(key)
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
if *mark != frame {
e.step(t, feet, moves, *now, budget);
*mark = frame;
}
for quad in e.quads(t, *now, at.camera, right, up) {
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
clear.push(Vertex {
pos: corner.to_array(),
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
if *mark != *frame {
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
*mark = *frame;
}
let (camera, right, up) = (
DVec3::from(view.camera),
Vec3::from(view.right),
Vec3::from(view.up),
);
particles.clear();
particles.extend(
e.quads(t, *now, camera, right, up)
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
);
draw.particles(uv, particles, glowing(t.glow, Light::Model));
}
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
/// tip over with the body.
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
let (m, Some(a)) = (at.m, &item.aura) else {
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
let Some(a) = &item.aura else {
return;
};
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
let motion = draw.motion();
if motion.flying || motion.asleep || motion.swimming {
return;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
return;
};
let Some((_, model)) = models.first() else {
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
// The model's origin is the middle of the feet: 24 pixels under the neck.
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
poses.reset(model);
item.animate(m.age as f32 + at.alpha, |name, pose| {
*poses.get(name) = pose
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
let look = Look::new(uv)
.lit(glowing(a.glow, Light::Model))
.see_through(a.alpha);
let ticks = draw.ticks();
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose)
});
let light = if a.glow { FULL_LIGHT } else { at.light };
let mut fx = self.cosmetics.fx.borrow_mut();
let start = fx.clear.len();
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
&mut fx.clear,
);
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
for v in &mut fx.clear[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one (and a card's two
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
}
/// After every player: the player's own companion in first person (out of the middle of the
/// view, after the first-person model went to the shadow pass only), then the frame's trails
/// and auras in one see-through span.
pub(in crate::game) fn cosmetics_fx_geometry(
&self,
camera: DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if !super::enabled() {
return;
}
if !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
self.mark_entity(out.len(), "minecraft:player");
self.own_companion_first_person(m, camera, alpha as f32, out);
}
let fx = self.cosmetics.fx.borrow();
if !fx.clear.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&fx.clear);
}
}
fn own_companion_first_person(
&self,
m: &Mob,
camera: DVec3,
alpha: f32,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
return;
}
let Some(worn) = self.worn(m, true) else {
return;
};
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
return;
};
let at = Wearer {
m,
own: true,
key: OWN,
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
camera,
alpha,
light: FULL_LIGHT,
};
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
return;
};
// Lit where it floats.
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if item.companion.as_ref().is_some_and(|c| c.glow) {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
}
}
-49
View File
@@ -1,49 +0,0 @@
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
use super::*;
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
if !self.settings.extras.discord_presence {
return Status::Off;
}
if self.title.is_some() {
return Status::Menus;
}
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
} else if self.remote.is_some() {
Play::Multiplayer
} else {
Play::Singleplayer
};
Status::World {
play,
dimension: match self.dim {
Dim::Overworld => Dimension::Overworld,
Dim::Nether => Dimension::Nether,
Dim::End => Dimension::End,
},
mode: self.mode,
}
}
}
+675 -244
View File
@@ -1,5 +1,5 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! page (`PAGE`, what the pause menu's Friends icon opens) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
@@ -7,18 +7,32 @@
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
//!
//! It sees the game only through `horizon_hooks` (the `ShareView` its `Hooks::share` tick
//! gets, `Hooks::lan_closed`, its page, `tr`) and keeps what it shares in its own statics, so
//! it can leave the engine for horizon-friends (spec #14167). The `horizon-ctl friends` command
//! below still reaches it from `Game` until then.
use super::Game;
use horizon_client::Result;
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_hooks::{
Hooks,
pages::{Act, Page, Place, Row},
share::{LanOptions, ShareView},
};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
mod screen;
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
opened_lan: bool,
@@ -31,12 +45,112 @@ pub(super) struct Sharing {
paths: (u32, u32),
}
/// The world shared, if one is. The game has one window player who can share (the engine
/// calls the hooks for it only), so one is all there is.
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
static ON: AtomicBool = AtomicBool::new(false);
/// What the page left for the next [`Hooks::share`] tick, which has the game's view.
#[derive(Default)]
struct PageState {
/// Chat lines (new codes, the relay stopping), said in order.
chat: Vec<String>,
/// A button pressed on the page.
pressed: Option<Action>,
/// The page was drawn since the last tick: its guests are read again.
open: bool,
/// The other players, as the last tick while the page was open read them.
guests: Vec<String>,
/// The action of each row the page last showed.
acts: Vec<Option<Action>>,
}
static PAGE_STATE: Mutex<PageState> = Mutex::new(PageState {
chat: Vec::new(),
pressed: None,
open: false,
guests: Vec::new(),
acts: Vec::new(),
});
/// Whether [`PAGE_STATE`] has something for the tick.
static WORK: AtomicBool = AtomicBool::new(false);
fn page() -> MutexGuard<'static, PageState> {
PAGE_STATE.lock().unwrap_or_else(PoisonError::into_inner)
}
/// A button of the page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
}
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
}
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
fn take() -> Option<Sharing> {
let taken = sharing().take();
ON.store(false, Ordering::Relaxed);
taken
}
/// Registered by the engine's entry while this module is part of it.
pub struct Friends;
pub static HOOKS: Friends = Friends;
impl Hooks for Friends {
/// What the page left for the game: chat lines, a button's press, the guests while it is
/// open. With nothing left, one relaxed load.
fn share(&self, world: &mut dyn ShareView) {
if !WORK.swap(false, Ordering::Relaxed) {
return;
}
let (lines, pressed, open) = {
let mut page = page();
let open = std::mem::take(&mut page.open);
(std::mem::take(&mut page.chat), page.pressed.take(), open)
};
for line in lines {
world.chat(&line);
}
if let Some(action) = pressed {
press(action, world);
}
if open {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
page().guests = guests;
}
}
/// The LAN server is closing: the code goes with it.
fn lan_closed(&self) {
if ON.load(Ordering::Relaxed)
&& let Some(mut sharing) = take()
{
sharing.host.stop();
}
}
}
pub const HELP: &[&str] = &[
"friends host share the open world behind an invite code",
"friends stop stop sharing",
"friends status the code, how friends reach this host, the guests",
];
/// A string in the player's language, or the English one.
fn tr(key: &str, english: &str) -> String {
horizon_hooks::tr(key).unwrap_or_else(|| english.to_owned())
}
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
fn stun_servers() -> Vec<String> {
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
@@ -53,101 +167,98 @@ fn env_flag(name: &str, default: bool) -> bool {
std::env::var(name).map_or(default, |v| v != "0")
}
impl Game<'_> {
/// Start sharing the open world.
pub(super) fn friends_start(&mut self) -> Result<()> {
if self.friends.is_some() {
return Ok(());
}
// Single player on its server thread shares that server, as Open to
// LAN publishes it; another server can't be shared.
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
return Err("open a singleplayer world first".into());
}
let opened_lan = self.lan_port().is_none();
if opened_lan {
// ShareToLanScreen's defaults, as the world options screen offers them.
self.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: false,
force_game_mode: true,
})?;
}
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
let host = match Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
}) {
Ok(h) => h,
Err(e) => {
if opened_lan {
self.lan_close(true);
}
return Err(e.into());
/// Start sharing the open world.
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
if ON.load(Ordering::Relaxed) {
return Ok(());
}
if !world.can_share() {
return Err("open a singleplayer world first".into());
}
let (port, opened_lan) = match world.lan_port() {
Some(port) => (port, false),
// ShareToLanScreen's defaults, as the world options screen offers them.
None => (world.open_lan(LanOptions::default())?, true),
};
let host = Host::start(HostConfig {
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
protocol: PROTOCOL_VERSION,
game: GAME_VERSION.to_owned(),
relay: horizon_client::net::friends_relay_setting(),
path: PathMode::from_env(),
port: std::env::var("HORIZON_FRIENDS_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(DEFAULT_PORT),
reach: reach::Options {
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
stun: stun_servers(),
// Both ends on one machine (tests).
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
},
});
let host = match host {
Ok(h) => h,
Err(e) => {
if opened_lan {
world.close_lan();
}
};
self.friends = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
Ok(())
}
return Err(e.to_string());
}
};
*sharing() = Some(Sharing {
host,
opened_lan,
code: None,
wrong: 0,
notice: None,
copied: None,
paths: (0, 0),
});
ON.store(true, Ordering::Relaxed);
Ok(())
}
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
pub(super) fn friends_stop(&mut self) {
let Some(mut sharing) = self.friends.take() else {
/// Stop sharing: the code ends (direct port closed, router mapping
/// deleted); the world closes to LAN again if sharing opened it.
fn stop(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
let Some(mut sharing) = take() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
world.close_lan();
} else {
let text = tr(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
);
world.chat(&text);
}
}
/// New codes and sessions, while the page is drawn and when `friends status` asks. Its chat
/// lines wait for the next tick ([`Hooks::share`]), or for [`say`].
fn poll() {
if !ON.load(Ordering::Relaxed) {
return;
}
let mut announce = false;
let mut stopped = None;
{
let mut guard = sharing();
let Some(sharing) = guard.as_mut() else {
return;
};
sharing.host.stop();
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
/// The LAN server is closing: the code goes with it.
pub(super) fn friends_lan_closed(&mut self) {
if let Some(mut sharing) = self.friends.take() {
sharing.host.stop();
}
}
/// New codes and sessions, once a tick and while the pause menu is drawn.
pub(super) fn tick_friends(&mut self) {
let Some(sharing) = self.friends.as_mut() else {
return;
};
let mut announce = false;
let mut stopped = None;
for event in sharing.host.poll() {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
@@ -158,7 +269,7 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some(lang::get_or(
sharing.notice = Some(tr(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
@@ -167,7 +278,7 @@ impl Game<'_> {
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
HostEvent::Disconnected { .. } => {}
HostEvent::RelayStopped => {
let text = lang::get_or(
let text = tr(
"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.",
);
@@ -176,164 +287,484 @@ impl Game<'_> {
}
}
}
if let Some(text) = stopped {
self.system_message(Text::literal(text));
}
if announce {
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
)));
}
}
let mut page = page();
page.chat.extend(stopped);
if announce {
page.chat.push(tr(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
}
if !page.chat.is_empty() {
WORK.store(true, Ordering::Relaxed);
}
}
pub(super) fn friends_copy(&mut self) {
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
return;
};
use horizon_client::clipboard::Clipboard;
self.clipboard.set(&code);
if let Some(s) = self.friends.as_mut() {
s.copied = Some(Instant::now());
}
/// The chat lines waiting, said now.
fn say(world: &mut dyn ShareView) {
let lines = std::mem::take(&mut page().chat);
for line in lines {
world.chat(&line);
}
}
/// Play with Friends. The world is paused under it in singleplayer, so
/// new codes and sessions are read while it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
self.tick_friends();
let view = self.friends_view();
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
});
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
}
pub(super) fn friends_screen_click(&mut self) {
let view = self.friends_view();
let Some(action) =
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
else {
return;
};
self.audio.play_button_click();
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
/// A button of the page, on the tick after its press.
fn press(action: Action, world: &mut dyn ShareView) {
match action {
Action::Share => {
if let Err(e) = start(world) {
let text = tr(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e, 1);
world.chat(&text);
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_uri(url);
}
}
Action::Done => self.screen = super::Screen::Paused,
}
Action::Copy => copy(world),
Action::Stop => stop(world),
// The system browser, as the folder buttons open their folders.
Action::Help => world.open_url(reach::HELP_URL),
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
fn copy(world: &mut dyn ShareView) {
if !world.can_share() {
return;
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
// On the server thread: the other players the server lists.
None if self.integrated.is_some() => {
let me = self.options.player_name();
let mut names = self.remote_online_names().unwrap_or_default();
names.retain(|n| *n != me);
names
}
None => Vec::new(),
};
let Some(s) = &self.friends else {
return FriendsView {
shown: self.remote.is_none() || self.integrated.is_some(),
..FriendsView::default()
};
};
let state = s.host.state();
let mut status = if s.code.is_none() {
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
FriendsView {
shown: true,
sharing: true,
code: s.code.clone(),
status,
reachable,
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
guests,
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
return;
};
world.copy(&code);
if let Some(s) = sharing().as_mut() {
s.copied = Some(Instant::now());
}
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
use serde_json::json;
if words[0] != "friends" {
return Ok(None);
}
match words.get(1).copied() {
Some("host") => {
self.friends_start()?;
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
}
Some("stop") => {
self.friends_stop();
Ok(Some(json!({ "ok": true })))
}
Some("status") => {
self.tick_friends();
let view = self.friends_view();
let state = self.friends.as_ref().map(|s| s.host.state());
Ok(Some(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": self.lan_port(),
})))
}
_ => Err("friends host|stop|status".into()),
/// What Play with Friends shows.
#[derive(Clone, Debug, Default)]
struct View {
sharing: bool,
code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
status: String,
/// Friends on the internet can reach the host.
reachable: bool,
/// The players in the world besides the host.
guests: Vec<String>,
notice: Option<String>,
/// The code was just copied.
copied: bool,
}
/// What Play with Friends shows; `guests` is asked only while sharing.
fn view(guests: impl FnOnce() -> Vec<String>) -> View {
let guard = sharing();
let Some(s) = guard.as_ref() else {
return View::default();
};
let state = s.host.state();
let mut status = if s.code.is_none() {
tr(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
if let Some(short) = &state.short {
status.push_str(&format!(" Short code for the relay: {short}."));
}
if let Some(problem) = &state.relay_problem {
status.push(' ');
status.push_str(problem);
}
let reachable = matches!(
state.verdict,
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
) || state.short.is_some();
View {
sharing: true,
code: s.code.clone(),
status,
reachable,
guests: guests(),
notice: s.notice.clone(),
copied: s
.copied
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
}
}
/// The page's rows for `view`, each with the button's action: the layout today's Friends
/// screen had (the engine draws it, `game/page_screen.rs`).
fn rows(view: &View, rows: &mut Vec<Row>, acts: &mut Vec<Option<Action>>) {
let mut row = |row: Row, act: Option<Action>| {
rows.push(row);
acts.push(act);
};
let hint = || {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
};
if !view.sharing {
row(
Row::text(tr(
"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.",
)),
None,
);
row(Row::Space(4), None);
row(Row::text(hint()).quiet(), None);
row(Row::Space(8), None);
row(
Row::button("horizon.friends.start_sharing"),
Some(Action::Share),
);
return;
}
row(
Row::text(tr("horizon.multiplayer.invite_code", "Invite Code")).quiet(),
None,
);
row(Row::Space(2), None);
match &view.code {
Some(code) => row(Row::text(code.clone()).groups(), None),
None => row(Row::text(view.status.clone()), None),
}
row(Row::Space(6), None);
let copy = if view.copied {
"horizon.friends.copied"
} else {
"horizon.friends.copy_code"
};
row(
Row::button(copy).half().active(view.code.is_some()),
Some(Action::Copy),
);
row(
Row::button("horizon.friends.stop_sharing").half(),
Some(Action::Stop),
);
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let status = Row::text(view.status.clone());
row(
if view.reachable {
status.quiet()
} else {
status
},
None,
);
row(Row::Space(4), None);
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen("%s", &view.guests.join(", "), 1)
};
row(Row::text(here).quiet(), None);
if let Some(notice) = &view.notice {
row(Row::text(notice.clone()), None);
}
// Where friends paste the code, while it fits above the footer.
row(Row::text(hint()).quiet(), None);
// Troubleshooting beside Done when friends on the internet can't reach the host.
if !view.reachable && view.code.is_some() {
row(
Row::button("horizon.friends.troubleshooting").footer(),
Some(Action::Help),
);
}
}
/// Play with Friends' page: what the pause menu's Friends icon opens. Its rows are read when
/// it is drawn; a press waits for the next tick, which has the game's view.
pub struct FriendsPage;
pub static PAGE: FriendsPage = FriendsPage;
impl Page for FriendsPage {
fn id(&self) -> &'static str {
"horizon_friends.share"
}
fn title(&self) -> &'static str {
"horizon.friends.title"
}
fn place(&self) -> Place {
Place::PauseFriends
}
fn rows(&self, out: &mut Vec<Row>) {
// The world is paused under the page in singleplayer, so new codes and sessions are
// read while it is drawn.
poll();
let guests = std::mem::take(&mut page().guests);
let view = view(|| guests);
let mut acts = Vec::new();
rows(&view, out, &mut acts);
let mut page = page();
page.guests = view.guests;
page.acts = acts;
page.open = true;
WORK.store(true, Ordering::Relaxed);
}
fn act(&self, row: usize, act: Act) {
let mut page = page();
if act == Act::Press
&& let Some(Some(action)) = page.acts.get(row).copied()
{
page.pressed = Some(action);
WORK.store(true, Ordering::Relaxed);
}
}
}
/// `horizon-ctl friends host|stop|status`.
fn control(
word: Option<&str>,
world: &mut dyn ShareView,
) -> std::result::Result<serde_json::Value, String> {
use serde_json::json;
match word {
Some("host") => {
start(world)?;
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
}
Some("stop") => {
stop(world);
Ok(json!({ "ok": true }))
}
Some("status") => {
poll();
say(world);
let view = if world.can_share() {
view(|| {
let mut guests = Vec::new();
world.players(&mut |name| guests.push(name.to_owned()));
guests
})
} else {
View::default()
};
let guard = sharing();
let sharing = guard.as_ref().filter(|_| view.sharing);
let state = sharing.map(|s| s.host.state());
Ok(json!({
"sharing": view.sharing,
"code": view.code,
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
"summary": view.status,
"reachable": view.reachable,
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
"direct_port": state.as_ref().map(|s| s.port),
"sessions": sharing.map_or(0, |s| s.host.sessions()),
"direct": sharing.map_or(0, |s| s.paths.0),
"relayed": sharing.map_or(0, |s| s.paths.1),
"guests": view.guests,
"lan_port": world.lan_port(),
}))
}
_ => Err("friends host|stop|status".into()),
}
}
impl Game<'_> {
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
return Ok(None);
}
Ok(Some(control(
words.get(1).copied(),
&mut self.share_view(),
)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A world as the engine shows it, recording what the crate did to it.
#[derive(Default)]
struct Fake {
can_share: bool,
lan: Option<u16>,
refuse_lan: bool,
did: Vec<String>,
}
impl ShareView for Fake {
fn can_share(&self) -> bool {
self.can_share
}
fn lan_port(&self) -> Option<u16> {
self.lan
}
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
self.did.push(format!("open_lan {options:?}"));
if self.refuse_lan {
return Err("no free port".into());
}
self.lan = Some(25565);
Ok(25565)
}
fn close_lan(&mut self) {
self.did.push("close_lan".into());
self.lan = None;
}
fn players(&self, each: &mut dyn FnMut(&str)) {
each("Alex");
}
fn chat(&mut self, text: &str) {
self.did.push(format!("chat {text}"));
}
fn copy(&mut self, text: &str) {
self.did.push(format!("copy {text}"));
}
fn open_url(&mut self, url: &str) {
self.did.push(format!("open_url {url}"));
}
fn tr(&self, _key: &str) -> Option<String> {
None
}
}
#[test]
fn a_world_that_cannot_be_shared_is_left_alone() {
// Another server, or a splitscreen guest in P1's world.
let mut guest = Fake {
lan: Some(25565),
..Fake::default()
};
assert_eq!(
control(Some("host"), &mut guest).unwrap_err(),
"open a singleplayer world first"
);
stop(&mut guest);
copy(&mut guest);
assert!(guest.did.is_empty(), "{:?}", guest.did);
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
assert_eq!(start(&mut world).unwrap_err(), "no free port");
assert_eq!(
world.did,
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
);
// Offered, not shared: the guests aren't asked for.
let view = view(|| unreachable!("the guests of a world not shared"));
assert!(!view.sharing && view.guests.is_empty());
assert!(!ON.load(Ordering::Relaxed));
}
#[test]
fn the_page_shows_todays_screen_and_a_press_runs_on_the_next_tick() {
// Sharing, with a code friends on the internet can't reach yet.
let shared = View {
sharing: true,
code: Some("K7QM2-AAAAA".into()),
status: "Friends on your network can join.".into(),
..View::default()
};
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&shared, &mut out, &mut acts);
let buttons: Vec<_> = out
.iter()
.zip(&acts)
.filter_map(|(row, act)| match row {
Row::Button {
label,
active,
half,
footer,
..
} => Some((*label, *active, *half, *footer, *act)),
_ => None,
})
.collect();
assert_eq!(
buttons,
[
(
"horizon.friends.copy_code",
true,
true,
false,
Some(Action::Copy)
),
(
"horizon.friends.stop_sharing",
true,
true,
false,
Some(Action::Stop)
),
(
"horizon.friends.troubleshooting",
true,
false,
true,
Some(Action::Help)
),
]
);
assert!(out.iter().any(
|row| matches!(row, Row::Text { text, groups: true, .. } if text == "K7QM2-AAAAA")
));
// Before the code there is nothing to copy.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(
&View {
code: None,
..shared
},
&mut out,
&mut acts,
);
assert!(out.iter().any(|row| matches!(
row,
Row::Button {
label: "horizon.friends.copy_code",
active: false,
..
}
)));
// Not sharing: Start Sharing. Its press waits for the tick, which has the world.
let (mut out, mut acts) = (Vec::new(), Vec::new());
rows(&View::default(), &mut out, &mut acts);
let share = acts.iter().position(|a| *a == Some(Action::Share)).unwrap();
page().acts = acts;
PAGE.act(share, Act::Press);
let mut world = Fake {
can_share: true,
refuse_lan: true,
..Fake::default()
};
HOOKS.share(&mut world);
assert_eq!(
world.did,
[
"open_lan LanOptions { guest_commands: false, force_game_mode: true }",
"chat Couldn't share the world: no free port",
]
);
// Pressed once, run once.
HOOKS.share(&mut world);
assert_eq!(world.did.len(), 2);
}
}
@@ -1,340 +0,0 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
/// A singleplayer world (not a server): sharing is offered.
pub shown: bool,
pub sharing: bool,
pub code: Option<String>,
/// Who can join and what to do when friends on the internet can't (or,
/// before the code, what the game is doing).
pub status: String,
/// Friends on the internet can reach the host.
pub reachable: bool,
/// Where the troubleshooting is, when they can't.
pub help: Option<String>,
/// The players in the world besides the host.
pub guests: Vec<String>,
pub notice: Option<String>,
/// The code was just copied.
pub copied: bool,
}
/// A button's action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
Share,
Copy,
Stop,
/// Open the troubleshooting page.
Help,
Done,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
fn hint() -> String {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
}
struct Button {
x: f32,
y: f32,
w: f32,
label: String,
action: Action,
active: bool,
}
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
view: &FriendsView,
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut y = 40.0;
let add = |lines: &mut Vec<(String, u32, f32)>, text: &str, color: u32, y: &mut f32| {
for line in wrap(text, width, atlas) {
lines.push((line, color, *y));
*y += 10.0;
}
};
let mut buttons = Vec::new();
if view.sharing {
lines.push((
tr("horizon.multiplayer.invite_code", "Invite Code"),
grey(),
y,
));
y += 12.0;
match &view.code {
Some(code) => {
for line in code_lines(atlas, code, width) {
lines.push((line, WHITE, y));
y += 10.0;
}
}
None => add(&mut lines, &view.status, WHITE, &mut y),
}
y += 6.0;
buttons.push(Button {
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied {
tr("horizon.friends.copied", "Copied")
} else {
tr("horizon.friends.copy_code", "Copy Code")
},
action: Action::Copy,
active: view.code.is_some(),
});
buttons.push(Button {
x: cx + 4.0,
y,
w: 150.0,
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
action: Action::Stop,
active: true,
});
y += 30.0;
if view.code.is_some() {
// Who can join: grey when friends anywhere can, white (it asks
// for something) when only the LAN and VPNs can.
let tint = if view.reachable { grey() } else { WHITE };
add(&mut lines, &view.status, tint, &mut y);
y += 4.0;
}
let here = if view.guests.is_empty() {
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
tr("horizon.friends.here_now", "Here now: %s").replacen(
"%s",
&view.guests.join(", "),
1,
)
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
let hint = hint();
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, &hint, grey(), &mut y);
}
} else {
add(
&mut lines,
&tr(
"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.",
),
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, &hint(), grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: tr("horizon.friends.start_sharing", "Start Sharing"),
action: Action::Share,
active: view.shown,
});
}
// HeaderAndFooterLayout's footer: Done, beside Troubleshooting when
// friends on the internet can't reach the host.
if view.sharing && view.help.is_some() {
buttons.push(Button {
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
action: Action::Help,
active: true,
});
buttons.push(Button {
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
} else {
buttons.push(Button {
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
}
(lines, buttons)
}
/// The code's groups packed into lines of at most `width`.
fn code_lines(atlas: &HudAtlas, code: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in code.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn label(key: &str) -> Text {
if key.starts_with("gui.") {
Text::translate(key, &[])
} else {
Text::literal(key)
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &FriendsView,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(title());
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
b.x,
b.y,
b.w,
20.0,
&label(&b.label),
b.active,
hover,
);
}
}
/// The button under a click, if it is active.
pub fn click(
atlas: &HudAtlas,
size: (f32, f32),
cursor: (f32, f32),
view: &FriendsView,
) -> Option<Action> {
layout(atlas, size, view)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.action)
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn share_then_copy_and_stop() {
let atlas = atlas();
let size = (427.0, 240.0);
let idle = FriendsView {
shown: true,
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &idle);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert_eq!(
click(&atlas, size, (share.x + 1.0, share.y + 1.0), &idle),
Some(Action::Share)
);
let sharing = FriendsView {
shown: true,
sharing: true,
status: "Finding how friends can reach you…".into(),
..FriendsView::default()
};
let (_, buttons) = layout(&atlas, size, &sharing);
let copy = buttons.iter().find(|b| b.action == Action::Copy).unwrap();
assert!(!copy.active, "nothing to copy before the code");
let with_code = FriendsView {
code: Some("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0".into()),
status: "Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address).".into(),
help: Some("https://example.invalid/help".into()),
..sharing
};
let (lines, buttons) = layout(&atlas, size, &with_code);
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"the text stays above the footer"
);
for action in [Action::Copy, Action::Stop, Action::Help, Action::Done] {
let b = buttons.iter().find(|b| b.action == action).unwrap();
assert_eq!(
click(&atlas, size, (b.x + 1.0, b.y + 1.0), &with_code),
Some(action)
);
}
}
#[test]
fn a_server_offers_no_sharing() {
let atlas = atlas();
let view = FriendsView::default();
let (_, buttons) = layout(&atlas, (427.0, 240.0), &view);
let share = buttons.iter().find(|b| b.action == Action::Share).unwrap();
assert!(!share.active);
}
}
+2 -17
View File
@@ -1,8 +1,8 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, hud::HudBatch};
use super::Game;
use horizon_client::Result;
pub const HELP: &[&str] = &[];
@@ -13,21 +13,6 @@ pub fn title() -> String {
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
pub(super) fn tick_friends(&mut self) {}
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
self.screen = Screen::Paused;
}
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
if words[0] != "friends" {
+2 -2
View File
@@ -1489,7 +1489,7 @@ impl Game<'_> {
None => self.unpublish(),
};
if closed {
self.friends_lan_closed();
self.lan_closed_hooks();
}
if closed && announce && self.integrated.is_some() {
self.system_message(Text::translate(
@@ -1499,7 +1499,7 @@ impl Game<'_> {
}
return;
}
self.friends_lan_closed();
self.lan_closed_hooks();
self.native_players_leaving();
let Some(mut host) = self.lan.take() else {
return;
@@ -34,8 +34,6 @@ pub(super) fn run_event_loop(mut game: Game<'_>) -> Result<()> {
narrator::stop();
leave_fullscreen_after(&result, &mut game.settings);
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
write_report(&game)?;
if let Some(e) = game.error.take() {
return Err(e);
@@ -98,9 +96,8 @@ impl Loading {
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -130,22 +127,25 @@ impl Loading {
// redraw the same design when they start. GDI is unsafe:
// horizon-threads holds it (this crate forbids unsafe code).
#[cfg(target_os = "windows")]
let boot_paint_stop = hwnd(&window).map(|hwnd| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
horizon_theme::fonts::GEIST_SEMIBOLD,
horizon_theme::fonts::GEIST_MONO,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
// In the fonts given to `menu_style`; with none, the window stays as it is.
let boot_paint_stop = hwnd(&window)
.zip(horizon_client::menu_style::get().fonts)
.map(|(hwnd, fonts)| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
fonts.semibold,
fonts.mono,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
stop
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
)
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
@@ -0,0 +1,321 @@
//! A page drawn as its own screen (`horizon_hooks::pages::Place::PauseFriends`, what the pause
//! menu's vanilla Friends icon opens), the way a mod adds a screen to vanilla: the menu
//! background over the paused world, the title centred at the top, the rows centred down the
//! middle, footer buttons beside Done at the bottom (`HeaderAndFooterLayout`).
use super::{Game, Screen};
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
ui,
};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
/// What a click on the screen hit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Hit {
/// The page's row with this index.
Row(usize),
Done,
}
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
hit: Hit,
active: bool,
}
fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
/// The centred lines (text, colour, y) and the buttons.
fn layout(
atlas: &HudAtlas,
(w, h): (f32, f32),
rows: &[Row],
) -> (Vec<(String, u32, f32)>, Vec<Button>) {
let width = (w - 40.0).min(310.0);
let cx = (w / 2.0).floor();
let mut lines = Vec::new();
let mut buttons = Vec::new();
let mut footer = Vec::new();
let mut y = 40.0;
// A half-width button waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.iter().enumerate() {
let beside = std::mem::take(&mut half_open);
let (text, quiet, groups) = match row {
Row::Text {
text,
quiet,
groups,
..
} => (text.clone(), *quiet, *groups),
Row::Note(text) => (text.clone(), true, false),
Row::Heading(key) => (horizon_client::lang::get(key), true, false),
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Button {
label,
active,
footer: true,
..
} => {
footer.push((r, *label, *active));
continue;
}
Row::Button {
label,
active,
half,
..
} => {
let (x, w) = match (*half, beside) {
(true, true) => {
y -= 30.0;
(cx + 4.0, 150.0)
}
(true, false) => {
half_open = true;
(cx - 154.0, 150.0)
}
(false, _) => (cx - 100.0, 200.0),
};
buttons.push(Button {
x,
y,
w,
label,
hit: Hit::Row(r),
active: *active,
});
y += 30.0;
continue;
}
// Settings rows belong in the options shell.
_ => continue,
};
let wrapped = if groups {
groups_lines(atlas, &text, width)
} else {
wrap(&text, width, atlas)
};
// Text that would run into the footer is left out.
if y + 10.0 * wrapped.len() as f32 > h - 33.0 {
continue;
}
let color = if quiet { grey() } else { WHITE };
for line in wrapped {
lines.push((line, color, y));
y += 10.0;
}
}
// The footer: the page's buttons, then Done; Done alone is wider.
footer.push((usize::MAX, "gui.done", true));
let (each, gap) = if footer.len() == 1 {
(200.0, 0.0)
} else {
(150.0, 8.0)
};
let total = footer.len() as f32 * (each + gap) - gap;
let mut x = cx - total / 2.0;
for (r, label, active) in footer {
let hit = if r == usize::MAX {
Hit::Done
} else {
Hit::Row(r)
};
buttons.push(Button {
x,
y: h - 27.0,
w: each,
label,
hit,
active,
});
x += each + gap;
}
(lines, buttons)
}
/// `-`-separated groups packed into lines of at most `width`, never broken inside a group.
fn groups_lines(atlas: &HudAtlas, text: &str, width: f32) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for group in text.split('-') {
match lines.last_mut() {
Some(l) if HudBatch::text_width(atlas, &format!("{l}-{group}")) <= width => {
l.push('-');
l.push_str(group);
}
_ => lines.push(group.to_owned()),
}
}
lines
}
fn draw(batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), title: &str, rows: &[Row]) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let title = Text::translate(title, &[]);
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), rows);
for (line, color, y) in lines {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, ((w - lw) / 2.0).floor(), y, color, true);
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if b.hit == Hit::Done {
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
let label = Text::translate(b.label, &[]);
ui::button(batch, atlas, b.x, b.y, b.w, 20.0, &label, b.active, hover);
}
}
/// The active button under a click.
fn click(atlas: &HudAtlas, size: (f32, f32), cursor: (f32, f32), rows: &[Row]) -> Option<Hit> {
layout(atlas, size, rows)
.1
.into_iter()
.find(|b| b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0))
.map(|b| b.hit)
}
/// The page the Friends icon opens: the first registered there.
fn friends_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::PauseFriends)
}
impl Game<'_> {
/// Whether the pause menu's Friends icon is active: a page is registered for it and this
/// window plays a singleplayer world (another server's world can't be shared).
pub(super) fn friends_shown(&self) -> bool {
self.can_share() && friends_page().is_some()
}
/// The Friends icon's page. Its rows are asked each time it is drawn.
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
let Some(page) = friends_page() else {
// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
self.screen = Screen::Paused;
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("friends", super::friends::TITLE)
});
draw(batch, &self.gui.atlas, self.cursor, page.title(), &rows);
}
pub(super) fn friends_screen_click(&mut self) {
let Some(page) = friends_page() else {
return;
};
let mut rows = Vec::new();
page.rows(&mut rows);
let Some(hit) = click(&self.gui.atlas, self.gui_size(), self.cursor, &rows) else {
return;
};
self.audio.play_button_click();
match hit {
Hit::Row(r) => page.act(r, Act::Press),
Hit::Done => self.screen = Screen::Paused,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn at(buttons: &[Button], hit: Hit) -> &Button {
buttons.iter().find(|b| b.hit == hit).unwrap()
}
#[test]
fn halves_sit_side_by_side_and_footer_buttons_beside_done() {
let atlas = atlas();
let size = (427.0, 240.0);
let rows = [
Row::text("Invite Code").quiet(),
Row::Space(2),
Row::text("0B33K-Q405Q-1Z5CV-X6NHY-R12N0-VK8T1-60N00-RA134-952PE-1HA08-910P0-K5M01-ST1NB-JDTB6-K4W1P-0").groups(),
Row::Space(6),
Row::button("copy").half().active(false),
Row::button("stop").half(),
Row::text("Friends on your network or VPN can join. For friends on the internet: turn on UPnP on your router, or forward TCP port 25580 to 10.0.0.2, or use a relay, or a VPN such as Tailscale or ZeroTier (the code already holds your VPN address)."),
Row::button("help").footer(),
];
let (lines, buttons) = layout(&atlas, size, &rows);
let (copy, stop) = (at(&buttons, Hit::Row(4)), at(&buttons, Hit::Row(5)));
// 213 is the middle: two 150-wide halves 8 apart around it, as vanilla's pairs.
assert_eq!(
(copy.x, copy.w, stop.x, stop.w),
(59.0, 150.0, 217.0, 150.0)
);
assert_eq!(copy.y, stop.y);
let (help, done) = (at(&buttons, Hit::Row(7)), at(&buttons, Hit::Done));
assert_eq!(
(help.x, done.x, help.y, done.y),
(59.0, 217.0, 213.0, 213.0)
);
// The code breaks between its groups only.
let code: Vec<_> = lines
.iter()
.filter(|(_, c, _)| *c == WHITE)
.map(|(l, ..)| l)
.collect();
assert!(code[0].starts_with("0B33K-") && code.iter().all(|l| !l.ends_with('-')));
assert!(
lines.iter().all(|(_, _, y)| y + 9.0 <= size.1 - 27.0),
"above the footer"
);
// An inactive button takes no click; Done alone is 200 wide.
assert_eq!(
click(&atlas, size, (copy.x + 1.0, copy.y + 1.0), &rows),
None
);
assert_eq!(
click(&atlas, size, (stop.x + 1.0, stop.y + 1.0), &rows),
Some(Hit::Row(5))
);
let (_, alone) = layout(&atlas, size, &[Row::button("share")]);
assert_eq!(at(&alone, Hit::Done).w, 200.0);
assert_eq!(at(&alone, Hit::Row(0)).x, 113.0);
}
#[test]
fn text_that_would_reach_the_footer_is_left_out() {
let atlas = atlas();
let rows = [
Row::Space(150),
Row::text("first"),
Row::text("too low").quiet(),
];
let (lines, _) = layout(&atlas, (427.0, 240.0), &rows);
// 40 + 150 = 190: one line fits above 207, the next (200..210) doesn't.
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].0, "first");
}
}
+4 -17
View File
@@ -801,19 +801,8 @@ impl Game<'_> {
packed,
out,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
self.cosmetics_geometry(
m,
true,
base,
Some(camera),
alpha as f32,
packed,
false,
out,
);
}
let world = horizon_hooks::players::Pass::World;
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
@@ -927,10 +916,8 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
}
let picture = horizon_hooks::players::Pass::Picture;
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
+147 -51
View File
@@ -738,14 +738,11 @@ impl Game<'_> {
});
self.set_pushed_on_ground(target, limited, resolved);
// Entity.move updates ground before its tiny-collision return. This
// native slice covers only unoccupied server NoAI horizontal moves.
if let Target::Entity(EntityHandle::Mob(i)) = target {
// shortcut: unoccupied server mobs only, expand when controller/passenger authority is bound.
let mob = if let Target::Entity(EntityHandle::Mob(i)) = target {
let m = &self.mobs[i];
let eligible = !self.options.execution_role.is_client()
(!self.options.execution_role.is_client()
&& self.remote.is_none()
&& limited.y == 0.0
&& m.no_ai
&& !m.extra.passenger
&& m.extra.mount.is_none()
&& m.rider.is_none()
@@ -757,16 +754,22 @@ impl Game<'_> {
.guests
.iter()
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
});
if eligible {
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
}))
.then_some(i)
} else {
None
};
// Entity.move updates ground before its tiny-collision return.
if let Some(i) = mob
&& self.mobs[i].no_ai
{
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
let tnt_after_move =
tnt.is_some() && !self.options.execution_role.is_client() && self.remote.is_none();
if mob.is_none() && !tnt_after_move {
self.restitute_piston(target, limited, resolved);
}
// `Entity.move(MoverType.PISTON, …)` restitution, which runs whether or
// not the step is applied: so a primed TNT in a chunk that does not tick
// entities still loses its velocity into walls and ceilings and
// bounces on slime (#12022).
self.restitute_piston(target, limited, resolved);
let moved = resolved.length_squared() > 1.0e-7
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
@@ -775,6 +778,12 @@ impl Game<'_> {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
// Restitution reads the new landing block even when the push was blocked.
if let Some(i) = mob {
self.mobs[i].restitute_movement(&self.world, &self.motion.blocks, limited, resolved);
} else if tnt_after_move {
self.restitute_piston(target, limited, resolved);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
}
@@ -1053,6 +1062,7 @@ mod tests {
mod order_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::geometry::Vec3;
use horizon_core::{geometry::Direction, position::BlockPos};
use horizon_world::level::pistons::MovingPiston;
use std::sync::Arc;
@@ -1151,8 +1161,9 @@ mod order_tests {
}
// The near stone's box reaches x 5.5 into a TNT from x 5.01: 0.49 plus
// PUSH_OFFSET; the far stone's old box stops it at x 6.
// Vanilla's TNT width is 0.98F, not the exact decimal 0.98.
assert!(
(moved[0] - 0.5).abs() < 1e-9,
(moved[0] - 0.500000009536743).abs() < 1e-9,
"far stone gone first: {}",
moved[0]
);
@@ -1251,7 +1262,7 @@ mod order_tests {
}
/// A primed TNT pushed down by a piston onto a slime block bounces up,
/// matching vanilla's slime restitution (~+0.64) even in a lazy chunk (#12022).
/// matching vanilla's restitution even in a lazy chunk (#12022).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_pushed_tnt_bounces_on_slime() {
@@ -1312,32 +1323,36 @@ mod order_tests {
death_ticks: 0,
id: 1,
};
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
game.sync_moving_pistons();
game.tnts.clear();
assert!(game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0.0d,-0.4d,0.0d]}"));
assert!(
(game.tnts[0].velocity.y - (-0.4)).abs() < 1.0e-9,
"TNT starts with vy -0.4, got {:?}",
game.tnts[0].velocity
);
// Piston advances from progress 0 to 0.5 and pushes the TNT down.
let pushes: Vec<_> = game
.block_ticks
.moving_pistons
.iter()
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
.collect();
for (_, piston) in &mut game.block_ticks.moving_pistons {
piston.progress += 0.5;
// Pinned 26.3 PistonMovingBlockEntity.moveCollidedEntities, without entity ticks.
for (floor, y, vy, final_y, final_vy) in [
(slime, 200.5, -0.4, 200.0, 0.3510000138133766_f64),
(stone, 200.5, -0.4, 200.0, 0.0),
(slime, 200.0, -0.4, 200.0, 0.3200000047683716),
(slime, 200.00001, -0.4, 200.00001, 0.32000064476813395),
(slime, 200.5, 0.3, 200.0, 0.0),
] {
game.world.set(5, 199, 5, floor).unwrap();
game.block_ticks.moving_pistons = vec![(BlockPos::new(5, 200, 5), piston)];
game.sync_moving_pistons();
game.tnts.clear();
assert!(game.run_command(&format!(
"summon tnt 5.5 {y} 5.5 {{fuse:80,Motion:[0.0d,{vy}d,0.0d]}}"
)));
assert_eq!(game.tnts[0].velocity.y, vy);
let pushes: Vec<_> = game
.block_ticks
.moving_pistons
.iter()
.map(|&(pos, old)| (pos, old, old.progress + 0.5))
.collect();
for (_, piston) in &mut game.block_ticks.moving_pistons {
piston.progress += 0.5;
}
game.push_piston_entities(pushes);
assert_eq!(game.tnts[0].position, Vec3::new(5.5, final_y, 5.5));
assert_eq!(game.tnts[0].velocity.y.to_bits(), final_vy.to_bits());
assert!(game.tnts[0].on_ground);
}
game.push_piston_entities(pushes);
// The slime block at y=199 bounces the TNT up; vertical velocity must be positive.
assert!(
game.tnts[0].velocity.y > 0.0,
"TNT should bounce up on slime, got vy {:?}",
game.tnts[0].velocity
);
}
}
@@ -1602,6 +1617,54 @@ mod h255_mob_piston_ground_tests {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_tnt_authority_and_exhausted_budget() {
use crate::game::execution_role::ExecutionRole;
for role in [
ExecutionRole::Client,
ExecutionRole::IntegratedServer,
ExecutionRole::DedicatedServer,
] {
with_role_game(role, |game| {
assert!(game.run_command("setblock 5 199 5 slime_block"));
assert!(
game.run_command("summon tnt 5.5 200.5 5.5 {fuse:80,Motion:[0d,-0.4d,0d]}")
);
let mut budget = std::collections::HashMap::new();
let push = |game: &mut Game<'_>,
budget: &mut std::collections::HashMap<_, _>,
movement| {
let tnt = &game.tnts[0];
game.move_pushed(
Target::Entity(EntityHandle::Tnt(0)),
tnt.bounds(),
[tnt.position.x, tnt.position.z],
0.0,
tnt.on_ground,
movement,
Direction::Down,
budget,
);
};
push(game, &mut budget, Vec3::ZERO);
assert_eq!(game.tnts[0].position.y, 200.5);
assert_eq!(game.tnts[0].velocity.y, -0.4);
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
let expected = if role.is_client() {
0.0_f64
} else {
0.3510000138133766
};
assert_eq!(game.tnts[0].velocity.y.to_bits(), expected.to_bits());
game.tnts[0].position.y = 200.5;
game.tnts[0].velocity.y = -0.4;
push(game, &mut budget, Vec3::new(0.0, -0.51, 0.0));
assert_eq!(game.tnts[0].position.y, 200.5);
assert_eq!(game.tnts[0].velocity.y, -0.4);
});
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
with_game(|game| {
let i = added_mob(game, 20, "horse", true, 8.5);
@@ -1613,6 +1676,16 @@ mod h255_mob_piston_ground_tests {
added_push(game, i, Vec3::new(0.51, 0.0, 0.0));
assert!(game.mobs[i].on_ground, "occupied path remains excluded");
assert_eq!(game.mobs[i].position, Vec3::new(9.01, 202.0, 4.5));
assert!(game.run_command("setblock 9 201 4 slime_block"));
game.mobs[i].position.y = 202.25;
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
game.mobs[i].sync_position = false;
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
assert_eq!(
game.mobs[i].velocity.y, 0.0,
"occupied restitution stays outside this slice"
);
assert!(!game.mobs[i].sync_position);
assert!(game.run_command("ride @s dismount"));
assert!(game.riding.is_none());
assert!(game.mobs[i].rider.is_none());
@@ -1655,23 +1728,46 @@ mod h255_mob_piston_ground_tests {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
fn h255_added_client_role_ai_and_vertical_ground_state() {
use crate::game::execution_role::ExecutionRole as Role;
for (role, no_ai, movement) in [
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, false, Vec3::new(0.25, 0.0, 0.0)),
(Role::IntegratedServer, true, Vec3::new(0.0, 0.25, 0.0)),
for (role, no_ai, movement, on_ground) in [
(Role::Client, true, Vec3::new(0.25, 0.0, 0.0), true),
(
Role::IntegratedServer,
false,
Vec3::new(0.25, 0.0, 0.0),
true,
),
(
Role::IntegratedServer,
true,
Vec3::new(0.0, 0.25, 0.0),
false,
),
] {
with_role_game(role, |game| {
let i = added_mob(game, 22, "husk", no_ai, 8.5);
let before = game.mobs[i].position;
added_push(game, i, movement);
assert_eq!(game.mobs[i].position, before.add_vector(movement));
assert!(
game.mobs[i].on_ground,
"excluded native path remains unchanged; not Java parity"
);
assert_eq!(game.mobs[i].on_ground, on_ground);
});
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_partial_push_reads_slime_after_the_position_changes() {
with_game(|game| {
assert!(game.run_command("setblock 8 201 4 slime_block"));
let i = added_mob(game, 23, "iron_golem", true, 8.5);
game.mobs[i].position.y = 202.25;
game.mobs[i].velocity = Vec3::new(0.0, -1.0, 0.0);
game.mobs[i].sync_position = false;
added_push(game, i, Vec3::new(0.0, -0.5, 0.0));
assert_eq!(game.mobs[i].position, Vec3::new(8.5, 202.0, 4.5));
assert!(game.mobs[i].velocity.y > 0.9);
assert!(game.mobs[i].sync_position);
});
}
}
+203 -36
View File
@@ -11,8 +11,8 @@ use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
use horizon_client::mobs::{Mob, Part, Polygon, render};
use horizon_hooks::engine;
use horizon_hooks::players::{
Bone, CameraView, Frame, Gear, Kind, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
};
use std::sync::Arc;
@@ -182,6 +182,14 @@ fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
})
}
/// Full light, packed as the vertices carry it.
const FULL: u32 = 240 | 240 << 8;
thread_local! {
/// What crates draw see-through this frame, added after every player.
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Forgets the meshes no crate holds any more (once a frame).
fn forget_meshes() {
let _ = MESHES.try_with(|m| {
@@ -199,7 +207,7 @@ impl PartPoses for PosesOf<'_> {
}
}
/// A player model drawn in the world pass, as a crate draws on it: posed on the GPU's skin path
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
/// culling), else its vertices go to `out`.
struct DrawPlayer<'a, 'g> {
@@ -208,6 +216,7 @@ struct DrawPlayer<'a, 'g> {
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
@@ -223,6 +232,19 @@ impl DrawPlayer<'_, '_> {
let (key, _) = render::body(self.m);
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
}
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
match light {
Light::Full => FULL,
Light::AtPlace => {
let p = (self.camera + at.as_dvec3()).floor();
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
}
_ => self.light,
}
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
@@ -234,7 +256,52 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
self.m.age as f32 + self.alpha
}
fn base(&self) -> [[f32; 4]; 4] {
self.base.to_cols_array_2d()
}
fn motion(&self) -> Motion {
let (m, a) = (self.m, self.alpha);
let t = f64::from(a);
let (p, n) = (m.previous, m.position);
let feet = [
p.x + (n.x - p.x) * t,
p.y + (n.y - p.y) * t,
p.z + (n.z - p.z) * t,
];
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
let yaw = m.previous_head_yaw + turn * a;
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
let swimming = m.extra.swim.amount(a) > 0.0;
Motion::new(feet, yaw, pitch).poses(
m.extra.crouching,
asleep,
m.extra.fall_flying,
swimming,
)
}
fn view(&self) -> View {
let q = self.game.camera_orientation();
let (right, up) = (
(q * glam::Vec3::X).to_array(),
(q * glam::Vec3::Y).to_array(),
);
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let left = [yaw.cos(), 0.0, yaw.sin()];
View::new(self.camera.to_array(), right, up).looking(forward, left)
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
if self.pass == Pass::FirstPerson {
return None;
}
if self.body.is_none() {
let mut poses = Poses::pooled();
poses.reset(self.body());
@@ -260,13 +327,30 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
let poses = &self.poses;
if let Some(alpha) = look.see_through {
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let pose_of = |p: &Part| poses.pose(p);
SEE_THROUGH.with_borrow_mut(|out| {
let start = out.len();
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
for v in &mut out[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one: `entity.wgsl`
// drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
});
return;
}
if self.gpu {
let mut models = self.game.skin_models.borrow_mut();
let mut frame = self.game.skin_frame.borrow_mut();
@@ -276,30 +360,43 @@ impl PlayerDraw for DrawPlayer<'_, '_> {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light: self.light,
light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
if skin.draw(&model, matrix, poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
let pose_of = |p: &Part| poses.pose(p);
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
}
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
let light = self.packed(light, glam::Vec3::ZERO);
SEE_THROUGH.with_borrow_mut(|out| {
for q in particles {
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
out.push(Vertex {
pos: *corner,
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
}
});
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
/// cosmetics still drawn here: see [`DrawPlayer`]. The bounds of what went to the GPU.
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
/// [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
@@ -315,33 +412,102 @@ impl Game<'_> {
self.serving.is_none(),
"a server thread calls no client hook"
);
let kind = kind(m)?;
let mut bounds = None;
if engine::any() {
let model = model(m, kind, self.remote_player_uuid(m.id));
let kind = kind(m).filter(|_| engine::any())?;
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
camera,
alpha,
light,
gpu,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
draw.bounds
}
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
/// `camera`: the world view's camera, or the picture's.
pub(super) fn own_layers_geometry(
&self,
m: &Mob,
pass: Pass,
camera: glam::DVec3,
alpha: f32,
light: u32,
out: &mut Vec<Vertex>,
) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base: render::base_transform(m, alpha, camera),
camera,
alpha,
light,
gpu: false,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
/// After every player (`game.rs`): what crates draw around the window's own model in first
/// person, then what was drawn see-through, in one span.
pub(super) fn players_after_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if engine::any()
&& !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let own = own.looks(own.gear, own.invisible || self.own_invisible());
self.mark_entity(out.len(), "minecraft:player");
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
alpha,
light,
gpu,
pass: Pass::FirstPerson,
base: render::base_transform(m, alpha as f32, camera),
camera,
alpha: alpha as f32,
light: FULL,
gpu: false,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
engine::first_person(&own, &mut draw);
}
let _ = SEE_THROUGH.try_with(|v| {
let mut v = v.borrow_mut();
if !v.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&v);
v.clear();
}
});
}
/// Once a frame, before drawing.
@@ -358,6 +524,7 @@ impl Game<'_> {
let frame = Frame::new(connection, self.integrated.is_some());
engine::frame(&frame, &mut HookStore(self));
forget_meshes();
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
self.cosmetics_frame();
}
+9 -10
View File
@@ -1569,7 +1569,7 @@ impl Game<'_> {
self.left = false;
self.right = false;
// The shared world is closing: its invite code ends with it.
self.friends_lan_closed();
self.lan_closed_hooks();
// Single player's server thread saves the world before the title shows.
let single_player = match self.integrated.take() {
Some(server) => {
@@ -7242,15 +7242,15 @@ impl Game<'_> {
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep follows LivingEntity's interpolation and head turn.
// Squid.aiStep; WalkAnimationState.update from the horizontal distance.
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.updateWalkAnimation.
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
let d = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z) as f32;
let (target, factor) = if m.species() == horizon_client::mobs::Species::Frog {
(horizon_client::mobs::frog::walk_target(m, d), 0.4)
} else if horizon_client::mobs::camel::is_camel(m.species()) {
horizon_client::mobs::camel::walk_target(m, d)
} else {
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
((d * 4.0).min(1.0), 0.4)
};
m.limb_speed += (target - m.limb_speed) * factor;
m.limb_position += m.limb_speed;
@@ -11463,8 +11463,7 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
/// Players in the server's player list, this one included (for the activity hooks).
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
+818 -4
View File
@@ -1,8 +1,18 @@
//! Replay playback's state in a `Game`. Recording a session's packets as `.mcpr` and playing them
//! back is platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
//! Recording a session's packets as `.mcpr` and playing them back in a `Game`: the `/studio record`
//! and `/studio replay` commands (chat and `horizon-ctl`), the replay's clock and its world. This is
//! platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
use super::Game;
use horizon_client::studio::ReplayDriver;
use super::{Game, PendingWorldEntry, Screen};
use horizon_client::{
Result,
studio::{Metadata, ReplayDriver},
text::Text,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// The packet replay a `Game` plays, if any, and its playback clock.
#[derive(Default)]
@@ -38,4 +48,808 @@ impl Game<'_> {
.player_alpha()
.unwrap_or_else(|| self.clock.camera_fraction())
}
/// In-game chat command handler: `/studio <record|replay> ...`.
/// Returns true if the command was recognized and handled.
pub(super) fn studio_command(&mut self, command: &str) -> bool {
let Some(rest) = command.strip_prefix("studio") else {
return false;
};
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return false;
}
let words: Vec<&str> = rest.split_whitespace().collect();
if words.is_empty() {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
return true;
}
match words[0] {
"freecam" | "keyframe" | "path" if super::studio::EDITOR => {
return self.studio_editor_command(&words);
}
"record" => match words.get(1).copied() {
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("stop") => match self.stop_studio_recording() {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("status") => {
let status = self.studio_recording_status();
if status["active"].as_bool() == Some(true) {
let elapsed =
(status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
let size =
(status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
let path = status["path"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.record.status.active",
&[&elapsed, &packets, &size, &path],
));
} else if let Some(error) = status["error"].as_str() {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
} else {
self.system_message(Text::translate(
"horizon.studio.record.status.inactive",
&[],
));
}
}
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
},
"replay" => match words.get(1).copied() {
Some("open") => {
let Some(path) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.open.usage",
&[],
));
return true;
};
match self.open_studio_replay(path) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let duration_s = v["duration_ms"].as_f64().unwrap_or(0.0) / 1000.0;
let packets = v["packet_count"].as_u64().unwrap_or(0);
let duration = format!("{duration_s:.1}");
let packets = packets.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.opened",
&[path, &duration, &packets],
));
}
Ok(v) => {
let err = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&err],
));
}
Err(e) => {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&error],
));
}
}
}
Some("pause") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("speed") => {
let Some(spd_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.usage",
&[],
));
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
None
};
if let Some(speed) = new_speed {
let speed = format!("{speed:.2}");
self.system_message(Text::tr(
"horizon.studio.replay.speed.set",
&[&speed],
));
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.invalid",
&[],
));
}
}
Some("seek") => {
let Some(ms_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.usage",
&[],
));
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.replay.driver.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.replay
.driver
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
let seconds = format!("{:.1}", actual_ms as f32 / 1000.0);
self.system_message(Text::tr(
"horizon.studio.replay.seeked",
&[&ms_text, &seconds],
));
}
Err(e) => {
if e.kind() == std::io::ErrorKind::Unsupported {
self.system_message(Text::translate(
"horizon.studio.replay.seek.backward_requires_standalone",
&[],
));
} else {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.seek.error",
&[&error],
));
}
}
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.invalid",
&[],
));
}
}
Some("status") => {
if let Some(r) = self.replay.driver.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
let speed = format!("{:.2}", r.speed());
let paused = r.is_paused().to_string();
let finished = r.is_finished().to_string();
self.system_message(Text::tr(
"horizon.studio.replay.status",
&[&position, &duration, &progress, &speed, &paused, &finished],
));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("close") => {
if self.close_studio_replay() {
self.system_message(Text::translate("horizon.studio.replay.closed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
},
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
}
true
}
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.replay.virtual_tick_ms = 0.0;
if let Some(player) = &self.player {
self.previous_position = player.position;
if let Some(m) = self.player_model.as_mut() {
m.previous = player.position;
m.position = player.position;
m.limb_speed = 0.0;
m.previous_limb_speed = 0.0;
}
}
}
/// Reconstruct replay state at a target timestamp. Standalone replays can
/// safely reset their synthetic remote; an in-world replay can only seek
/// forward because its pre-replay world must remain intact.
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
let current_ms = self
.replay
.driver
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.replay.standalone {
let packets = self
.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
self.init_remote_for_replay()
.map_err(|error| std::io::Error::other(error.to_string()))?;
packets
} else {
if target_ms < current_ms {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"backward seek requires a standalone replay",
));
}
self.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
};
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.replay.last_tick = Some(std::time::Instant::now());
Ok(())
}
fn dispatch_studio_replay_packets(
&mut self,
packets: Vec<horizon_client::studio::mcpr::TimestampedPacket>,
) {
for pkt in packets {
match horizon_protocol::packets::play::Clientbound::decode(pkt.id, &pkt.payload) {
Ok(packet) => {
let _ = self.handle_packet(packet);
}
Err(e) => {
let name = horizon_protocol::ids::name(
horizon_protocol::State::Play,
horizon_protocol::Direction::Clientbound,
pkt.id,
)
.unwrap_or("?");
log::debug!(
"Studio replay skipped packet {name} (id {:#x}): {e}",
pkt.id
);
}
}
}
}
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
let Some(driver) = self.replay.driver.as_mut() else {
self.replay.last_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.replay.last_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.replay.last_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
Err(e) => {
log::warn!("Studio replay advance error: {e}");
return Ok(());
}
};
self.dispatch_studio_replay_packets(ready_packets);
let paused = self
.replay
.driver
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
self.tick_player_model();
}
if !paused {
let speed = self
.replay
.driver
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.replay.virtual_tick_ms >= 50.0 {
self.replay.virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
current_pos.x - self.previous_position.x,
current_pos.z - self.previous_position.z,
);
let walked = (dx * dx + dz * dz).sqrt() as f32;
if let Some(m) = self.player_model.as_mut() {
m.previous = self.previous_position;
m.position = current_pos;
m.previous_limb_speed = m.limb_speed;
let target_speed = (walked * 4.0).min(1.0);
m.limb_speed += (target_speed - m.limb_speed) * 0.4;
if m.limb_speed < 0.001 {
m.limb_speed = 0.0;
}
m.limb_position += m.limb_speed;
m.previous_yaw = m.yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.previous_body_yaw = m.body_yaw;
m.yaw = self.yaw;
m.head_yaw = self.yaw;
m.head_pitch = self.pitch;
let mut target = m.body_yaw;
if dx * dx + dz * dz > 0.0025 {
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing =
((self.yaw - walk + 180.0).rem_euclid(360.0) - 180.0).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
let diff = (target - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
m.body_yaw += diff * 0.3;
let body_diff = (self.yaw - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
if body_diff.abs() > 50.0 {
m.body_yaw += body_diff - body_diff.signum() * 50.0;
}
m.age = m.age.wrapping_add(1);
}
self.previous_position = current_pos;
}
}
} else if let Some(m) = self.player_model.as_mut() {
m.previous_limb_speed = m.limb_speed;
}
self.studio_replay_advanced();
Ok(())
}
/// Control socket command handler for `studio`.
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
if words.first().copied() != Some("studio") {
return Ok(None);
}
match words.get(1).copied() {
Some("record") => match words.get(2).copied() {
Some("start") => {
let name = words.get(3).copied();
let res = self.start_studio_recording(name)?;
Ok(Some(res))
}
Some("stop") => {
let res = self.stop_studio_recording()?;
Ok(Some(res))
}
Some("status") => {
let res = self.studio_recording_status();
Ok(Some(res))
}
_ => Ok(Some(json!({ "error": "unknown studio record subcommand" }))),
},
Some("replay") => match words.get(2).copied() {
Some("open") => {
let path = words.get(3).ok_or("missing path for studio replay open")?;
let res = self.open_studio_replay(path)?;
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("speed") => {
let spd_str = words.get(3).ok_or("missing speed value")?;
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
if let Some(r) = self.replay.driver.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("seek") => {
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
let pos = if let Some(r) = self.replay.driver.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
None
};
if let Some(pos) = pos {
self.reset_studio_playback_ticks();
Ok(Some(json!({ "ok": true, "position_ms": pos })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("status") => {
if let Some(r) = self.replay.driver.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
"position_ms": r.position_ms(),
"duration_ms": r.duration_ms(),
"progress": r.progress(),
"speed": r.speed(),
"paused": r.is_paused(),
"finished": r.is_finished(),
})))
} else {
Ok(Some(json!({ "ok": true, "active": false })))
}
}
Some("close") => {
let closed = self.close_studio_replay();
Ok(Some(json!({ "ok": true, "closed": closed })))
}
_ => Ok(Some(json!({ "error": "unknown studio replay subcommand" }))),
},
Some("screen") if super::studio::EDITOR => self.studio_screen_control(words),
_ => Ok(Some(json!({ "error": "unknown studio command" }))),
}
}
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
if r.session.is_recording() {
return Ok(json!({ "ok": false, "error": "recording already active" }));
}
let _ = std::fs::create_dir_all("recordings");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = match name {
Some(n) if !n.is_empty() => format!("{}_{}.mcpr", n, ts),
_ => format!("recording_{}.mcpr", ts),
};
let path = PathBuf::from("recordings").join(filename);
let server_address = if r.session.address.is_empty() {
"local".to_owned()
} else {
r.session.address.clone()
};
let is_singleplayer = server_address == "local";
let meta = Metadata::new(
server_address,
is_singleplayer,
horizon_core::release::VERSION.into(),
);
match horizon_client::studio::StudioRecorder::start(path.clone(), meta) {
Ok(recorder) => {
r.session.start_recording(recorder, |recorder| {
self.dump_world_snapshot_to_recorder(recorder);
});
Ok(json!({ "ok": true, "path": path.display().to_string() }))
}
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
}
}
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
match r.session.stop_recording() {
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Some(Err(e)) => Ok(json!({ "ok": false, "error": e.to_string() })),
None => Ok(json!({ "ok": false, "error": "no active recording" })),
}
}
pub(super) fn studio_recording_status(&self) -> Value {
if let Some(r) = self.remote.as_ref()
&& let Some(status) = r.session.recorder_status()
{
json!({
"ok": true,
"active": status.active,
"path": status.path.display().to_string(),
"elapsed_ms": status.elapsed.as_millis(),
"packet_count": status.packet_count,
"bytes_written": status.bytes_written,
})
} else {
json!({ "ok": true, "active": false })
}
}
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
if let Err(error) = self.save_studio_camera_track() {
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
// setup builds the normal client registries, which require `rest`.
if self.remote.is_none() && self.res.rest.get().is_none() {
let pending = PendingWorldEntry::StudioReplay(PathBuf::from(path));
if self.defer_world_entry(pending)? {
return Ok(json!({ "ok": true, "pending": true }));
}
self.join_world_rest()?;
}
// Validate and index the archive before replacing the title menu or active world. A
// temp-file, metadata, or protocol error must leave the current screen usable.
let driver = match ReplayDriver::open(Path::new(path)) {
Ok(driver) => driver,
Err(error) => {
log::error!("Failed to open studio replay '{path}': {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
};
let standalone = self.replay.driver.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
log::error!("Failed to initialize studio replay session: {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
self.studio_replay_opened(&driver);
if self.replay.driver.is_none() {
self.replay.previous_camera_type = Some(self.camera_type);
self.replay.standalone = standalone;
}
self.replay.driver = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
self.capture(false);
self.dragging = false;
// Apply timestamp-zero login, player pose, and chunk packets before the first rendered
// frame so playback opens at the recorded player's viewpoint.
let _ = self.advance_studio_replay();
self.reset_studio_playback_ticks();
log::info!("Studio: Opened replay '{path}' ({duration_ms} ms, {packets} packets)");
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"packet_count": packets,
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
self.studio_replay_closing();
let closed = self.replay.driver.take().is_some();
if !closed {
return false;
}
// A standalone replay owns a synthetic Remote connection. Treat that
// connection as replay state even if the saved origin flag was lost;
// sending it through leave_server() opens the Multiplayer screen.
let standalone = self.replay.standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.replay.standalone = false;
if let Some(camera_type) = self.replay.previous_camera_type.take() {
self.camera_type = camera_type;
}
self.replay.last_tick = None;
self.studio_replay_closed();
self.replay.virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
remote.session.disconnect();
}
self.using = None;
self.end_mod_use();
if let Err(error) = self.finish_close_world() {
log::error!("Failed to close standalone Studio replay cleanly: {error}");
}
self.reset_studio_hand_state();
// A title-screen replay has no previous world perspective to
// restore. Start the next world in first person with visible hands.
self.camera_type = super::perspective::CameraType::FirstPerson;
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
} else if self.integrated.is_none() && self.lan.is_none() {
let _ = self.close_world();
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
}
closed
}
/// Dump current player pose, dimension login, center chunk, and resident loaded chunks
/// into the recording stream at timestamp 0ms so replays accurately reconstruct the world.
pub(super) fn dump_world_snapshot_to_recorder(
&self,
recorder: &horizon_client::studio::StudioRecorder,
) {
let Some(r) = self.remote.as_ref() else {
return;
};
let record_cb = |pkt: horizon_protocol::packets::play::Clientbound| {
let encoded = pkt.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
if let Ok(id) = reader.varint() {
let payload = reader.rest();
recorder.record_packet_at(0, id, payload);
}
};
// 1. C::Login packet baseline
let login = horizon_protocol::packets::play::Clientbound::Login(
horizon_protocol::packets::play::Login {
player_id: r.entity_id,
hardcore: self.level.hardcore,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: horizon_protocol::packets::play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 0,
game_type: 0,
previous_game_type: None,
debug: false,
flat: false,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
},
online_mode: false,
enforces_secure_chat: false,
},
);
record_cb(login);
// 2. Center chunk and player position
let (pos, yaw, pitch) = if let Some(p) = &self.player {
(
glam::DVec3::new(p.position.x, p.position.y, p.position.z),
self.yaw,
self.pitch,
)
} else {
(glam::DVec3::ZERO, self.yaw, self.pitch)
};
let chunk_x = (pos.x.floor() as i32) >> 4;
let chunk_z = (pos.z.floor() as i32) >> 4;
let center_chunk = horizon_protocol::packets::play::Clientbound::SetChunkCacheCenter(
horizon_protocol::packets::play::SetChunkCacheCenter {
x: chunk_x,
z: chunk_z,
},
);
record_cb(center_chunk);
let player_pos = horizon_protocol::packets::play::Clientbound::PlayerPosition(
horizon_protocol::packets::play::PlayerPosition {
teleport: 0,
position: [pos.x, pos.y, pos.z],
delta: [0.0, 0.0, 0.0],
yaw,
pitch,
relatives: 0,
},
);
record_cb(player_pos);
// 3. Encode resident loaded chunks into LevelChunkWithLight packets
let biome_names: Vec<String> = world_biomes!(self)
.list
.iter()
.map(|b| b.name.clone())
.collect();
let registry: Vec<String> = r
.registries
.registries
.get("minecraft:worldgen/biome")
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_else(|| biome_names.clone());
let encoder = crate::lan::chunks::ChunkEncoder::with_signs(
self.res.facts().states.iter().map(|s| {
(
s.name.clone(),
s.fluid_level > 0 || s.waterlogged,
s.sign_form(),
)
}),
&r.builtin,
&biome_names,
&registry,
);
let mut count = 0;
for chunk in self.world.chunks.values() {
let level_chunk = encoder.encode(chunk, &self.world.block_entities);
record_cb(
horizon_protocol::packets::play::Clientbound::LevelChunkWithLight(level_chunk),
);
count += 1;
}
log::info!(
"Studio Recorder: Dumped initial world snapshot ({count} chunks) at timestamp 0ms"
);
}
}
@@ -306,8 +306,6 @@ pub(super) fn run_loading(
App::Loading(loading) => leave_fullscreen_after(&result, &mut loading.settings),
}
result?;
#[cfg(feature = "discord")]
horizon_client::discord_presence::stop();
match app {
App::Ready(mut split) => {
write_report(&split.host)?;
@@ -0,0 +1,117 @@
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
use super::{Game, lan::LanSettings};
use horizon_client::text::Text;
use horizon_hooks::share::{LanOptions, ShareView};
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
/// plays P1's world and can't share it; its leaving doesn't close it).
fn shares(game: &Game<'_>) -> bool {
!game.serving() && !game.options.split_guest
}
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
/// horizon-friends (spec #14167). Before the hooks are sealed.
#[cfg(feature = "friends")]
pub(crate) fn register_friends() {
if horizon_hooks::register(&super::friends::HOOKS).is_err()
|| horizon_hooks::pages::register(&super::friends::PAGE).is_err()
{
log::warn!("Play with Friends registered after the game started: sharing is off");
}
}
impl Game<'_> {
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
pub(super) fn share_tick(&mut self) {
if shares(self) {
horizon_hooks::engine::share(&mut World(self));
}
}
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
pub(super) fn lan_closed_hooks(&self) {
if shares(self) {
horizon_hooks::engine::lan_closed();
}
}
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
/// to LAN publishes it; another server can't be.
pub(super) fn can_share(&self) -> bool {
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
}
}
impl<'g> Game<'g> {
/// The view of this window's world, for the engine's own Friends screen and command until
/// they move to the crate.
#[cfg(feature = "friends")]
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
World(self)
}
}
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
impl ShareView for World<'_, '_> {
fn can_share(&self) -> bool {
self.0.can_share()
}
fn lan_port(&self) -> Option<u16> {
self.0.lan_port()
}
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
self.0
.lan_open(LanSettings {
port: horizon_client::lan::available_port(),
guest_commands: options.guest_commands,
force_game_mode: options.force_game_mode,
})
.map_err(|e| e.to_string())?;
self.0
.lan_port()
.ok_or_else(|| "the world isn't open to LAN".to_owned())
}
fn close_lan(&mut self) {
self.0.lan_close(true);
}
fn players(&self, each: &mut dyn FnMut(&str)) {
let g = &self.0;
if let Some(host) = &g.lan {
for guest in &host.hub.guests {
each(&guest.name);
}
} else if g.integrated.is_some() {
// On the server thread: the other players the server lists.
let me = g.options.player_name();
for name in g.remote_online_names().unwrap_or_default() {
if name != me {
each(&name);
}
}
}
}
fn chat(&mut self, text: &str) {
self.0.system_message(Text::literal(text));
}
fn copy(&mut self, text: &str) {
use horizon_client::clipboard::Clipboard;
self.0.clipboard.set(text);
}
fn open_url(&mut self, url: &str) {
// The system browser, as the folder buttons open their folders.
super::packs::open_uri(url);
}
fn tr(&self, key: &str) -> Option<String> {
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
}
}
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
continue;
}
// Opened without the focus: whoever types in the game's window keeps the keyboard.
let attributes =
horizon_client::window::with_horizon_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes = horizon_client::window::with_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
// Space of its own that hides the other windows as it slides in.
let placement = self.placement(*key).cloned();
@@ -113,6 +113,117 @@ fn fire_from_damage_lights_or_burns_the_stand_though_the_command_fails() {
assert!(health.ends_with(": 16.0f"), "{health}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn igniting_a_frozen_stand_clears_its_freeze_while_burning_keeps_it() {
let mut game = game();
summon(&mut game, ",TicksFrozen:140");
let (_, frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(frozen.ends_with(": 140"), "{frozen}");
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
let (ok, frozen_after) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(!ok, "{frozen_after}");
// When already burning, in_fire calls causeDamage(0.15) instead of igniteForSeconds.
assert!(game.run_command(&format!("data merge entity {STAND} {{TicksFrozen:140}}")));
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (ok, still_frozen) = say(&mut game, &format!("data get entity {STAND} TicksFrozen"));
assert!(ok && still_frozen.ends_with(": 140"), "{still_frozen}");
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn damage_immunity_on_worn_boots_refuses_campfire_but_not_in_fire_or_wrong_slot() {
let mut game = game();
summon(
&mut game,
r#",equipment:{feet:{id:"minecraft:diamond_boots",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
// campfire is in both #ignites_armor_stands and #burn_from_stepping.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 0s"), "{fire}");
// in_fire is not in #burn_from_stepping, so it still ignites the stand.
assert!(!game.run_command(&format!("damage {STAND} 1 minecraft:in_fire")));
let (_, fire) = say(&mut game, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
// Frost Walker on a helmet (head) is not in an active slot for the enchantment.
let mut helmeted = self::game();
summon(
&mut helmeted,
r#",equipment:{head:{id:"minecraft:diamond_helmet",count:1,components:{"minecraft:enchantments":{"minecraft:frost_walker":1}}}}"#,
);
assert!(!helmeted.run_command(&format!("damage {STAND} 1 minecraft:campfire")));
let (_, fire) = say(&mut helmeted, &format!("data get entity {STAND} Fire"));
assert!(fire.ends_with(": 100s"), "{fire}");
}
fn capture() {
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
}
fn take_events() -> Vec<(&'static str, [f64; 3], Option<horizon_client::mobs::Who>)> {
super::sculk::TEST_POSTED_EVENTS.with(|events| events.borrow_mut().take().unwrap_or_default())
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn cause_damage_and_explosion_post_attributed_game_events() {
use horizon_client::mobs::Who;
let mut game = game();
summon(&mut game, ",Fire:200s");
assert!(game.run_command("summon pig 14.5 100 2.5 {NoAI:1b}"));
let pig_id = game.mobs[0].id;
let at = [2.5, 100.0, 8.5];
// Burning stand hurt by in_fire (0.15) posts entity_damage attributed to the pig.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:in_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand hurt by on_fire (4.0) while surviving posts entity_damage.
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert_eq!(
take_events(),
vec![("minecraft:entity_damage", at, Some(Who::Mob(pig_id)))]
);
// Burning stand broken by on_fire (health <= 0.5) posts entity_die attributed to the pig.
assert!(game.run_command(&format!("data merge entity {STAND} {{Health:4f}}")));
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:on_fire by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
// Stand broken by explosion posts entity_die attributed to the pig.
summon(&mut game, "");
capture();
assert!(!game.run_command(&format!(
"damage {STAND} 1 minecraft:explosion by @e[type=pig,limit=1]"
)));
assert!(!game.run_command(&format!("execute if entity {STAND}")));
assert_eq!(
take_events(),
vec![("minecraft:entity_die", at, Some(Who::Mob(pig_id)))]
);
}
// Controls added after the baseline: the guards before the acting sources,
// and a stand broken once however often it is hurt.
File diff suppressed because it is too large. Load diff
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
+56
View File
@@ -0,0 +1,56 @@
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
//! windows' icon and the colours, sizes and fonts of the menus vanilla doesn't have
//! (`docs/design-system.md`).
//! Its own module so it can leave the engine crate as a pure file move, for the official
//! executable to call.
use horizon_client::menu_style::{Fonts, MenuStyle, OverlayColours, SettingsColours, Sizes};
use horizon_theme as theme;
/// Give the engine Horizon's look; call it once, before the first window opens.
pub(crate) fn give() {
horizon_client::window::set_icon_png(theme::ICON_PNG);
let (blocks, dark) = (theme::BLOCKS_DARK, theme::DARK);
horizon_client::menu_style::install(MenuStyle {
settings: SettingsColours {
page: blocks.page,
ink: blocks.ink,
ink_soft: blocks.ink_soft,
muted: blocks.muted,
outline: blocks.outline,
accent: blocks.accent,
face: blocks.face,
// The face's hover, lighter than `face`: the settings screen's own shade.
face_hover: 0x6a50e6,
success: blocks.success,
warn: blocks.warn,
danger: blocks.danger,
},
overlay: OverlayColours {
page: dark.page,
backdrop_muted: theme::BACKDROP_MUTED,
ink: dark.ink,
ink_soft: dark.ink_soft,
rule: dark.rule,
accent: dark.accent,
on_accent: dark.on_accent,
},
sizes: Sizes {
caption: theme::CAPTION.px,
label: theme::LABEL.px,
ui: theme::UI.px,
body: theme::BODY.px,
title: theme::TITLE.px,
display: theme::DISPLAY.px,
micro: theme::MICRO.px,
radius: theme::RADIUS,
radius_plate: theme::RADIUS_PLATE,
control_large: theme::CONTROL_LARGE,
},
fonts: Some(Fonts {
regular: theme::fonts::GEIST_REGULAR,
medium: theme::fonts::GEIST_MEDIUM,
semibold: theme::fonts::GEIST_SEMIBOLD,
mono: theme::fonts::GEIST_MONO,
}),
});
}
+1
View File
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
mod horizon_look;
pub mod mod_api;
#[cfg(test)]
+31 -1
View File
@@ -73,7 +73,7 @@ impl Animation {
/// KeyframeAnimation.apply(millis, scale): offsets added to each bone's pose.
pub fn apply(&self, millis: i64, scale: f32, poses: &mut super::anim::Poses) {
let mut seconds = millis as f32 / 1000.0;
if self.looping {
if self.looping && self.length > 0.0 {
seconds %= self.length;
}
for (bone, channels) in &self.bones {
@@ -231,4 +231,34 @@ mod tests {
];
assert!((sample(&k, 1.0, 1.0)[0] - 1.0).abs() < 1e-6);
}
#[test]
fn looping_animation_applies_walk_without_overflow() {
let anim = Animation {
length: 1.25,
looping: true,
bones: vec![(
"body".to_string(),
vec![Channel {
target: Target::Position,
keyframes: vec![
Keyframe {
time: 0.0,
pre: [0.0; 3],
post: [0.0, 1.0, 0.0],
catmull: false,
},
Keyframe {
time: 1.25,
pre: [0.0, 1.0, 0.0],
post: [0.0, 1.0, 0.0],
catmull: false,
},
],
}],
)],
};
let mut poses = super::super::anim::Poses::default();
anim.apply_walk(50.0, 1.0, 1.0, 2.5, &mut poses);
assert!((poses.get("body")[1] - 1.0).abs() < 1e-6);
}
}
+32 -23
View File
@@ -849,7 +849,6 @@ impl Mob {
self.horizontal_collision = x_hit || z_hit;
// A server's mobs are authoritative (`isLocalInstanceAuthoritative`):
// the vertical flags update even without vertical movement.
let moved_vertically = wanted.y.abs() > 0.0;
let vertical = wanted.y != moved.y;
self.vertical_collision_below = vertical && wanted.y < 0.0;
self.on_ground = self.vertical_collision_below;
@@ -857,42 +856,51 @@ impl Mob {
// checkFallDamage reads the landing block (getOnPosLegacy).
self.supporting = self.supporting_block(w, moved);
self.species_check_fall_damage(w, moved, events);
// Entity.restituteMovementAfterCollisions: the colliding components
// bounce back by the entity's bounciness or the block's (a slime
// block, a bed), whichever is larger. A mob never sneaks, so nothing
// suppresses its bounce there.
if moved_vertically && vertical || self.horizontal_collision {
// Entity.getOnPos(0.2F): the block whose bounce this move sees.
self.restitute_movement(w.world, w.movement, wanted, moved);
self.movement_emission(w, moved, events);
// Entity.move's last step: soul sand, honey and the like slow the
// horizontal speed of whatever the mob stands in or on.
let factor = self.block_speed_factor(w);
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
/// `Entity.restituteMovementAfterCollisions`, shared by travel and piston pushes.
pub fn restitute_movement(
&mut self,
world: &crate::world::World,
movement: &horizon_world::movement_properties::BoundMovementProperties,
wanted: Vec3,
moved: Vec3,
) {
let differs = |a: f64, b: f64| (b - a).abs() >= f64::from(1.0e-5_f32);
let x_hit = differs(wanted.x, moved.x);
let z_hit = differs(wanted.z, moved.z);
let vertical = wanted.y != moved.y;
if wanted.y.abs() > 0.0 && vertical || x_hit || z_hit {
let on = (
self.position.x.floor() as i32,
(self.position.y - f64::from(0.2_f32)).floor() as i32,
self.position.z.floor() as i32,
);
let input = RestitutionInput {
block: w.world.block(on.0, on.1, on.2),
block: world.block(on.0, on.1, on.2),
collided_x: x_hit,
collided_z: z_hit,
vertical_collision: vertical,
collision_below: self.vertical_collision_below,
collision_below: vertical && wanted.y < 0.0,
living: true,
resolved_movement: moved,
fall_distance: self.fall_distance,
};
// The port's phases cannot fail for a mob: a block outside the
// registry reads as one with no bounce, never as an error.
// Unknown block states have no bounce; the provider cannot fail.
let _ = restitute_movement(
&mut MobRestitution {
mob: self,
world: w,
world,
movement,
},
input,
);
}
self.movement_emission(w, moved, events);
// Entity.move's last step: soul sand, honey and the like slow the
// horizontal speed of whatever the mob stands in or on.
let factor = self.block_speed_factor(w);
self.velocity = horizon_world::entity_move::scale_by_block_speed(self.velocity, factor);
}
/// `Entity.getBlockSpeedFactor` at the mob's feet.
fn block_speed_factor(&self, w: &MobWorld) -> f32 {
@@ -2103,20 +2111,21 @@ pub fn client_water_splash(mob: &mut Mob, out: &mut Vec<MobEvent>) {
/// mob that never sneaks. The phases themselves are
/// `movement_restitution::restitute_movement`, the same port the player and
/// dropped items use.
struct MobRestitution<'a, 'w> {
struct MobRestitution<'a> {
mob: &'a mut Mob,
world: &'a MobWorld<'w>,
world: &'a crate::world::World,
movement: &'a horizon_world::movement_properties::BoundMovementProperties,
}
impl<'a, 'w> MobRestitution<'a, 'w> {
impl<'a> MobRestitution<'a> {
/// The block's movement data: its coefficients and its bounce tag.
fn movement(
&self,
state: BlockStateId,
) -> Result<horizon_world::movement_properties::MovementBlock, ()> {
self.world.movement.get(state).map_err(|_| ())
self.movement.get(state).map_err(|_| ())
}
}
impl RestitutionWorld for MobRestitution<'_, '_> {
impl RestitutionWorld for MobRestitution<'_> {
// The block data cannot fail here: a state the registry does not know
// reads as a block with no bounce, never as an error.
type Error = ();
@@ -2162,7 +2171,7 @@ impl RestitutionWorld for MobRestitution<'_, '_> {
position.z.floor() as i32,
));
};
let state = self.world.world.block(support[0], support[1], support[2]);
let state = self.world.block(support[0], support[1], support[2]);
let keeps_column = self
.movement(state)
.is_ok_and(|b| b.wall || b.coefficients.fence_gate);
@@ -928,12 +928,12 @@ fn chunk_pois(
return;
};
let kind = |state: u32| poi_of_state(w, horizon_world::registry::BlockStateId(state));
let min = w.world.bottom_y();
for s in (y0 - min) >> 4..=(y1 - min) >> 4 {
let section = &chunk.sections[s as usize];
for &(i, poi) in w.world.pois.get((qx, s, qz), section, kind).iter() {
let (min, mut lists) = (w.world.bottom_y(), Vec::new());
w.world.pois.column(chunk, min, (y0, y1), kind, &mut lists);
for (base, pois) in &lists {
for &(i, poi) in pois.iter() {
let i = i32::from(i);
let y = min + s * 16 + (i >> 8);
let y = base + (i >> 8);
if (y0..=y1).contains(&y) {
visit(poi, [qx * 16 + (i & 15), y, qz * 16 + (i >> 4 & 15)]);
}
+43 -27
View File
@@ -46,11 +46,11 @@ impl NoResources {
let x = ((ui.w - width) / 2.0).round();
let lines: Vec<Vec<String>> = PARAGRAPHS
.iter()
.map(|p| wrap(ui, Face::Regular, BODY, p, width))
.map(|p| wrap(ui, Face::Regular, body(), p, width))
.collect();
let text_h = lines.iter().map(|l| l.len() as f32 * LINE).sum::<f32>()
+ (lines.len() - 1) as f32 * PARAGRAPH_GAP;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + CONTROL + 20.0 + SMALL_LINE;
let height = TITLE_LINE + 16.0 + text_h + 28.0 + control() + 20.0 + SMALL_LINE;
let y = ((ui.h - height) / 2.0).max(24.0).round();
let buttons_y = y + TITLE_LINE + 16.0 + text_h + 28.0;
let mut buttons = Vec::new();
@@ -59,7 +59,7 @@ impl NoResources {
("Download the launcher", Action::Download),
("Quit", Action::Quit),
] {
let w = (ui.measure(Face::Medium, BUTTON_TEXT, label) + 40.0).round();
let w = (ui.measure(Face::Medium, button_text(), label) + 40.0).round();
buttons.push(Button {
label,
action,
@@ -80,21 +80,29 @@ impl NoResources {
pub fn draw(&self, ui: &mut Ui) {
let layout = self.layout(ui);
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::BG);
ui.rounded(0.0, 0.0, ui.w, ui.h, 0.0, color::bg());
let mut y = layout.y;
ui.text_line(
Face::Medium,
TITLE_SIZE,
title_size(),
TITLE_LINE,
layout.x,
y,
color::TEXT,
color::text(),
TITLE,
);
y += TITLE_LINE + 16.0;
for paragraph in &layout.lines {
for line in paragraph {
ui.text_line(Face::Regular, BODY, LINE, layout.x, y, color::TEXT, line);
ui.text_line(
Face::Regular,
body(),
LINE,
layout.x,
y,
color::text(),
line,
);
y += LINE;
}
y += PARAGRAPH_GAP;
@@ -102,20 +110,20 @@ impl NoResources {
for button in &layout.buttons {
let hovered = button.contains(ui.cursor);
let (plate, ink) = match (button.action, hovered) {
(Action::Download, false) => (color::ACCENT, color::ON_ACCENT),
(Action::Download, false) => (color::accent(), color::on_accent()),
(Action::Download, true) => {
(color::with_alpha(color::ACCENT, 0.85), color::ON_ACCENT)
(color::with_alpha(color::accent(), 0.85), color::on_accent())
}
(Action::Quit, false) => (color::RAISED, color::TEXT),
(Action::Quit, true) => (color::HOVER, color::TEXT),
(Action::Quit, false) => (color::raised(), color::text()),
(Action::Quit, true) => (color::hover(), color::text()),
};
let h = CONTROL;
let h = control();
ui.rounded(button.x, button.y, button.w, h, h / 2.0, plate);
let lw = ui.measure(Face::Medium, BUTTON_TEXT, button.label);
let lw = ui.measure(Face::Medium, button_text(), button.label);
let lx = button.x + ((button.w - lw) / 2.0).round();
ui.text_line(
Face::Medium,
BUTTON_TEXT,
button_text(),
h,
lx,
button.y,
@@ -123,11 +131,11 @@ impl NoResources {
button.label,
);
}
let small_y = layout.buttons[0].y + CONTROL + 20.0;
let small_y = layout.buttons[0].y + control() + 20.0;
for (i, line) in wrap(
ui,
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
&self.looked_in,
layout.width,
)
@@ -137,11 +145,11 @@ impl NoResources {
let line_y = small_y + i as f32 * SMALL_LINE;
ui.text_line(
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
SMALL_LINE,
layout.x,
line_y,
color::MUTED,
color::muted(),
line,
);
}
@@ -157,14 +165,22 @@ impl NoResources {
}
}
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
fn title_size() -> f32 {
ui_theme::sizes().title
}
const TITLE_LINE: f32 = 40.0;
const BODY: f32 = horizon_theme::BODY.px;
fn body() -> f32 {
ui_theme::sizes().body
}
const LINE: f32 = 24.0;
const PARAGRAPH_GAP: f32 = 10.0;
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
fn button_text() -> f32 {
ui_theme::sizes().ui
}
const SMALL_LINE: f32 = 18.0;
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
fn control() -> f32 {
ui_theme::sizes().control_large
}
struct Layout {
x: f32,
@@ -184,7 +200,7 @@ struct Button {
impl Button {
fn contains(&self, (cx, cy): (f32, f32)) -> bool {
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + CONTROL
cx >= self.x && cx < self.x + self.w && cy >= self.y && cy < self.y + control()
}
}
@@ -232,7 +248,7 @@ mod tests {
for size in [[1280, 720], [800, 600], [2560, 1440]] {
let (download, quit, density) = with_ui(size, (0.0, 0.0), |ui| {
let layout = screen.layout(ui);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + CONTROL / 2.0);
let center = |b: &Button| (b.x + b.w / 2.0, b.y + control() / 2.0);
(
center(&layout.buttons[0]),
center(&layout.buttons[1]),
@@ -255,7 +271,7 @@ mod tests {
let layout = screen.layout(ui);
let last = layout.buttons.last().unwrap();
assert!(
last.x + last.w <= ui.w && last.y + CONTROL <= ui.h,
last.x + last.w <= ui.w && last.y + control() <= ui.h,
"{size:?}"
);
});
@@ -266,10 +282,10 @@ mod tests {
fn text_wraps_within_the_column_and_keeps_every_word() {
with_ui([1280, 720], (0.0, 0.0), |ui| {
for paragraph in PARAGRAPHS {
let lines = wrap(ui, Face::Regular, BODY, paragraph, 300.0);
let lines = wrap(ui, Face::Regular, body(), paragraph, 300.0);
assert!(lines.len() > 1, "{paragraph}");
for line in &lines {
assert!(ui.measure(Face::Regular, BODY, line) <= 300.0, "{line}");
assert!(ui.measure(Face::Regular, body(), line) <= 300.0, "{line}");
}
assert_eq!(lines.join(" "), paragraph);
}
+12 -12
View File
@@ -293,16 +293,16 @@ impl Keyboard {
y0 - 12.0,
width + 24.0,
height,
ui_theme::RADIUS_PLATE,
color::with_alpha(color::BG, 0.94),
ui_theme::radius_plate(),
color::with_alpha(color::bg(), 0.94),
);
ui.border(
x0 - 12.0,
y0 - 12.0,
width + 24.0,
height,
ui_theme::RADIUS_PLATE,
color::BORDER,
ui_theme::radius_plate(),
color::border(),
);
for (r, row) in self.rows().iter().enumerate() {
let y = y0 + r as f32 * (KEY_H + GAP);
@@ -314,13 +314,13 @@ impl Keyboard {
let lit = matches!(key, Key::Shift) && (self.shift || self.caps)
|| matches!(key, Key::Layer) && self.symbols;
let (plate, ink) = if focused {
(color::ACCENT, color::ON_ACCENT)
(color::accent(), color::on_accent())
} else if lit {
(color::HOVER, color::TEXT)
(color::hover(), color::text())
} else {
(color::RAISED, color::TEXT)
(color::raised(), color::text())
};
ui.rounded(x, y, w, KEY_H, ui_theme::RADIUS, plate);
ui.rounded(x, y, w, KEY_H, ui_theme::radius(), plate);
let label = match key {
Key::Char(ch) => self.letter(*ch),
Key::Shift if self.caps => "CAPS".into(),
@@ -331,10 +331,10 @@ impl Keyboard {
Key::Backspace => tr("horizon.osk.delete", "Delete"),
Key::Done => tr("gui.done", "Done"),
};
let lw = ui.measure(Face::Medium, ui_theme::BODY, &label);
let lw = ui.measure(Face::Medium, ui_theme::body(), &label);
ui.text_line(
Face::Medium,
ui_theme::BODY,
ui_theme::body(),
KEY_H,
x + ((w - lw) / 2.0).round(),
y,
@@ -359,10 +359,10 @@ impl Keyboard {
.collect();
ui.text_centered(
Face::Regular,
ui_theme::SMALL,
ui_theme::small(),
x0 + width / 2.0,
y0 + 5.0 * (KEY_H + GAP) + 2.0,
color::MUTED,
color::muted(),
&line.join(" "),
);
}
@@ -408,7 +408,7 @@ impl TimelineUi {
let tx = start_x + sec * px_per_sec;
if tx <= start_x + w {
// Major tick line
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::HEAD_RULE);
style::fill(batch, atlas, [tx, y + 8.0, 1.0, 8.0], style::head_rule());
// Label every 2 or 5 seconds depending on width
let is_labeled = if total_secs > 60.0 {
(sec as u32).is_multiple_of(10)
@@ -424,7 +424,7 @@ impl TimelineUi {
"horizon.studio.timeline.seconds",
&[Text::literal(seconds)],
);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::SOFT, true);
batch.text_styled(atlas, &label, tx + 2.0, y + 1.0, style::soft(), true);
}
}
sec += sec_step;
@@ -459,7 +459,7 @@ impl TimelineUi {
// Base lane well
let lane_rect = [start_x, y, track_w, bar_h];
style::fill(batch, atlas, lane_rect, 0xFF14_171F);
style::ring(batch, atlas, lane_rect, style::ROW_RULE);
style::ring(batch, atlas, lane_rect, style::row_rule());
let total_dur = self.duration_ms.max(1) as f32;
@@ -514,7 +514,7 @@ impl TimelineUi {
&scene_title,
seg_x + 5.0,
y + 5.0,
style::MUTED,
style::muted(),
true,
);
}
@@ -577,7 +577,7 @@ impl TimelineUi {
let px = start_x + norm * track_w;
// Scrubber line from ruler through bottom of tracks
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::ACCENT);
style::fill(batch, atlas, [px - 1.0, y, 2.0, h], style::accent());
// Inverted triangle marker at ruler top
let tri_color = style::WHITE;
+8 -4
View File
@@ -178,7 +178,7 @@ impl TopBarUi {
);
}
let color = if open {
style::ACCENT
style::accent()
} else if hovered {
style::WHITE
} else {
@@ -220,7 +220,7 @@ impl TopBarUi {
if pause_hovered {
style::WHITE
} else {
style::MUTED
style::muted()
},
true,
);
@@ -339,7 +339,11 @@ impl TopBarUi {
&Text::translate(key, &[]),
x + 8.0,
y + 6.0,
if hovered { style::WHITE } else { style::MUTED },
if hovered {
style::WHITE
} else {
style::muted()
},
true,
);
if !shortcut.is_empty() {
@@ -350,7 +354,7 @@ impl TopBarUi {
&shortcut,
x + menu_width - shortcut_width - 8.0,
y + 6.0,
style::SOFT,
style::soft(),
true,
);
}
+111 -46
View File
@@ -1,6 +1,6 @@
//! The design system's widgets drawn in the game (`crates/horizon-theme`:
//! palette, type scale, radii, control sizes, motion and the bundled fonts;
//! `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! The design system's widgets drawn in the game: palette, type scale, radii,
//! control sizes and fonts from `menu_style`, given at startup (in the official
//! game `crates/horizon-theme`, `docs/design-system.md`). Every menu is vanilla's (`ui`); what is left
//! here draws Horizon's own overlays that vanilla has no screen for: the
//! on-screen keyboard a controller types with (`osk`), plus the
//! `horizon.txt` preferences.
@@ -39,44 +39,76 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
pub(crate) mod color {
use horizon_theme::DARK as P;
pub const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
use horizon_client_render::menu_style::{self, OverlayColours};
pub use menu_style::hud_colour as hex;
fn p() -> &'static OverlayColours {
&menu_style::get().overlay
}
/// The page, under every veil.
pub const BG: u32 = hex(P.page, 255);
pub fn bg() -> u32 {
hex(p().page, 255)
}
/// Hover plates, secondary buttons, fields, the segmented track: the
/// dark palette's soft plate, as ink over the veil so the plates read the
/// same over any backdrop (over the page colour they are `plate_soft`).
pub const RAISED: u32 = hex(P.ink, 20);
pub fn raised() -> u32 {
hex(p().ink, 20)
}
/// Pressed plates and a switch that is off (`plate`, the same way).
pub const HOVER: u32 = hex(P.ink, 36);
pub fn hover() -> u32 {
hex(p().ink, 36)
}
/// One-pixel rules between rows, slider tracks.
pub const BORDER: u32 = hex(P.rule, 255);
pub fn border() -> u32 {
hex(p().rule, 255)
}
/// Text and selection (`ink`).
pub const TEXT: u32 = hex(P.ink, 255);
pub fn text() -> u32 {
hex(p().ink, 255)
}
/// Values and secondary text (`ink_soft`).
pub const MUTED: u32 = hex(P.ink_soft, 255);
pub const ACCENT: u32 = hex(P.accent, 255);
pub const ON_ACCENT: u32 = hex(P.on_accent, 255);
pub fn muted() -> u32 {
hex(p().ink_soft, 255)
}
pub fn accent() -> u32 {
hex(p().accent, 255)
}
pub fn on_accent() -> u32 {
hex(p().on_accent, 255)
}
pub(crate) fn with_alpha(c: u32, a: f32) -> u32 {
let base = (c >> 24) as f32;
(c & 0x00ff_ffff) | ((base * a.clamp(0.0, 1.0)).round() as u32) << 24
}
}
/// The launcher's type scale and shapes (`horizon_theme`), in dp.
pub(crate) const SMALL: f32 = horizon_theme::CAPTION.px;
pub(crate) const BODY: f32 = horizon_theme::LABEL.px;
/// The boot screen's wordmark (`DISPLAY`) and console lines (`MICRO`).
pub(crate) const DISPLAY: f32 = horizon_theme::DISPLAY.px;
pub(crate) const MICRO: f32 = horizon_theme::MICRO.px;
/// Fields; plates (hover rows, selection); pills (buttons, segments).
pub(crate) const RADIUS: f32 = horizon_theme::RADIUS;
pub(crate) const RADIUS_PLATE: f32 = horizon_theme::RADIUS_PLATE;
/// The type scale and shapes (`menu_style`), in dp.
pub(crate) fn sizes() -> &'static horizon_client_render::menu_style::Sizes {
&horizon_client_render::menu_style::get().sizes
}
pub(crate) fn small() -> f32 {
sizes().caption
}
pub(crate) fn body() -> f32 {
sizes().label
}
/// The boot screen's wordmark (`display`) and console lines (`micro`).
fn display() -> f32 {
sizes().display
}
fn micro() -> f32 {
sizes().micro
}
/// Fields; plates (hover rows, selection).
pub(crate) fn radius() -> f32 {
sizes().radius
}
pub(crate) fn radius_plate() -> f32 {
sizes().radius_plate
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Face {
@@ -87,20 +119,34 @@ pub(crate) enum Face {
/// Counts, timestamps and the boot screen's console lines.
Mono,
}
#[cfg(not(test))]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> = None;
#[cfg(test)]
const TEST_FONTS: Option<horizon_client_render::menu_style::Fonts> =
Some(horizon_client_render::menu_style::Fonts {
regular: horizon_theme::fonts::GEIST_REGULAR,
medium: horizon_theme::fonts::GEIST_MEDIUM,
semibold: horizon_theme::fonts::GEIST_SEMIBOLD,
mono: horizon_theme::fonts::GEIST_MONO,
});
struct Fonts {
faces: [FontRef<'static>; 4],
}
impl Fonts {
fn load() -> Self {
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
Self {
/// The faces given in `menu_style`; none given, none (unit tests take the design system's).
fn load() -> Option<Self> {
let given = horizon_client_render::menu_style::get()
.fonts
.or(TEST_FONTS)?;
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("given font");
Some(Self {
faces: [
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
f(given.regular),
f(given.medium),
f(given.semibold),
f(given.mono),
],
}
})
}
fn face(&self, f: Face) -> &FontRef<'static> {
&self.faces[f as usize]
@@ -142,7 +188,8 @@ struct Cached {
}
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
struct Cache {
fonts: Fonts,
/// None: no fonts were given, and text draws nothing.
fonts: Option<Fonts>,
entries: HashMap<Key, Cached>,
shelf: (u32, u32, u32),
pending: Vec<crate::font::Patch>,
@@ -230,9 +277,16 @@ impl Cache {
if let Some(c) = self.entries.get(&key) {
return *c;
}
let scale = self.fonts.scale(face, f32::from(size) / 4.0);
let Some(fonts) = &self.fonts else {
return Cached {
rect: None,
left: 0.0,
top: 0.0,
};
};
let scale = fonts.scale(face, f32::from(size) / 4.0);
let glyph = id.with_scale_and_position(scale, ab_glyph::point(0.0, 0.0));
let made = match self.fonts.face(face).outline_glyph(glyph) {
let made = match fonts.face(face).outline_glyph(glyph) {
Some(outline) => {
let b = outline.px_bounds();
let (w, h) = (b.width() as u32, b.height() as u32);
@@ -459,14 +513,17 @@ impl<'a> Ui<'a> {
let atlas = self.atlas;
let gui = self.scale;
with_cache(atlas, |c| {
let scale = c.fonts.scale(face, px);
let Some(fonts) = &c.fonts else {
return (0.0, 0.0, 0.0, Vec::new());
};
let scale = fonts.scale(face, px);
let (ascent, descent) = {
let f = c.fonts.face(face).as_scaled(scale);
let f = fonts.face(face).as_scaled(scale);
(f.ascent(), f.descent())
};
let chars: Vec<(char, Option<crate::font::Glyph>)> = unformatted(text)
.map(|ch| {
let pack = (!ch.is_whitespace() && c.fonts.face(face).glyph_id(ch).0 == 0)
let pack = (!ch.is_whitespace() && fonts.face(face).glyph_id(ch).0 == 0)
.then(|| atlas.font.glyph(ch))
// The pack lacks it too (its missing glyph): Geist's box, like vanilla's.
.filter(|g| g.rect.is_some() || g.right <= 0.0);
@@ -491,7 +548,10 @@ impl<'a> Ui<'a> {
continue;
}
let (id, advance, kern) = {
let font = c.fonts.face(face);
let Some(fonts) = &c.fonts else {
break;
};
let font = fonts.face(face);
let id = font.glyph_id(ch);
let f = font.as_scaled(scale);
(id, f.h_advance(id), prev.map_or(0.0, |p| f.kern(p, id)))
@@ -606,8 +666,8 @@ pub fn draw_boot(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
/// The wordmark with its tagline, centred.
fn wordmark(atlas: &HudAtlas, batch: &mut HudBatch) {
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let size = (ui.w / 16.0).clamp(DISPLAY, 96.0);
let small = (size * 0.25).clamp(MICRO, 18.0);
let size = (ui.w / 16.0).clamp(display(), 96.0);
let small = (size * 0.25).clamp(micro(), 18.0);
let block = size * 1.45 + small * 0.6 + small * 1.5;
let top = (ui.h - block) / 2.0;
ui.text_centered(Face::SemiBold, size, ui.w / 2.0, top, BOOT_TEXT, "Horizon");
@@ -629,7 +689,7 @@ pub fn draw_boot_log(atlas: &HudAtlas, batch: &mut HudBatch, logs: &[String]) {
return;
}
let mut ui = Ui::new(atlas, batch, (0.0, 0.0));
let (size, line, pad) = (MICRO, MICRO * 1.5, 16.0);
let (size, line, pad) = (micro(), micro() * 1.5, 16.0);
let max_w = ui.w / 2.0;
let max_rows = (((ui.h - pad) / line) as usize).clamp(1, 24);
let mut y = ui.h - pad - line;
@@ -696,7 +756,12 @@ mod tests {
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
reset_atlas();
let draw = |cache: &mut Cache| {
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
let glyph = cache
.fonts
.as_ref()
.unwrap()
.face(Face::Regular)
.glyph_id('A');
assert!(
cache
.glyph(&atlas, Face::Regular, glyph, 16.0)
@@ -782,7 +847,7 @@ mod tests {
let lines = wrapping_lines(
&ui,
Face::Mono,
MICRO,
micro(),
"the quick brown fox jumps over the lazy dog",
90.0,
);
@@ -790,7 +855,7 @@ mod tests {
assert!(
lines
.iter()
.all(|l| ui.measure(Face::Mono, MICRO, l) <= 90.0),
.all(|l| ui.measure(Face::Mono, micro(), l) <= 90.0),
"every wrapped line fits"
);
assert_eq!(
@@ -812,7 +877,7 @@ mod tests {
fn drawn(atlas: &HudAtlas, text: &str) -> (f32, Vec<([f32; 2], [f32; 2])>) {
let mut batch = HudBatch::new([1280, 720]);
let mut ui = Ui::new(atlas, &mut batch, (0.0, 0.0));
let w = ui.text_line(Face::Medium, BODY, 20.0, 10.0, 10.0, color::TEXT, text);
let w = ui.text_line(Face::Medium, body(), 20.0, 10.0, 10.0, color::text(), text);
let quads = batch
.vertices
.iter()
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "horizon-discord"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Discord Rich Presence: \"Playing Horizon\" on the player's Discord profile (docs/native-client.md)"
[dependencies]
horizon-platform = { path = "../horizon-platform" }
log = "0.4"
serde_json = "1.0.151"
[lints]
workspace = true
@@ -1,21 +1,30 @@
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! length u32 LE + JSON). The game says what the player does on each change ([`set`]), which wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
//! or with `HORIZON_NO_DISCORD=1`.
//!
//! A Horizon crate (docs/layers.md): the game tells it what the player does through the
//! platform's hooks ([`HOOKS`]: `client_started`, `activity`, `shutdown`), which the official
//! executable (`horizon-official`) registers with the switch: its line of `horizon-extras.txt`
//! ([`SETTING`]) and its row in Extras' Multiplayer group ([`SECTION`]). horizon-client's own
//! executable has no presence.
#![forbid(unsafe_code)]
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Condvar, Mutex, OnceLock};
use std::thread::Thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_command::args::GameMode;
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_platform::pages::{Act, Label, Page, Place, Row};
use horizon_platform::settings::Store;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -40,24 +49,141 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
/// The presence's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Presence = Presence;
pub struct Presence;
impl Hooks for Presence {
/// Starts the presence thread with the game's window (never for a dedicated server).
fn client_started(&self, client: &hooks::Client) {
start(client.automated);
}
fn activity(&self, now: Activity) {
set(match now {
Activity::Menus => Status::Menus,
Activity::World {
play,
dimension,
mode,
} => Status::World {
play,
dimension,
mode,
},
// Anything a later game says that this crate doesn't know yet.
_ => Status::Off,
});
}
/// On quit: the activity is cleared.
fn shutdown(&self) {
stop();
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
Hosting {
players: usize,
max: usize,
},
/// The switch's line in `horizon-extras.txt`, where the engine's Extras kept it before.
const KEY: &str = "discordPresence";
/// The switch's row, and what it says on hover.
const LABEL: &str = "horizon.extras.discord_presence";
const ABOUT: &str = "horizon.extras.discord_presence.tooltip";
/// The switch (on by default). One presence per computer: the first splitscreen player's
/// switch, in their settings, on their Extras page; the other players have none.
static ON: AtomicBool = AtomicBool::new(true);
/// Whether the player whose turn it is owns the switch: the first one.
fn first_player() -> bool {
hooks::seat() == 0
}
/// Turns the presence on or off; off clears the activity at once.
pub fn set_enabled(on: bool) {
ON.store(on, Ordering::Relaxed);
let mut shared = lock();
let status = shown(shared.game);
let changed = shared.wanted.update(status, unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
pub fn enabled() -> bool {
ON.load(Ordering::Relaxed)
}
/// What Discord is told for the game's `status`: nothing with the switch off.
fn shown(status: Status) -> Status {
if enabled() { status } else { Status::Off }
}
/// The switch's line of `horizon-extras.txt`, for `horizon_platform::settings::register`.
pub static SETTING: Setting = Setting;
pub struct Setting;
impl Store for Setting {
fn file(&self) -> &'static str {
"horizon-extras.txt"
}
fn owns(&self, key: &str) -> bool {
key == KEY
}
fn load(&self, _key: &str, value: &str) {
if !first_player() {
return;
}
match value {
"true" => set_enabled(true),
"false" => set_enabled(false),
_ => {}
}
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
if first_player() {
line(KEY, if enabled() { "true" } else { "false" });
}
}
fn is_default(&self) -> bool {
enabled() || !first_player()
}
}
/// The switch's row in the Extras page's Multiplayer group, for
/// `horizon_platform::pages::register`.
pub static SECTION: Section = Section;
pub struct Section;
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-discord.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if !first_player() {
return;
}
rows.push(Row::Toggle {
label: LABEL,
on: enabled(),
});
}
fn act(&self, row: usize, act: Act) {
if row == 0 && first_player() && matches!(act, Act::Press | Act::PressBack) {
set_enabled(!enabled());
}
}
fn tooltip(&self, _row: usize) -> Option<Label> {
Some(Label::Key(ABOUT))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -97,11 +223,15 @@ impl Wanted {
}
struct Shared {
/// What the game said last.
game: Status,
/// What Discord is to show: the game's, or nothing with the switch off.
wanted: Wanted,
stop: bool,
stopped: bool,
}
static SHARED: Mutex<Shared> = Mutex::new(Shared {
game: Status::Menus,
wanted: Wanted {
status: Status::Menus,
since: None,
@@ -114,7 +244,8 @@ static THREAD: OnceLock<Thread> = OnceLock::new();
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
pub fn start(enabled: bool, automated: bool) {
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
if THREAD.get().is_some()
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
@@ -122,9 +253,6 @@ pub fn start(enabled: bool, automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -136,12 +264,13 @@ pub fn start(enabled: bool, automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Free without [`start`].
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
pub fn set(status: Status) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
let mut shared = lock();
shared.game = status;
let changed = shared.wanted.update(shown(status), unix_now());
drop(shared);
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
@@ -582,6 +711,37 @@ mod tests {
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
#[cfg(unix)]
/// The switch as the player's settings and the Extras page see it. The only test that
/// touches the presence's statics.
#[test]
fn the_switch_clears_the_activity_at_once() {
let wanted = || lock().wanted.status;
Setting.load(KEY, "false");
set(Status::Menus);
assert!(!enabled());
assert_eq!(wanted(), Status::Off);
let mut rows = Vec::new();
SECTION.rows(&mut rows);
assert_eq!(
rows,
[Row::Toggle {
label: LABEL,
on: false
}]
);
let mut lines = Vec::new();
Setting.save(&mut |k, v| lines.push(format!("{k}:{v}")));
assert_eq!(lines, ["discordPresence:false"]);
assert!(!Setting.is_default());
// On from the page: what the game said last is shown at once.
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Menus);
assert!(Setting.is_default());
SECTION.act(0, Act::Press);
assert_eq!(wanted(), Status::Off);
SECTION.act(0, Act::Press);
}
#[test]
fn talks_to_discord_over_its_socket() {
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
+177 -5
View File
@@ -22,6 +22,7 @@ pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod share;
pub mod tunnel;
use registry::Registry;
@@ -77,12 +78,87 @@ pub trait Hooks: Sync {
/// Draws on a player model, after its vanilla layers, each time it is drawn.
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// Draws around the window's own model while its body is out of view (first person), once a
/// frame after the first-person model: [`players::Pass::FirstPerson`].
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
None
}
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
/// with the window's world ([`share`]). Only the window's own player calls it.
fn share(&self, _world: &mut dyn share::ShareView) {}
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
/// world. Called at that moment, by the window's own player only.
fn lan_closed(&self) {}
/// A game window opened: once, before the first frame, for every run that has a window,
/// automated ones included ([`Client::automated`]); never for a dedicated server.
fn client_started(&self, _client: &Client) {}
/// What the player is doing, for a status shown outside the game (Discord's): called when it
/// changes, not every frame. Splitscreen: the first player's only.
fn activity(&self, _now: Activity) {}
}
/// What [`Hooks::client_started`] gets.
#[non_exhaustive]
pub struct Client {
/// A run nobody plays: offscreen, a benchmark, a run that quits by itself after some frames.
pub automated: bool,
}
impl Client {
pub fn new(automated: bool) -> Self {
Self { automated }
}
}
/// What the player is doing ([`Hooks::activity`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Activity {
/// On the title screen or another menu outside a world.
Menus,
World {
play: Play,
dimension: Dimension,
mode: GameMode,
},
}
/// How the player is in a world.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Play {
Singleplayer,
/// A server or someone else's LAN world.
Multiplayer,
/// Their world is open to LAN or friends; `players` counts the host, `max` is how many may
/// join.
Hosting {
players: usize,
max: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GameMode {
Survival,
Creative,
Adventure,
Spectator,
}
/// What [`Hooks::started`] gets.
@@ -123,6 +199,15 @@ pub fn register(hooks: &'static dyn Hooks) -> Result<(), TooLate> {
}
/// The registered hooks, sealing the list on the first call.
/// The game's translations, which it installs at start ([`engine::set_language`]).
static LANGUAGE: std::sync::OnceLock<fn(&str) -> Option<String>> = std::sync::OnceLock::new();
/// The text of a translation key in the player's language, if the game has it. For text a crate
/// makes outside a view, such as a page's rows. None before the game installs its languages.
pub fn tr(key: &str) -> Option<String> {
LANGUAGE.get().and_then(|language| language(key))
}
fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
@@ -157,6 +242,11 @@ pub mod engine {
crate::hud::engine::all();
}
/// Where [`crate::tr`] reads translations from; the first call wins.
pub fn set_language(language: fn(&str) -> Option<String>) {
let _ = LANGUAGE.set(language);
}
/// [`Hooks::started`].
pub fn started(game: &Started<'_>) {
for hooks in all() {
@@ -178,6 +268,32 @@ pub mod engine {
}
}
/// [`Hooks::client_started`].
pub fn client_started(client: &Client) {
for hooks in all() {
hooks.client_started(client);
}
}
/// The activity at the last call (`None` before the first).
static ACTIVITY: std::sync::Mutex<Option<Activity>> = std::sync::Mutex::new(None);
/// [`Hooks::activity`], when `now` differs from the last call's. Cheap enough to call every
/// frame: with no hook registered it only loads the empty list.
pub fn activity(now: Activity) {
let hooks = all();
if hooks.is_empty() {
return;
}
let mut last = ACTIVITY.lock().unwrap_or_else(|e| e.into_inner());
if last.replace(now) != Some(now) {
drop(last);
for h in hooks {
h.activity(now);
}
}
}
/// [`Hooks::shutdown`].
pub fn shutdown() {
for hooks in all() {
@@ -185,6 +301,20 @@ pub mod engine {
}
}
/// [`Hooks::share`].
pub fn share(world: &mut dyn share::ShareView) {
for hooks in all() {
hooks.share(world);
}
}
/// [`Hooks::lan_closed`].
pub fn lan_closed() {
for hooks in all() {
hooks.lan_closed();
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
@@ -217,6 +347,13 @@ pub mod engine {
}
}
/// [`Hooks::first_person`].
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.first_person(own, draw);
}
}
/// [`Hooks::camera`].
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
all().iter().find_map(|h| h.camera(own))
@@ -255,6 +392,9 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn activity(&self, now: Activity) {
self.0.lock().unwrap().push(format!("{now:?}"));
}
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
self.0
.lock()
@@ -285,7 +425,14 @@ mod tests {
return;
};
let turn = draw.ticks() / 100.0;
draw.draw(&HAT, head, uv, &mut |p| p.pose("brim")[4] = turn);
let look = players::Look::new(uv);
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
}
/// A spark at the eyes, as bright as the head turns.
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
let alpha = draw.motion().head_yaw / 90.0;
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
draw.particles([0.0; 4], &[spark], players::Light::Full);
}
fn tick(&self, models: &mut dyn players::PlayerModels) {
let mut ids = Vec::new();
@@ -362,9 +509,10 @@ mod tests {
))
});
/// A player drawn at age 50, its head where the camera is; logs each draw.
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
/// and its particles' alphas.
#[derive(Default)]
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>);
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
impl players::PlayerDraw for Canvas {
fn pass(&self) -> players::Pass {
players::Pass::World
@@ -372,6 +520,15 @@ mod tests {
fn ticks(&self) -> f32 {
50.0
}
fn base(&self) -> [[f32; 4]; 4] {
[[1.0, 0.0, 0.0, 0.0]; 4]
}
fn motion(&self) -> players::Motion {
players::Motion::new([0.0; 3], 45.0, 0.0)
}
fn view(&self) -> players::View {
players::View::default()
}
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
}
@@ -385,7 +542,7 @@ mod tests {
&mut self,
mesh: &std::sync::Arc<players::Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: players::Look,
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
) {
struct Rest([f32; 9]);
@@ -396,7 +553,10 @@ mod tests {
}
let mut brim = Rest(mesh.children[0].pose);
pose(&mut brim);
self.0.push((mesh.name.clone(), matrix, uv, brim.0[4]));
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
}
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
self.1.extend(particles.iter().map(|p| p.alpha));
}
}
@@ -415,6 +575,14 @@ mod tests {
for open in [false, true, true, true, false, false] {
engine::published(open);
}
let world = Activity::World {
play: Play::Singleplayer,
dimension: Dimension::Nether,
mode: GameMode::Creative,
};
for now in [Activity::Menus, Activity::Menus, world, world] {
engine::activity(now);
}
engine::shutdown();
assert!(engine::any());
let mut atlas = Atlas::default();
@@ -445,6 +613,8 @@ mod tests {
0.5
)]
);
engine::first_person(&model(players::Kind::Own), &mut canvas);
assert_eq!(canvas.1, [0.5]);
let mut models = Two(Default::default());
engine::tick(&mut models);
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
@@ -454,6 +624,8 @@ mod tests {
"started 0.2.31",
"published true",
"published false",
"Menus",
"World { play: Singleplayer, dimension: Nether, mode: Creative }",
"frame 4"
]
);
+6
View File
@@ -19,6 +19,12 @@ pub enum Place {
/// start a group of their own after the page's. A [`Row::Heading`] starts another group. A
/// [`Row::Key`] shows its key there, without changing it: give a page of its own to a key.
Extras,
/// What the pause menu's Friends icon opens (vanilla's `CommonButtons.friends`): the icon is
/// active while a page is registered here and the world can be shared (singleplayer). The
/// first page registered here is drawn as its own screen over the paused world, as a mod's
/// screen is: the title at the top, the rows centred down the middle, footer buttons beside
/// Done at the bottom. Text that would reach the footer is left out.
PauseFriends,
}
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
+152 -5
View File
@@ -274,8 +274,149 @@ impl Bone {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Pass {
/// In the world, seen by the camera.
/// In the world, seen by the camera (the window's own model too, in third person).
World,
/// The inventory's picture of the window's own model.
Picture,
/// The window's own model while its body is out of view (first person): no bone, only what
/// floats around it ([`crate::Hooks::first_person`]).
FirstPerson,
}
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Motion {
/// Where its feet are, in the world.
pub feet: [f64; 3],
/// Where its head turns (degrees, the game's yaw and pitch).
pub head_yaw: f32,
pub head_pitch: f32,
pub crouching: bool,
/// Lies in a bed.
pub asleep: bool,
/// Flies with an elytra.
pub flying: bool,
/// Swims (any part of the swimming pose).
pub swimming: bool,
}
impl Motion {
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
Self {
feet,
head_yaw,
head_pitch,
..Self::default()
}
}
/// With its poses: crouching, asleep, flying, swimming.
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
Self {
crouching,
asleep,
flying,
swimming,
..self
}
}
}
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct View {
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
pub camera: [f64; 3],
/// Its right and up, for what faces it.
pub right: [f32; 3],
pub up: [f32; 3],
/// Where the window's player looks, and their level left (first person).
pub forward: [f32; 3],
pub left: [f32; 3],
}
impl View {
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
Self {
camera,
right,
up,
..Self::default()
}
}
/// With where the window's player looks and their level left.
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
Self {
forward,
left,
..self
}
}
}
/// How a mesh or particles are lit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Light {
/// As the player model is.
Model,
/// Fully lit: it glows.
Full,
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
AtPlace,
}
/// How [`PlayerDraw::draw`] draws a mesh.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Look {
/// The sprite part it is textured by (u0, v0, u1, v1).
pub uv: [f32; 4],
pub light: Light,
/// Drawn see-through at this alpha, after every player, its back faces dropped.
pub see_through: Option<f32>,
}
impl Look {
/// Opaque, lit as the model.
pub fn new(uv: [f32; 4]) -> Self {
Self {
uv,
light: Light::Model,
see_through: None,
}
}
pub fn lit(self, light: Light) -> Self {
Self { light, ..self }
}
pub fn see_through(self, alpha: f32) -> Self {
Self {
see_through: Some(alpha),
..self
}
}
}
/// A quad that faces the camera ([`PlayerDraw::particles`]).
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Particle {
/// Its corners, relative to the camera.
pub corners: [[f32; 3]; 4],
/// Each corner's place across the sprite part (0 to 1).
pub uv: [[f32; 2]; 4],
pub alpha: f32,
}
impl Particle {
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
Self { corners, uv, alpha }
}
}
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
@@ -291,19 +432,25 @@ pub trait PlayerDraw {
fn pass(&self) -> Pass;
/// The model's age in ticks, with the frame's part of a tick.
fn ticks(&self) -> f32;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part.
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
fn base(&self) -> [[f32; 4]; 4];
fn motion(&self) -> Motion;
fn view(&self) -> View;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
/// no body is drawn: [`Pass::FirstPerson`]).
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
/// As [`Sprites::get`].
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
/// Draws `mesh` at `matrix`, textured by the sprite part `uv` (u0, v0, u1, v1) and lit as the
/// model is; `pose` poses its parts first.
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
uv: [f32; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
);
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
}
+53
View File
@@ -0,0 +1,53 @@
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
//! says the world stopped being open to LAN.
//!
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct LanOptions {
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
pub guest_commands: bool,
/// Guests play in the world's game mode.
pub force_game_mode: bool,
}
impl Default for LanOptions {
fn default() -> Self {
Self {
guest_commands: false,
force_game_mode: true,
}
}
}
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
/// method says so.
pub trait ShareView {
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
/// the title screen and on another server.
fn can_share(&self) -> bool;
/// The port the world is open to LAN on, if it is.
fn lan_port(&self) -> Option<u16>;
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
fn close_lan(&mut self);
/// The names of the other players in the world (not this one's). For a screen or a command,
/// not every tick.
fn players(&self, each: &mut dyn FnMut(&str));
/// A line in this player's chat, as the game's own system messages; `text` is already in
/// the player's language ([`ShareView::tr`]).
fn chat(&mut self, text: &str);
/// Puts `text` on the system clipboard.
fn copy(&mut self, text: &str);
/// Opens `url` in the system's browser.
fn open_url(&mut self, url: &str);
/// The translation of `key` in the player's language, if the game has one.
fn tr(&self, key: &str) -> Option<String>;
}
+1
View File
@@ -16,6 +16,7 @@ horizon-platform = { path = "../horizon-platform" }
horizon-heartbeat = { path = "../horizon-heartbeat" }
horizon-friends = { path = "../horizon-friends", features = ["game"] }
horizon-official-servers = { path = "../horizon-official-servers" }
horizon-discord = { path = "../horizon-discord" }
log = "0.4"
[build-dependencies]
+14 -5
View File
@@ -4,27 +4,36 @@
//! (`game-build.yml`); horizon-client keeps its own plain executable.
//!
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
//! crate.
//! `horizon_platform::hooks::Hooks`, and its settings store and pages if it has any, and
//! [`register`] adds them. Nothing here depends on an engine crate.
#![forbid(unsafe_code)]
use horizon_platform::hooks::{self, Hooks};
use horizon_platform::{pages, settings};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 3] = [
const PARTS: [&dyn Hooks; 4] = [
// The anonymous "I'm playing" ping.
&horizon_heartbeat::HOOKS,
// Play with Friends: `friend:` addresses join through its tunnel.
&horizon_friends::game::HOOKS,
// The official server in the player's server list.
&horizon_official_servers::HOOKS,
// "Playing Horizon" on the player's Discord profile.
&horizon_discord::HOOKS,
];
/// Their settings, in the player's settings files.
const STORES: [&dyn settings::Store; 1] = [&horizon_discord::SETTING];
/// Their rows and pages in the options screens.
const PAGES: [&dyn pages::Page; 1] = [&horizon_discord::SECTION];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
/// game, and so does the launcher's generated executable for native mods.
pub fn register() {
for part in PARTS {
if let Err(e) = hooks::register(part) {
let late = PARTS.into_iter().map(hooks::register);
let late = late.chain(STORES.into_iter().map(settings::register));
for result in late.chain(PAGES.into_iter().map(pages::register)) {
if let Err(e) = result {
// The executable registered after starting the game: a bug, not a player's problem.
log::warn!("Horizon: an in-game part did not register: {e}");
}
+7 -4
View File
@@ -50,11 +50,14 @@ pub mod hooks {
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
/// the crate's sprites, the camera, and what a crate draws on them.
pub use horizon_hooks::players::{
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark,
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
SpriteStore, Sprites,
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
share, tr, tunnel,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
+4 -4
View File
@@ -23,14 +23,14 @@ What changes in that build:
|---|---|---|
| Cosmetics, emotes and the emote wheel, "Show me as a Horizon player" | `game/cosmetics.rs` (+ `cosmetics/fx.rs`, `cosmetics/emotes.rs`, `cosmetics/emote_wheel.rs`), crate `horizon-cosmetics` | `game::cosmetics::enabled()`: the service is never asked, nothing is drawn, B opens no wheel |
| The official server added to the server list, the withdrawn partner server taken out | crate `horizon-official-servers` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent, Discord) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| Horizon Settings (Extras, PvP & HUD, Splitscreen, Studio, AI Agent) and Mods (with the registry browser) | `options_ui.rs` (`Page::Horizon` and below), `extras_ui.rs`, `extras.rs`, `pvp.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_hud.rs` | the Options screen shows vanilla's greyed Telemetry Data instead (`options_ui::HORIZON_EXTRAS`, set in `entry.rs`) |
| Discord Rich Presence and its switch | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Play with Friends | hosting: `game/friends.rs` (its page, drawn by `game/page_screen.rs`); joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| The anonymous "I'm playing" ping | crate `horizon-heartbeat` | not in horizon-client at all: only the official executable (`horizon-official`) registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
Credits & Attribution then says where the official build is (`horizon.plain.official`).
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features above, the gates
`grep -rn horizon-extras crates/` lists every gate. Apart from the `friends` feature above, the gates
are `cfg!(feature = "horizon-extras")`: both sides compile in every build, so no stub has to follow
the code it replaces. To remove an extra for good, delete its files from the table and the lines its
gate guards.
+9 -4
View File
@@ -156,8 +156,8 @@ Horizon's own extras and its product, and every built-in address of an outside s
benchmark uploads, releases and updates, the hosting supervisor, the launcher.
- **Examples:** `horizon-addresses`, `horizon-cosmetics`, `horizon-friends`, `horizon-relay`, `horizon-telemetry`,
`horizon-bench`, `horizon-release-sign`, `horizon-server`, `horizon-mod-registry`, `horizon-heartbeat`,
`horizon-official`, `horizon-official-servers`, `launcher/`. Still in engine crates today: `game/cosmetics.rs`, `game/emotes.rs`,
`game/discord.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
`horizon-official`, `horizon-official-servers`, `horizon-discord`, `launcher/`. Still in engine crates today:
`game/cosmetics.rs`, `game/emotes.rs`, `game/extras.rs`, `game/tweaks.rs`, `extras_ui.rs`, `pvp.rs`.
- **Rules:**
- It uses the platform's public API only, never the engine.
- Until it moves, every Horizon feature in the game sits behind the `horizon-extras` feature
@@ -287,7 +287,9 @@ build pays nothing.
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
to LAN). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
@@ -363,7 +365,7 @@ pub trait Store: Sync {
A page lives in the options screen's shell, and its entry sits on one of the game's menus.
```rust
pub enum Place { HorizonSettings, Title, Pause }
pub enum Place { HorizonSettings, Title, Pause, Extras, PauseFriends }
pub trait Page: Sync {
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
fn title(&self) -> &'static str; // translation key
@@ -391,6 +393,9 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
button on the title screen or the pause menu that opens the page. These buttons exist only when something
registered, so the vanilla menus keep vanilla's look. The page is drawn with the same widgets, tooltips,
narration, controller focus and `horizon-ctl` ids as the engine's pages.
- `PauseFriends`: what the pause menu's vanilla Friends icon opens. The icon is active while a page is registered
there and the world can be shared; the page is drawn as its own screen over the paused world (title, rows down the
middle, footer buttons beside Done), the way Play with Friends' screen always looked.
- `Button` and `Text` are built with `Row::button` and `Row::text` and their options: `.active(false)` greys a
button, `.half()` puts two side by side, `.footer()` sets one beside Done on a page drawn as its own screen,
`.quiet()` greys text, `.groups()` breaks it only between `-`-separated groups (an invite code). In the options
+11 -6
View File
@@ -423,13 +423,18 @@ While Discord runs on the same computer, the player's Discord profile shows
"Playing Horizon" and what they are doing: "In the menus", "Singleplayer",
"Multiplayer" or "Hosting a world" (with the party size, of 8), the dimension
("In the Nether") and the time since they entered the world. It never shows a
world's name or a server's address. Horizon Settings has the switch
(**Discord Rich Presence**, on by default; `discordPresence` in
`horizon-extras.txt`), and `HORIZON_NO_DISCORD=1` turns it off for one run.
world's name or a server's address. The switch is on the Extras page, in its
Multiplayer group (**Discord Rich Presence**, on by default; `discordPresence`
in `horizon-extras.txt`; in splitscreen only the first player has it), and
`HORIZON_NO_DISCORD=1` turns it off for one run.
`crates/horizon-client/src/discord_presence.rs` speaks Discord's local IPC
itself (the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its
own: the frame only hands it a status when it changes. It retries every 15 s
The crate `horizon-discord` (Horizon layer) speaks Discord's local IPC itself
(the `discord-ipc-N` socket or named pipe, no SDK) from a thread of its own.
The game hands it a status only when it changes, through the platform's
`activity` hook (`game/activity.rs` says what the player does), and keeps the
switch itself: its settings store and its Extras row (the platform's settings
and pages registries). Only the official executable (`horizon-official`)
registers it: horizon-client's own executable shows no presence and no switch. It retries every 15 s
while Discord isn't running and stays within Discord's 5 updates per 20 s.
Dedicated servers never start it, and automated runs (offscreen, benchmarks)
only talk to a fake Discord: `lab.game_env` sets `HORIZON_NO_DISCORD=1`, and
+7 -4
View File
@@ -26,7 +26,7 @@ that finds the port takes the session.
## How it works
**Hosting.** The pause menu's Friends button (vanilla's icon, beside Player Reporting) opens **Play
with Friends** (`game/friends/screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
with Friends** (game/friends.rs's page at `Place::PauseFriends`, drawn by `game/page_screen.rs`); its **Start Sharing** opens the world to LAN if it isn't (Open to
LAN's defaults), then the host's game, on its own:
1. listens on its **direct port**, TCP 25580 by default (the same port every time, so a port forwarded
@@ -54,9 +54,12 @@ router that opened the port, or with a forwarded port, that's it: no server took
reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a loopback port like any server.
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
horizon-client's own executable has none, so there a `friend:` address is refused. Hosting needs no hook: the
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
game only through `horizon_hooks`: `Hooks::share` each client tick with a `share::ShareView` (open the world to
LAN, its players, chat, the clipboard, a link), `Hooks::lan_closed`, its page (`pages::Place::PauseFriends`, what
the pause menu's Friends icon opens; a press runs on the next tick) and `tr`. It keeps what it shares in its own
statics. It still lives in horizon-client (`game/friends.rs`, the `friends` feature, which comes with
`horizon-extras`) until `horizon-ctl` offers commands to crates above the engine (#14167).
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
+2 -2
View File
@@ -427,8 +427,8 @@ game built in `Game::new`, the integrated server on its own thread. On that base
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers, no Discord presence or Play with Friends: the `discord` and `friends` features; nor the "I'm
playing" ping, which only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
controllers, no Play with Friends: the `friends` feature; nor Discord presence or the "I'm playing" ping, which
only the official executable has), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
+9
View File
@@ -6291,6 +6291,14 @@ def moving_piston_presentation_diagnostic():
return moving_piston_presentation(cmd, setup)
def piston_mob_bounce():
try:
from .piston_bounce_corpus import piston_bounce
except ImportError:
from piston_bounce_corpus import piston_bounce
return piston_bounce(cmd, setup)
def piston_entity_tick_order():
"""#9337: entities travel before moving pistons push them, not afterward."""
ops = []
@@ -7481,6 +7489,7 @@ def groups():
('wither spawn', wither_spawn()),
('riding teleport', riding_teleport()),
('piston entity tick order', piston_entity_tick_order()),
('piston mob bounce', piston_mob_bounce()),
('primed tnt box', primed_tnt_box()),
('primed tnt fall', primed_tnt_fall()),
('command block last output', command_block_last_output()),
+20 -7
View File
@@ -9,7 +9,7 @@ The game goes title -> world -> Nether -> quit through horizon-ctl, and the acti
single single player on its server thread
lan a host opens its world to LAN and a guest joins: the guest shows "Multiplayer",
the host "Hosting a world" with a party of 2 of 8
setting off in horizon-extras.txt: nothing reaches Discord; the Horizon Settings switch
setting off in horizon-extras.txt: nothing reaches Discord; the Extras switch
turns it on (the activity appears) and off (it is cleared)
late Discord starts after the game: found within the 15 s retry
@@ -136,7 +136,7 @@ class Game:
self.discord.start()
extra = {'HORIZON_HOME': self.home, 'HORIZON_TELEMETRY_URL': 'off', 'HORIZON_DISCORD_IPC_DIR': self.discord.dir}
self.env = lab.game_env(**extra)
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--resources',
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-official'), '--resources',
str(lab.RESOURCES.resolve()), *args)
def ctl_run(self, *words, timeout=600):
@@ -207,8 +207,21 @@ def lan(bins):
g.close()
def extras_row(game, label):
"""The Extras page's row whose label starts with `label`, scrolling the list down to it: only
the rows on screen are widgets."""
first = game.ctl_run('ui')['widgets'][0]['bounds']
for _ in range(20):
found = [w for w in game.ctl_run('ui')['widgets'] if (w.get('label') or '').startswith(label)]
if found:
return found[0]
game.ctl_run('scroll-at', str(first[0] + 5), str(first[1] + 5), '-5')
time.sleep(0.3)
raise AssertionError(f'no {label!r} row on the Extras page')
def setting(bins):
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Horizon Settings: the
"""Off in horizon-extras.txt: Discord never hears from the game. Turned on in Extras: the
activity appears; off again: it is cleared."""
game = Game(bins, 'setting', extras='discordPresence:false\n')
d = game.discord
@@ -216,8 +229,8 @@ def setting(bins):
game.ctl_run('wait-for', 'screen', 'title')
time.sleep(3)
assert not d.handshakes and not d.activities, (d.handshakes, d.activities)
game.ctl_run('options', 'horizon')
switch = next(w for w in game.ctl_run('ui')['widgets'] if w.get('label', '').startswith('Discord Rich'))
game.ctl_run('options', 'extras')
switch = extras_row(game, 'Discord Rich')
assert switch['label'] == 'Discord Rich Presence: Off', switch
game.ctl_run('click', switch['id'])
d.wait('turned on', shows('In the menus'))
@@ -251,14 +264,14 @@ CHECKS = {'single': single, 'lan': lan, 'setting': setting, 'late': late}
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--check', choices=[*CHECKS, 'all'], default='all')
parser.add_argument('--bin-dir', help='prebuilt horizon-client and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--bin-dir', help='prebuilt horizon-official and horizon-ctl (default: build with the fast profile)')
args = parser.parse_args()
if sys.platform == 'win32':
sys.exit('the fake Discord is a Unix socket: macOS or Linux only')
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-official', '-p', 'horizon-client-lib', '--bins'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
capture_output=True, text=True, check=True).stdout)
+2 -4
View File
@@ -51,6 +51,7 @@
"horizon-addresses": "horizon",
"horizon-bench": "horizon",
"horizon-cosmetics": "horizon",
"horizon-discord": "horizon",
"horizon-friends": "horizon",
"horizon-heartbeat": "horizon",
"horizon-launcher": "horizon",
@@ -95,7 +96,6 @@
"horizon-client -> horizon-shaders",
"horizon-client -> horizon-telemetry",
"horizon-client -> horizon-theme",
"horizon-client-input -> horizon-theme",
"horizon-client-lib -> horizon-anticheat",
"horizon-client-lib -> horizon-bench",
"horizon-client-lib -> horizon-chat-filter",
@@ -106,13 +106,11 @@
"horizon-client-lib -> horizon-shader-api",
"horizon-client-lib -> horizon-shaders",
"horizon-client-lib -> horizon-telemetry",
"horizon-client-lib -> horizon-theme",
"horizon-client-render -> horizon-cosmetics",
"horizon-client-render -> horizon-hdr",
"horizon-client-render -> horizon-optifine",
"horizon-client-render -> horizon-polytone",
"horizon-client-render -> horizon-shader-api",
"horizon-client-render -> horizon-shaders",
"horizon-client-render -> horizon-theme"
"horizon-client-render -> horizon-shaders"
]
}
+64
View File
@@ -0,0 +1,64 @@
"""Actual downward piston pushes onto slime, including the blocked move (#12091)."""
def piston_bounce(cmd, setup):
ops = setup('/tick freeze', '/gamerule spawn_mobs false',
'/gamerule advance_time false', '/forceload add 0 0')
ops.append({'op': 'settle', 'required_chunks': [[0, 0]]})
ops += setup('/tp @s 1.5 200 1.5', '/gamemode creative @s',
'/kill @e[type=!player]', '/fill 0 199 0 15 199 15 stone',
'/fill 0 200 0 15 208 15 air',
'/scoreboard objectives add piston_bounce dummy',
'/execute store result score start piston_bounce run time query gametime')
cases = [('slime_block', 'slime_block', False),
('slime_block', 'stone', False),
('stone', 'slime_block', False),
('slime_block', 'slime_block', True)]
for index, (floor, top, no_ai) in enumerate(cases):
x, z = 4 + index % 2 * 6, 4 + index // 2 * 6
selector = f'@e[tag=piston_bounce_{index},limit=1]'
for command in (f'/setblock {x} 200 {z} {floor}',
f'/setblock {x} 204 {z} {top}',
f'/setblock {x} 205 {z} piston[facing=down]',
f'/summon wither_skeleton {x}.5 201 {z}.5 '
'{PersistenceRequired:1b,Invulnerable:1b,Motion:[0d,0d,0d],'
f'NoAI:{int(no_ai)}b,Tags:["piston_bounce_{index}"]' + '}'):
ops.append(cmd(command, expect_result=1))
ops += setup(f'/attribute {selector} movement_speed base set 0')
fields = (f'Pos:[{x}.5d,201d,{z}.5d],Motion:[0d,0d,0d],'
'Health:20f' + (',NoAI:1b' if no_ai else ''))
ops.append(cmd(f'/execute if entity @e[tag=piston_bounce_{index},limit=1,type=wither_skeleton,nbt={{' + fields + '}]',
expect_result=1))
ops.append(cmd(f'/execute unless entity @e[tag=piston_bounce_{index},nbt={{NoGravity:1b}}]',
expect_result=1))
if not no_ai:
ops.append(cmd(f'/execute unless entity @e[tag=piston_bounce_{index},nbt={{NoAI:1b}}]',
expect_result=1))
for tick in range(7):
if tick == 1:
for x, z in [(4, 4), (10, 4), (4, 10), (10, 10)]:
ops.append(cmd(f'/setblock {x + 1} 205 {z} redstone_block', expect_result=1))
if tick == 3:
# Remove the ceiling so the bounce becomes observable travel.
for x, z in [(4, 4), (10, 4), (4, 10), (10, 10)]:
ops += setup(f'/fill {x} 203 {z} {x} 205 {z} air')
for y in (203, 204, 205):
ops.append(cmd(f'/execute if block {x} {y} {z} air', expect_result=1))
if tick:
ops += setup('/tick step 1')
ops.append({'op': 'tick', 'n': 1})
ops += setup('/execute store result score now piston_bounce run time query gametime',
'/scoreboard players operation now piston_bounce -= start piston_bounce')
ops.append(cmd('/scoreboard players get now piston_bounce', expect_result=tick))
for index in range(len(cases)):
selector = f'@e[tag=piston_bounce_{index},limit=1]'
for field in ('Pos', 'Motion', 'OnGround'):
ops.append(cmd(f'/data get entity {selector} {field}'))
if tick in (1, 2):
x, z = 4 + index % 2 * 6, 4 + index // 2 * 6
ops.append(cmd(f'/execute if block {x} 205 {z} piston[facing=down,extended=true]',
expect_result=1))
ops.append(cmd(f'/data get block {x} 204 {z} progress'))
return ops + setup('/kill @e[type=!player]', '/fill 0 200 0 15 208 15 air',
'/scoreboard objectives remove piston_bounce', '/forceload remove 0 0',
'/tick unfreeze', '/tp @s 8.5 230 8.5 0 0')