Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 9dc8b78a8f runner watch: one issue when jobs wait for self-hosted runners that are offline
Jobs that need the maintainer's machines (bots on horizon-light, parity, parity-main, the Mac's
Nightly and launcher builds) wait up to a day when those machines are off, then fail, and nothing
says why. Every 30 minutes, on GitHub's runners, tools/ci/runner_watch.py lists the jobs of queued and
running runs that wait for a self-hosted runner; when one has waited over 2 hours in a pool where no
job runs at all, it opens one issue naming the pools, what stops and what to do, keeps it current,
and closes it once nothing waits. Only the workflow token (actions: read, issues: write).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 00:00:38 +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 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
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
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 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 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 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 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
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 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
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
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
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
70 changed files with 2051 additions and 1314 deletions

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
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",
+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 -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)
}
@@ -171,7 +171,7 @@ impl OptionsUi {
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Space(_) => continue,
added::Row::Button {
label,
active,
+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;
+1
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.
+5 -14
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;
@@ -1499,7 +1498,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)
@@ -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();
@@ -12529,15 +12527,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, &early_settings);
// 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,76 @@
//! 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, settings: &Settings) {
if options.dedicated.is_some() {
return;
}
let automated = options.benchmark.is_some()
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
// The Discord switch's state is the first thing the presence hears, so nothing is sent with
// it off.
horizon_hooks::engine::activity(match settings.extras.discord_presence {
true => Activity::Menus,
false => Activity::Hidden,
});
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.settings.extras.discord_presence {
return Activity::Hidden;
}
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,
},
}
}
}
+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
+286 -122
View File
@@ -21,8 +21,8 @@ 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, Mark, Mesh, NameDecoration,
PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, channel};
@@ -66,8 +66,8 @@ pub(super) struct Cosmetics {
/// 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)>,
/// Kept between players so a frame of avatars allocates none: a companion or an aura.
poses: RefCell<Poses>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Companions, trails and auras.
@@ -463,14 +463,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>,
}
/// 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
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
@@ -539,6 +570,10 @@ 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));
}
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 +699,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 +715,135 @@ 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.items.retain(|_, k| Arc::strong_count(&k.item) > 1);
}
/// The cosmetics `m` wears, on its bones, and an emote's props (companions, trails and auras
/// are still the game's: `cosmetics_geometry`).
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;
let shown = if own { said.mine } else { !said.others_hidden };
// "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 worn = match (&self.client, self.uuid_of(m)) {
(Some(client), Some(uuid)) if shown && bodies => client.frame().worn(uuid),
_ => 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<(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) {
// Covered by gear, or drawn by no bone (a name tag style, companions, trails, auras).
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, 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;
}
}
});
}
}
self.sprites
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
@@ -922,104 +1043,47 @@ impl Game<'_> {
}
}
/// 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.
/// The companion, trail and aura `m` wears, in the world (`camera`: where the view's camera
/// is); the items on its bones are the hook's (`Seat::player_layers`). `gpu`: a companion
/// goes on the GPU's skin path (the mob pass's skin frame, begun) and the bounds of what went
/// there come back, for culling; else its vertices go to `out`.
#[allow(clippy::too_many_arguments)]
pub(super) fn cosmetics_geometry(
pub(super) fn cosmetics_fx_of(
&self,
m: &Mob,
own: bool,
base: glam::Mat4,
camera: Option<glam::DVec3>,
camera: 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) {
// "Name tags only": other players' styles beside their names, nothing around them.
let bodies = own || self.settings.extras.effective_others() == Others::Shown;
// The player's own in first person: only its companion, drawn after the drain of the
// first-person model (`cosmetics_fx_geometry`).
if !bodies || own && !self.own_body_in_view() {
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 worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
return None;
}
let mut poses = self.cosmetics.poses.borrow_mut();
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
}
for item in worn.iter() {
if let Slot::Companion | Slot::Trail | Slot::Aura = item.slot {
let at = fx::Wearer {
m,
own,
key: if own { OWN } else { m.id },
base,
camera,
alpha,
light,
};
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 = self.cosmetics_fx(&at, item, gpu, bounds, &mut poses, out);
}
}
bounds
@@ -1398,4 +1462,104 @@ 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).
struct Canvas {
pose: Option<Arc<dyn BodyPose>>,
sprite: Option<[f32; 4]>,
drawn: Vec<(String, [[f32; 4]; 4], [f32; 4], [f32; 3])>,
}
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 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],
uv: [f32; 4],
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.drawn
.push((mesh.name.clone(), matrix, 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(),
sprite: None,
drawn: Vec::new(),
};
// 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}");
}
}
}
@@ -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(
@@ -314,8 +314,7 @@ impl Game<'_> {
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);
self.pose_companion(item, m, alpha, &mut 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,
}
}
}
@@ -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)
+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);
});
}
}
+46 -2
View File
@@ -199,7 +199,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> {
@@ -336,7 +336,7 @@ impl Game<'_> {
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.then(|| self.cosmetics_fx_of(m, false, base, camera, alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
@@ -344,6 +344,50 @@ impl Game<'_> {
}
}
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
/// third person (`Pass::World`, then the cosmetics' companion, trail and aura still drawn
/// here), 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"
);
let fx = pass == Pass::World && super::cosmetics::enabled();
if !engine::any() && !fx {
return;
}
let base = render::base_transform(m, alpha, camera);
if engine::any() {
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base,
alpha,
light,
gpu: false,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
if fx {
self.cosmetics_fx_of(m, true, base, camera, alpha, light, false, out);
}
}
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
+1 -2
View File
@@ -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()
}
@@ -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)?;
@@ -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();
@@ -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)]
+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)]);
}
-41
View File
@@ -7,7 +7,6 @@ use crate::lan::discovery::Scanner;
use crate::text::{DARK_GRAY, GRAY, Text, format_translation};
use crate::title::confirm_top;
use horizon_protocol::servers::{self, ServerEntry};
pub use page::Form;
use std::{
collections::HashMap,
path::{Path, PathBuf},
@@ -139,8 +138,6 @@ pub enum Screen {
relay: String,
focus: u8,
},
/// A page a crate registered at the title screen's Friends icon, as a form.
Page(Form),
Disconnected {
reason: String,
/// Auto-reconnect's next attempt, until it is cancelled.
@@ -149,21 +146,6 @@ pub enum Screen {
lines: std::cell::Cell<usize>,
},
}
mod page;
/// What the title screen's Friends icon opens: the page a crate registered there
/// (`Place::Title`), else Join a Friend when an invite code can be joined. None: it is inactive.
pub fn title_entry() -> Option<Screen> {
match page::title_page() {
Some(page) => Some(Screen::Page(Form::open(page))),
None => crate::net::joins_friends().then(|| Screen::friend(true)),
}
}
/// Whether the title screen's Friends icon opens something ([`title_entry`]).
pub fn has_title_entry() -> bool {
page::title_page().is_some() || crate::net::joins_friends()
}
/// What a click asks the title screen to do.
pub enum Outcome {
None,
@@ -194,7 +176,6 @@ impl Screen {
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Page(form) => form.id(),
Self::Disconnected { .. } => "disconnected",
}
}
@@ -763,7 +744,6 @@ impl Screen {
53,
),
],
Self::Page(form) => form.buttons(w, h),
Self::Friend { code, .. } => vec![
b(
half(w) - 100.0,
@@ -884,7 +864,6 @@ impl Screen {
]),
)
}
Self::Page(form) => form.narration(),
Self::Friend { .. } => ScreenInfo::new(
"multiplayer/friend",
get("horizon.multiplayer.join_friend", "Join a Friend"),
@@ -1339,7 +1318,6 @@ impl Screen {
centered(batch, &line.plain(), top + 17.0 + i as f32 * 9.0, u32::MAX);
}
}
Self::Page(form) => form.draw(atlas, batch, caret, focused_at),
Self::Friend {
code, relay, focus, ..
} => {
@@ -1405,7 +1383,6 @@ impl Screen {
/// selected server or LAN game, as a click on it does.
pub fn confirm(&self) -> Outcome {
match self {
Self::Page(form) => form.confirm(),
Self::Direct { address } if servers::parse_address(address).is_some() => {
Outcome::Connect {
address: direct_address(address),
@@ -1454,9 +1431,6 @@ impl Screen {
saves: &Path,
) -> Outcome {
let (w, h) = size;
if let Self::Page(form) = self {
return form.click(id, x, y);
}
match (&mut *self, id) {
(
Self::List {
@@ -1849,7 +1823,6 @@ impl Screen {
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { focus: 1, .. } => 105.0,
Self::Friend { .. } => return None,
Self::Page(form) => return form.focused_box(),
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
@@ -1878,13 +1851,6 @@ impl Screen {
_ => None,
}
}
/// Whether a press off the buttons may land on one of its text fields.
pub fn has_fields(&self) -> bool {
matches!(
self,
Self::Edit { .. } | Self::Friend { .. } | Self::Page(_)
)
}
/// Join a friend, with the relay the player set before.
pub fn friend(from_title: bool) -> Self {
Self::Friend {
@@ -1944,9 +1910,6 @@ impl Screen {
}
/// The focused text field, its id and maximum length (copy and paste).
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
if let Self::Page(form) = self {
return form.focused_text();
}
let id = match self {
Self::Edit { focus: 0, .. } => SERVER_NAME,
Self::Edit { .. } => SERVER_ADDRESS,
@@ -1971,9 +1934,6 @@ impl Screen {
/// round to the first field. `button` is the focused button, if any; a field
/// has the focus (`focus` below 2) only while no button does.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
if let Self::Page(form) = self {
return form.tab_dir(reverse, buttons, button);
}
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
return;
};
@@ -2000,7 +1960,6 @@ impl Screen {
pub fn escape(&mut self, saves: &Path) -> bool {
match self {
Self::List { .. }
| Self::Page(_)
| Self::Friend {
from_title: true, ..
} => true,
@@ -1,467 +0,0 @@
//! A page a crate registers at `horizon_hooks::pages::Place::Title`, which the title screen's
//! Friends icon opens: a form drawn as Join a Friend and `ManageServerScreen` are. The title at
//! the top, then the rows from y 53 (a field's grey label, its 200-wide `EditBox` 11 below, the
//! next 41 lower; text 10 a line), and the page's buttons in a column under them from a quarter
//! of the height + 114, 24 apart, the one that leaves it last.
//!
//! The engine keeps each field's text while the form is open (the title screen's caret edits
//! it, as every field of its own) and tells the page each change before anything else reaches
//! it ([`Act::Edit`]), then Enter in a field ([`Act::Submit`]) and the presses. A page joins a
//! server with `horizon_hooks::pages::connect`.
use super::{ButtonSpec, Outcome, centered_x, half, quarter};
use crate::hud::{HudAtlas, HudBatch, rgba};
use horizon_hooks::pages::{self, Act, Page, Place, Row};
use std::cell::RefCell;
use std::time::Instant;
/// The first button's id; button `k` of the page is `FIRST + k`.
const FIRST: u8 = 100;
/// The text a line of the form wraps at.
const WIDTH: f32 = 300.0;
/// The page the title screen's Friends icon opens: the first registered there.
pub(super) fn title_page() -> Option<&'static dyn Page> {
pages::engine::all()
.iter()
.copied()
.find(|page| page.place() == Place::Title)
}
/// An open form: the page, and what the engine keeps while it is open.
#[derive(Clone)]
pub struct Form {
page: &'static dyn Page,
/// Each field's text, in the order the rows have them.
texts: Vec<String>,
/// The text the page last heard of each field.
sent: RefCell<Vec<String>>,
/// The focused field; `texts.len()` (or more) while a button has the focus.
pub(super) focus: usize,
/// Each field's box where the last draw put it.
boxes: RefCell<Vec<[f32; 4]>>,
}
impl std::fmt::Debug for Form {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Form")
.field("page", &self.page.id())
.field("texts", &self.texts)
.field("focus", &self.focus)
.finish()
}
}
/// A button of the form: its row (None: the one that leaves), label key, and whether it acts.
struct Button {
row: Option<usize>,
label: &'static str,
active: bool,
half: bool,
}
impl Form {
/// The form of `page`, its fields holding the text its rows start with, the first focused.
pub fn open(page: &'static dyn Page) -> Self {
let mut rows = Vec::new();
page.rows(&mut rows);
let texts: Vec<String> = rows
.iter()
.filter_map(|row| match row {
Row::Field { text, .. } => Some(text.clone()),
_ => None,
})
.collect();
Self {
page,
sent: RefCell::new(texts.clone()),
boxes: RefCell::new(vec![[0.0; 4]; texts.len()]),
texts,
focus: 0,
}
}
pub fn id(&self) -> &'static str {
self.page.id()
}
/// The page's rows, after it heard every change to its fields.
fn rows(&self) -> Vec<Row> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let fields = field_rows(&rows);
let mut sent = self.sent.borrow_mut();
let mut told = false;
for ((text, sent), row) in self.texts.iter().zip(sent.iter_mut()).zip(fields) {
if text != sent {
sent.clone_from(text);
self.page.act(row, Act::Edit(text.clone()));
told = true;
}
}
if told {
rows.clear();
self.page.rows(&mut rows);
}
rows
}
fn buttons_of(rows: &[Row], back: &'static str) -> Vec<Button> {
let mut buttons: Vec<Button> = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Button {
label,
active,
half,
..
} => Some(Button {
row: Some(r),
label,
active: *active,
half: *half,
}),
_ => None,
})
.collect();
buttons.push(Button {
row: None,
label: back,
active: true,
half: false,
});
buttons
}
/// The id of the button that leaves: Cancel's or Done's, for the controller.
fn back_id(&self) -> u8 {
if self.page.back() == "gui.cancel" {
57
} else {
50
}
}
pub fn buttons(&self, w: f32, h: f32) -> Vec<ButtonSpec> {
let rows = self.rows();
let cx = half(w);
let mut y = quarter(h) + 114.0;
let mut beside = false;
let mut out = Vec::new();
for (k, b) in Self::buttons_of(&rows, self.page.back())
.into_iter()
.enumerate()
{
let id = match b.row {
Some(_) => FIRST + k as u8,
None => self.back_id(),
};
let label = crate::lang::get(b.label);
let (x, bw) = match (b.half, beside) {
(true, true) => {
y -= 24.0;
beside = false;
(cx + 2.0, 98.0)
}
(true, false) => {
beside = true;
(cx - 100.0, 98.0)
}
(false, _) => {
beside = false;
(cx - 100.0, 200.0)
}
};
out.push((x, y, bw, label, b.active, id));
y += 24.0;
}
out
}
pub fn draw(
&self,
atlas: &HudAtlas,
batch: &mut HudBatch,
caret: &mut crate::ui::Caret,
focused_at: Instant,
) {
let w = batch.width;
let rows = self.rows();
let title = crate::lang::get(self.page.title());
let tw = HudBatch::text_width(atlas, &title);
batch.text(atlas, &title, centered_x(w, tw), 20.0, u32::MAX, true);
let mut y = 53.0;
let mut field = 0;
let mut boxes = self.boxes.borrow_mut();
for (r, row) in rows.iter().enumerate() {
let (text, color) = match row {
Row::Field { label, max, .. } => {
let Some(value) = self.texts.get(field) else {
break;
};
super::Screen::field(
atlas,
batch,
caret,
focused_at,
&crate::lang::get(label),
value,
("page.field", r),
*max,
(y, y + 11.0),
self.focus == field,
);
boxes[field] = [half(w) - 100.0, y + 11.0, 200.0, 20.0];
field += 1;
y += 41.0;
continue;
}
Row::Space(px) => {
y += f32::from(*px);
continue;
}
Row::Heading(key) => (crate::lang::get(key), u32::MAX),
Row::Note(text) => (text.clone(), rgba(160, 160, 160, 255)),
Row::Text { text, quiet, .. } => {
let grey = rgba(160, 160, 160, 255);
(text.clone(), if *quiet { grey } else { u32::MAX })
}
_ => continue,
};
for line in super::wrap(&text, WIDTH, atlas) {
let lw = HudBatch::text_width(atlas, &line);
batch.text(atlas, &line, centered_x(w, lw), y, color, true);
y += 10.0;
}
}
}
pub fn narration(&self) -> crate::widgets::ScreenInfo {
crate::widgets::ScreenInfo::new(self.page.id(), crate::lang::get(self.page.title()))
}
/// A press on button `id`, or on a field (`id` None).
pub fn click(&mut self, id: Option<u8>, x: f32, y: f32) -> Outcome {
match id {
Some(id) if id == self.back_id() => Outcome::Back,
Some(id) if id >= FIRST => {
let rows = self.rows();
let buttons = Self::buttons_of(&rows, self.page.back());
match buttons.get(usize::from(id - FIRST)) {
Some(Button {
row: Some(r),
active: true,
..
}) => self.act(*r, Act::Press),
_ => Outcome::None,
}
}
Some(_) => Outcome::None,
None => {
let hit = self
.boxes
.borrow()
.iter()
.position(|&[bx, by, bw, bh]| crate::ui::hovered((x, y), bx, by, bw, bh));
if let Some(field) = hit {
self.focus = field;
}
Outcome::None
}
}
}
/// Enter: the focused field submits.
pub fn confirm(&self) -> Outcome {
let rows = self.rows();
match field_rows(&rows).nth(self.focus) {
Some(r) => self.act(r, Act::Submit),
None => Outcome::None,
}
}
/// The page acts; a server it asked to join is joined.
fn act(&self, row: usize, act: Act) -> Outcome {
self.page.act(row, act);
match pages::engine::take_connect() {
Some(address) => Outcome::Connect {
address,
lan: false,
},
None => Outcome::None,
}
}
pub fn focused_box(&self) -> Option<[f32; 4]> {
self.boxes.borrow().get(self.focus).copied()
}
/// The focused field, its id and maximum length.
pub fn focused_text(&mut self) -> Option<(crate::clipboard::FieldId, &mut String, usize)> {
let mut rows = Vec::new();
self.page.rows(&mut rows);
let (r, max) = rows
.iter()
.enumerate()
.filter_map(|(r, row)| match row {
Row::Field { max, .. } => Some((r, *max)),
_ => None,
})
.nth(self.focus)?;
let text = self.texts.get_mut(self.focus)?;
Some((("page.field", r), text, max))
}
/// Tab through the fields, then the enabled `buttons`, then round to the first field.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
let fields = self.texts.len();
let stops = fields + buttons.len();
if stops == 0 {
return;
}
let at = match *button {
Some(id) => buttons.iter().position(|&b| b == id).map(|i| fields + i),
None => Some(self.focus.min(fields.saturating_sub(1))),
}
.unwrap_or(0);
let next = if reverse {
(at + stops - 1) % stops
} else {
(at + 1) % stops
};
if next < fields {
self.focus = next;
*button = None;
} else {
self.focus = fields;
*button = Some(buttons[next - fields]);
}
}
}
/// The rows that are fields, in order.
fn field_rows(rows: &[Row]) -> impl Iterator<Item = usize> + '_ {
rows.iter()
.enumerate()
.filter(|(_, row)| matches!(row, Row::Field { .. }))
.map(|(r, _)| r)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A join form: a code and a relay, Join while the code has 4 characters or more.
struct Join {
heard: Mutex<Vec<(usize, Act)>>,
code: Mutex<String>,
}
impl Page for Join {
fn id(&self) -> &'static str {
"test.join"
}
fn title(&self) -> &'static str {
"horizon.multiplayer.join_friend"
}
fn place(&self) -> Place {
Place::Title
}
fn rows(&self, rows: &mut Vec<Row>) {
let code = self.code.lock().unwrap().clone();
rows.push(Row::field("horizon.multiplayer.invite_code", code.clone()).max(8));
rows.push(Row::field(
"horizon.multiplayer.relay",
"relay.example:25599",
));
rows.push(Row::button("horizon.multiplayer.join").active(code.len() >= 4));
}
fn act(&self, row: usize, act: Act) {
if let (0, Act::Edit(text)) = (row, &act) {
text.clone_into(&mut self.code.lock().unwrap());
}
if matches!((row, &act), (2, Act::Press) | (0, Act::Submit)) {
pages::connect(format!("friend:{}", self.code.lock().unwrap()));
}
self.heard.lock().unwrap().push((row, act));
}
fn back(&self) -> &'static str {
"gui.cancel"
}
}
fn join() -> &'static Join {
Box::leak(Box::new(Join {
heard: Mutex::new(Vec::new()),
code: Mutex::new(String::new()),
}))
}
#[test]
fn a_form_lays_out_as_join_a_friend() {
let form = Form::open(join());
let buttons = form.buttons(427.0, 240.0);
// Join at a quarter of the height + 114, Cancel 24 below, both 200 wide and centred.
assert_eq!(
buttons
.iter()
.map(|(x, y, w, _, on, id)| (*x, *y, *w, *on, *id))
.collect::<Vec<_>>(),
[
(113.0, 174.0, 200.0, false, FIRST),
(113.0, 198.0, 200.0, true, 57)
]
);
assert_eq!(form.texts, ["", "relay.example:25599"]);
}
#[test]
fn typing_reaches_the_page_before_a_press_and_enter_submits() {
let page = join();
let mut form = Form::open(page);
let (id, text, max) = form.focused_text().unwrap();
assert_eq!((id, max), (("page.field", 0), 8));
text.push_str("AB");
// Join is greyed: the page heard "AB" before the buttons were laid out.
assert!(!form.buttons(427.0, 240.0)[0].4);
form.focused_text().unwrap().1.push_str("CD");
assert!(form.buttons(427.0, 240.0)[0].4);
match form.click(Some(FIRST), 0.0, 0.0) {
Outcome::Connect { address, lan } => {
assert_eq!((address.as_str(), lan), ("friend:ABCD", false))
}
_ => panic!("Join did not connect"),
}
match form.confirm() {
Outcome::Connect { address, .. } => assert_eq!(address, "friend:ABCD"),
_ => panic!("Enter did not submit"),
}
let heard = page.heard.lock().unwrap();
assert_eq!(
*heard,
[
(0, Act::Edit("AB".into())),
(0, Act::Edit("ABCD".into())),
(2, Act::Press),
(0, Act::Submit)
]
);
assert!(matches!(form.click(Some(57), 0.0, 0.0), Outcome::Back));
}
#[test]
fn tab_goes_through_the_fields_then_the_buttons() {
let mut form = Form::open(join());
let mut button = None;
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (1, None));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (2, Some(57)));
form.tab_dir(false, &[57], &mut button);
assert_eq!((form.focus, button), (0, None));
form.tab_dir(true, &[57], &mut button);
assert_eq!(button, Some(57));
assert!(form.focused_text().is_none());
}
}
-16
View File
@@ -523,22 +523,6 @@ pub fn is_invite(code: &str, full: bool) -> bool {
horizon_hooks::engine::tunnel().is_some_and(|r| r.is_code(code, full))
}
/// Whether an invite code can be joined: a resolver is registered (horizon-official registers
/// horizon-friends'). The title screen's Friends icon, which opens Join a Friend, is inactive
/// without one, as the pause menu's is without a page to share from.
pub fn joins_friends() -> bool {
#[cfg(test)]
if JOINS_FRIENDS_IN_TEST.get() {
return true;
}
horizon_hooks::engine::tunnel().is_some()
}
#[cfg(test)]
thread_local! {
/// A test's stand-in for a registered resolver: the registry is the process's and sealed.
pub(crate) static JOINS_FRIENDS_IN_TEST: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// An address as players and logs see it: a friend's invite code stays
/// out of it (its secret is in there).
pub fn shown_address(address: &str) -> String {
+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,
);
}
+10 -7
View File
@@ -1940,8 +1940,8 @@ impl Title {
)
.does(crate::widgets::Action::Exit),
];
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
// CommonButtons' icon buttons; Horizon's Join a Friend (an
// invite code) sits behind vanilla's Friends button.
for (i, (key, plain, id, icon)) in [
("gui.friends.open", "Friends", 5, "friends/friends"),
("options.language", "Language...", 6, "icon/language"),
@@ -1960,7 +1960,7 @@ impl Title {
y + 72.0,
20.0,
&lang::get_or(key, plain),
id != 5 || crate::multiplayer::has_title_entry(),
true,
id,
);
button.icon = Some((icon, 15.0));
@@ -3658,7 +3658,12 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
.map(|b| b.id);
if hit.is_some() {
self.focused_button = hit;
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
} else if matches!(
self.mode,
Mode::Multiplayer(
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
)
) {
// A press on a text field takes the focus from the button.
self.focused_button = None;
}
@@ -3777,7 +3782,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
crate::multiplayer::Outcome::None => Action::None,
},
(Mode::Main, Some(5)) => {
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
self.mode = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
Action::None
}
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
@@ -5301,8 +5306,6 @@ mod classic_tests {
#[test]
fn classic_main_menu_icons_route_language_and_accessibility() {
// Join a Friend needs a resolver (title/tests.rs tests the icon without one).
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
let mut title = Title::new(
PathBuf::new(),
Path::new("/nonexistent/horizon-test-resources"),
+2 -50
View File
@@ -100,61 +100,13 @@ impl Drop for Fixture {
}
}
/// Join a Friend needs a resolver; these tests stand one in (`net::JOINS_FRIENDS_IN_TEST`).
fn with_resolver() -> Fixture {
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
Fixture::new()
}
#[test]
fn without_a_resolver_the_friends_icon_is_inactive_and_opens_nothing() {
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
let mut f = Fixture::new();
let icons: Vec<_> = f
.title
.buttons(480.0, 262.0)
.into_iter()
.filter(|b| b.icon.is_some())
.map(|b| (b.label.clone(), b.enabled, b.x))
.collect();
// Same place and look as with one, only greyed; Language and Accessibility stay active.
let friends = icons.iter().find(|(label, ..)| label == "Friends").unwrap();
assert!(!friends.1);
assert!(
icons
.iter()
.filter(|(label, ..)| label != "Friends")
.all(|(_, on, _)| *on)
);
assert_eq!(f.click("Friends"), Action::None);
assert!(matches!(f.title.mode, Mode::Main));
crate::net::JOINS_FRIENDS_IN_TEST.set(true);
let with: Vec<_> = f
.title
.buttons(480.0, 262.0)
.into_iter()
.filter(|b| b.icon.is_some())
.map(|b| (b.label.clone(), b.x, b.y))
.collect();
assert_eq!(
with.iter()
.map(|(l, x, _)| (l.clone(), *x))
.collect::<Vec<_>>(),
icons
.iter()
.map(|(l, _, x)| (l.clone(), *x))
.collect::<Vec<_>>()
);
crate::net::JOINS_FRIENDS_IN_TEST.set(false);
}
mod multiplayer_parent_navigation {
use super::*;
use crate::multiplayer::{Outcome, Screen};
#[test]
fn friend_cancel_returns_to_title() {
let mut f = with_resolver();
let mut f = Fixture::new();
f.click("Friends");
assert!(matches!(
f.title.mode,
@@ -166,7 +118,7 @@ mod multiplayer_parent_navigation {
#[test]
fn friend_escape_returns_to_title() {
let mut f = with_resolver();
let mut f = Fixture::new();
f.click("Friends");
assert!(matches!(
f.title.mode,
+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,11 +1,15 @@
//! 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. 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;
@@ -15,7 +19,7 @@ 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};
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -40,24 +44,38 @@ 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;
#[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,
},
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,
},
// Hidden, and 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)]
@@ -114,7 +132,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 +141,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 +152,10 @@ 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 changed = lock().wanted.update(status, unix_now());
if let Some(thread) = THREAD.get().filter(|_| changed) {
thread.unpark();
}
}
+105
View File
@@ -83,6 +83,72 @@ pub trait Hooks: Sync {
fn tunnel(&self) -> Option<&dyn tunnel::Resolver> {
None
}
/// 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 {
/// The player keeps it to themselves: the Discord switch in Horizon Settings is off. (The
/// switch moves to the Discord crate's own settings with the settings store.)
Hidden,
/// 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.
@@ -178,6 +244,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() {
@@ -255,6 +347,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()
@@ -415,6 +510,14 @@ mod tests {
for open in [false, true, true, true, false, false] {
engine::published(open);
}
for now in [
Activity::Menus,
Activity::Menus,
Activity::Hidden,
Activity::Hidden,
] {
engine::activity(now);
}
engine::shutdown();
assert!(engine::any());
let mut atlas = Atlas::default();
@@ -454,6 +557,8 @@ mod tests {
"started 0.2.31",
"published true",
"published false",
"Menus",
"Hidden",
"frame 4"
]
);
-55
View File
@@ -19,11 +19,6 @@ 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,
/// The title screen's Friends icon slot (vanilla's `CommonButtons.friends`, beside Language
/// and Accessibility): with a page registered here the icon opens the first one as a form of
/// its own, as Join a Friend is drawn ([`Row::Field`]). It shows heading, note, text, space,
/// field and button rows; the others belong in the options shell.
Title,
}
/// One page. Its values live in the crate (statics, often its settings [`crate::settings::Store`]);
@@ -45,11 +40,6 @@ pub trait Page: Sync {
fn tooltip(&self, _row: usize) -> Option<Label> {
None
}
/// The label of the button the engine adds to leave a page drawn as its own screen: a
/// translation key, `gui.done` by default (`gui.cancel` for a form that does something).
fn back(&self) -> &'static str {
"gui.done"
}
}
/// A row of a page. Labels are translation keys.
@@ -95,16 +85,6 @@ pub enum Row {
/// A key binding, as Key Binds shows one: `key` is its key name (`key.keyboard.z`,
/// `key.mouse.left`). A press waits for the next key or mouse button ([`Act::Bind`]).
Key { label: &'static str, key: String },
/// A text field ([`Row::field`]) on a page drawn as its own screen, as vanilla's `EditBox`:
/// its label above it, focus by click or Tab, typing, the caret keys, selection, copy and
/// paste, at most `max` characters. `text` is its value when the page opens; the engine then
/// keeps it, and sends each change ([`Act::Edit`]) and Enter ([`Act::Submit`]).
#[non_exhaustive]
Field {
label: &'static str,
text: String,
max: usize,
},
}
impl Row {
@@ -127,23 +107,6 @@ impl Row {
}
}
/// A text field starting with `text`, at most 32 characters (`EditBox`'s default).
pub fn field(label: &'static str, text: impl Into<String>) -> Self {
Self::Field {
label,
text: text.into(),
max: 32,
}
}
/// A text field taking at most `chars` characters.
pub fn max(mut self, chars: usize) -> Self {
if let Self::Field { max, .. } = &mut self {
*max = chars;
}
self
}
/// A button greyed out (`false`): drawn, never pressed.
pub fn active(mut self, on: bool) -> Self {
if let Self::Button { active, .. } = &mut self {
@@ -204,19 +167,6 @@ pub enum Act {
Slide(f32),
/// A key row got its new key: its key name (`key.keyboard.z`).
Bind(String),
/// A field's text changed: all of it.
Edit(String),
/// Enter in a field.
Submit,
}
/// The server a page asked to join ([`connect`]).
static CONNECT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// From a [`Place::Title`] page's [`Page::act`]: join the server at `address` as Direct
/// Connection does (the engine's addresses, `friend:<code>` among them), once the act returns.
pub fn connect(address: impl Into<String>) {
*CONNECT.lock().unwrap_or_else(|e| e.into_inner()) = Some(address.into());
}
static PAGES: Registry<dyn Page> = Registry::new();
@@ -235,9 +185,4 @@ pub mod engine {
pub fn all() -> &'static [&'static dyn Page] {
PAGES.all()
}
/// The server a page asked to join since the last call ([`connect`]).
pub fn take_connect() -> Option<String> {
CONNECT.lock().unwrap_or_else(|e| e.into_inner()).take()
}
}
+3 -1
View File
@@ -274,8 +274,10 @@ 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 poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
+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]
+3 -1
View File
@@ -11,13 +11,15 @@
use horizon_platform::hooks::{self, Hooks};
/// 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,
];
/// Registers Horizon's in-game parts with the game. The executable calls it before it runs the
+5 -2
View File
@@ -54,7 +54,10 @@ pub mod hooks {
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
SpriteStore, Sprites,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
tunnel,
};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
@@ -65,7 +68,7 @@ pub mod settings {
/// Settings pages a crate adds to the options screens (docs/layers.md, "Platform registries").
pub mod pages {
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, register};
}
/// Drawing on the HUD (docs/layers.md, "Platform registries").
+9
View File
@@ -115,3 +115,12 @@ ssh desktop 'sudo -n rm -f /var/lib/gha-control/pause && sudo -n systemctl start
```
With the desktop paused, set `SELF_HOSTED_CI=light`, or `checks` waits for it.
## When our runners are offline
`.github/workflows/runner-watch.yml` runs every 30 minutes on GitHub's runners (never ours) and calls
`tools/ci/runner_watch.py`. When a job has waited over 2 hours for a self-hosted runner, in a pool where no job is
running at all (offline, not just busy), it opens one issue, "CI: self-hosted runners are not taking jobs", listing
each pool, what stops and what to do, and keeps its text current; it closes the issue once nothing waits. It needs
only the workflow token (`actions: read`, `issues: write`). What the Team and the council do meanwhile:
[operations.md](operations.md#self-hosted-runners-are-offline).
+2 -2
View File
@@ -24,13 +24,13 @@ 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 |
| Discord Rich Presence | 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`, `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)) |
| 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.
+4 -18
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
@@ -363,7 +363,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, Pause, Extras, Title }
pub enum Place { HorizonSettings, Title, Pause }
pub trait Page: Sync {
fn id(&self) -> &'static str; // stable, for horizon-ctl and the page stack
fn title(&self) -> &'static str; // translation key
@@ -371,7 +371,6 @@ pub trait Page: Sync {
fn rows(&self, rows: &mut Vec<Row>); // asked when the page is drawn, not every frame elsewhere
fn act(&self, row: usize, act: Act); // a click, a reverse click, a slider's value, a key
fn tooltip(&self, row: usize) -> Option<Label> { None } // hover text, may follow the value
fn back(&self) -> &'static str { "gui.done" } // the leave button of a page drawn as a screen
}
pub enum Row {
Heading(&'static str), // translation key
@@ -383,11 +382,9 @@ pub enum Row {
Key { label: &'static str, key: String }, // a binding: `key.keyboard.z`
Text { text: String, quiet: bool, groups: bool }, // Row::text(..): white or grey, wrapped
Space(u8), // GUI pixels, on a page drawn as a screen
Field { label: &'static str, text: String, max: usize }, // Row::field(..).max(n): an EditBox, on a page drawn as a screen
}
pub enum Label { Key(&'static str), Value(String) }
pub enum Act { Press, PressBack, Slide(f32), Bind(String), Edit(String), Submit }
pub fn connect(address: impl Into<String>); // from a Title page's act: join that server
pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
```
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Title` and `Pause`: a
@@ -398,17 +395,6 @@ pub fn connect(address: impl Into<String>); // from a Title pa
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
shell, text is notes and space is nothing.
- `Title` is the title screen's Friends icon slot (vanilla's `CommonButtons.friends`), not a new row: the icon
keeps its place and look at every GUI scale, and opens the first page registered there as a form drawn as Join a
Friend is (`multiplayer/page.rs`): the title, the rows from y 53 (a field's grey label, its 200-wide box 11
below, 41 a field), the buttons in a column from a quarter of the height + 114, the back button last
(`Page::back`, `gui.cancel` for a form). It shows heading, note, text, space, field and button rows. With no page
there the icon opens the engine's Join a Friend while an invite-code resolver is registered, else it is
inactive.
- `Field` is vanilla's `EditBox` on a page drawn as its own screen: focus by click or Tab, typing, the caret keys,
selection, copy and paste, at most `max` characters (32 by default). The engine keeps its text while the page is
open and sends each change (`Act::Edit`, before any other act or redraw reaches the page) and Enter
(`Act::Submit`). The options shell has no field.
- Rows are rebuilt when the page is drawn or acted on, like the engine's own pages: no cost while no page is
open.
- The engine keeps native mods' pages (`Page::NativeMod`) as they are. Both kinds sit in one module that
+6 -3
View File
@@ -427,9 +427,12 @@ 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.
`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). Only the
official executable (`horizon-official`) registers it: horizon-client's own
executable shows no presence. 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
+1 -2
View File
@@ -53,8 +53,7 @@ router that opened the port, or with a forwarded port, that's it: no server took
(`horizon_hooks::tunnel`, `horizon_platform::hooks::tunnel` for the Horizon layer): the resolver a hook offers
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 and the title screen's
Friends icon, which opens Join a Friend, is inactive (`net::joins_friends`). Hosting needs no hook: the
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.
+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`
+54
View File
@@ -0,0 +1,54 @@
import datetime as dt
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools" / "ci"))
import runner_watch as rw # noqa: E402
NOW = dt.datetime(2026, 11, 10, 12, 0, tzinfo=dt.timezone.utc)
LIGHT = ["self-hosted", "linux", "horizon-light"]
GAME = ["self-hosted", "linux", "horizon-game"]
MAC_NIGHTLY = ["self-hosted", "macOS", "horizon-nightly"]
def job(labels, hours_ago, status="queued", name="j"):
created = (NOW - dt.timedelta(hours=hours_ago)).strftime("%Y-%m-%dT%H:%M:%SZ")
return {"name": name, "labels": labels, "status": status, "created_at": created, "html_url": f"https://x/{name}"}
class WaitingJobs(unittest.TestCase):
def test_a_job_waiting_past_the_limit_in_an_idle_pool_counts(self):
waiting = rw.waiting_jobs([job(LIGHT, 3, name="triage")], NOW, 2)
self.assertEqual([j["name"] for j in waiting], ["triage"])
self.assertAlmostEqual(waiting[0]["waited_h"], 3)
def test_recent_jobs_github_hosted_jobs_and_finished_jobs_do_not(self):
jobs = [job(LIGHT, 1), job(["ubuntu-latest"], 9), job(GAME, 9, status="completed")]
self.assertEqual(rw.waiting_jobs(jobs, NOW, 2), [])
def test_a_busy_pool_is_not_offline_but_another_idle_pool_still_is(self):
jobs = [job(GAME, 7, name="parity"), job(GAME, 0.5, status="in_progress"), job(MAC_NIGHTLY, 5, name="mac")]
self.assertEqual([j["name"] for j in rw.waiting_jobs(jobs, NOW, 2)], ["mac"])
class Plan(unittest.TestCase):
def test_opens_once_then_updates_then_closes(self):
waiting = rw.waiting_jobs([job(LIGHT, 3), job(LIGHT, 5, name="claim"), job(MAC_NIGHTLY, 4)], NOW, 2)
action, text = rw.plan(waiting, None, NOW)
self.assertEqual(action, "open")
self.assertIn("| `horizon-light` | 2 | 5 h | [claim](https://x/claim) |", text)
self.assertIn("SELF_HOSTED_CI", text) # the fix for the light pool is named
self.assertIn("no Nightly is published", text) # and what the Mac pool stops
self.assertNotIn("parity job waits", text) # pools with nothing waiting are left out
self.assertEqual(rw.plan(waiting, 42, NOW)[0], "update")
self.assertEqual(rw.plan([], 42, NOW)[0], "close")
self.assertEqual(rw.plan([], None, NOW), (None, ""))
def test_an_unknown_label_set_is_still_reported_by_its_labels(self):
waiting = rw.waiting_jobs([job(["self-hosted", "gpu-box"], 3)], NOW, 2)
self.assertIn("| `gpu-box` | 1 |", rw.plan(waiting, None, NOW)[1])
if __name__ == "__main__":
unittest.main()
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""Every 30 minutes: are jobs waiting for a self-hosted runner nobody runs? (.github/workflows/runner-watch.yml)
python3 tools/ci/runner_watch.py [--dry-run] [--hours 2]
Several jobs run only on the maintainer's machines (docs/ci-runners.md): the vanilla harnesses need his own copy
of Minecraft, the macOS builds his Mac. When those machines are off, GitHub keeps such a job queued for up to a day
and then fails it, and nothing says why: triage commands go unanswered, Nightlies stop, a pull request's parity
check stays yellow. This script lists the jobs of this repository's queued and running workflow runs that wait for
a self-hosted runner, and when any has waited longer than --hours in a pool where no job runs at all (so the
runners are offline, not just busy) it opens one issue (or updates its text) saying
which runners are missing, what stops because of it and what to do (docs/operations.md). When nothing waits any
more, it comments and closes that issue. Needs only the workflow token's `actions: read` and `issues: write`, so it
runs on GitHub's own runners even when every self-hosted one is gone.
"""
import argparse
import datetime as dt
import json
from pathlib import Path
import subprocess
import sys
ROOT = Path(__file__).resolve().parents[2]
TITLE = "CI: self-hosted runners are not taking jobs"
GUIDE = "https://github.com/horizon-rust/horizon/blob/main/docs/operations.md#self-hosted-runners-are-offline"
# What stops while no runner with this label takes jobs, and what the Team does meanwhile. A job asks for a set of
# labels; the first of these it asks for names it.
EFFECTS = [
("horizon-light", "Small jobs (triage and claim commands, pull request labels, overlap notes, the Mojang "
"republish check, a pull request's parity scope) wait instead of running.",
"An organisation owner sets the repository variable `SELF_HOSTED_CI` to `off`: they run on GitHub's runners."),
("horizon-checks", "`verify` and `launcher` of the pull requests whose number ends in 0 or 1 wait, so those pull "
"requests can't merge.",
"An organisation owner sets `SELF_HOSTED_CI` to `off` (or `light`), then the authors push again or a Team "
"member re-runs the checks."),
("horizon-parity-main", "parity-main doesn't run, so no newer commit of main is green for the Nightly: Nightlies "
"keep shipping the last green commit or nothing.",
"Nothing to do on GitHub: the harnesses need the maintainer's Minecraft. Merge on `verify` as usual; label "
"risky pull requests `needs-proof`."),
("horizon-nightly", "The Nightly's macOS builds wait, so no Nightly is published.",
"Players keep the last Nightly. Nothing to do on GitHub until a Mac runner is back."),
("horizon-release", "The launcher's macOS builds wait, so no launcher release is published.",
"Players keep their launcher. Nothing to do on GitHub until a Mac runner is back."),
("horizon-game", "A pull request's own parity job waits (it isn't required: merging still works).",
"Merge on `verify` after reading the diff; label pull requests that touch gameplay `needs-proof`."),
]
def parse_time(text):
return dt.datetime.fromisoformat(text.replace("Z", "+00:00"))
def waiting_jobs(jobs, now, hours):
"""The jobs queued for a self-hosted runner for at least `hours`, in pools where no job runs at all (a busy
pool works through its queue; an idle one with jobs waiting is offline): [{name, labels, waited_h, url}]."""
hosted = [job for job in jobs if "self-hosted" in (job.get("labels") or [])]
running = {pool(job["labels"]) for job in hosted if job.get("status") == "in_progress"}
out = []
for job in hosted:
labels = job["labels"]
if job.get("status") != "queued" or pool(labels) in running or not job.get("created_at"):
continue
waited = (now - parse_time(job["created_at"])).total_seconds() / 3600
if waited >= hours:
out.append({"name": job.get("name", "?"), "labels": labels, "waited_h": waited,
"url": job.get("html_url", "")})
return out
def pool(labels):
"""The runner pool a job waits for: the first EFFECTS label it asks for, else its labels joined."""
return next((label for label, _, _ in EFFECTS if label in labels),
", ".join(label for label in labels if label != "self-hosted"))
def body(waiting, now):
pools = {}
for job in waiting:
pools.setdefault(pool(job["labels"]), []).append(job)
lines = [f"🤖 Agent: written by `tools/ci/runner_watch.py` (runner-watch workflow), updated every 30 minutes; "
f"last {now:%Y-%m-%d %H:%M} UTC.", "",
"Jobs are waiting for self-hosted runners and none of those runners is running anything: they are "
"offline (the maintainer's machines are probably off). GitHub fails such a job after a day.", "",
"| Runner label | Jobs waiting | Longest wait | Example |", "| --- | --- | --- | --- |"]
for name, jobs in sorted(pools.items()):
oldest = max(jobs, key=lambda job: job["waited_h"])
lines.append(f"| `{name}` | {len(jobs)} | {oldest['waited_h']:.0f} h | [{oldest['name']}]({oldest['url']}) |")
lines += ["", "**What stops, and what to do:**", ""]
for label, effect, action in EFFECTS:
if label in pools:
lines.append(f"- `{label}`: {effect} {action}")
lines += ["", f"The whole procedure: [docs/operations.md]({GUIDE}). This issue closes itself when no job "
"waits any more."]
return "\n".join(lines) + "\n"
def plan(waiting, issue, now):
"""(action, text): open, update or close the issue, or nothing. issue: the open issue's number or None."""
if waiting:
return ("update" if issue else "open"), body(waiting, now)
if issue:
return "close", "🤖 Agent: no job waits for a self-hosted runner any more: closing."
return None, ""
def gh(*arguments):
return subprocess.run(["gh", *arguments], cwd=ROOT, check=True, capture_output=True, text=True).stdout
def all_jobs(repo, since):
jobs = []
for status in ("queued", "in_progress"):
runs = json.loads(gh("api", f"repos/{repo}/actions/runs?status={status}&per_page=100&created=%3E%3D{since}",
"--jq", "[.workflow_runs[].id]") or "[]")
for run in runs:
jobs += json.loads(gh("api", f"repos/{repo}/actions/runs/{run}/jobs?per_page=100", "--jq", ".jobs") or "[]")
return jobs
def open_issue(repo):
listed = json.loads(gh("issue", "list", "--repo", repo, "--state", "open", "--search", f'"{TITLE}" in:title',
"--json", "number,title") or "[]")
return next((issue["number"] for issue in listed if issue["title"] == TITLE), None)
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--repo", default="horizon-rust/horizon")
parser.add_argument("--hours", type=float, default=2.0, help="how long a job may wait before it counts")
parser.add_argument("--dry-run", action="store_true", help="print what it would do, change nothing")
args = parser.parse_args()
now = dt.datetime.now(dt.timezone.utc)
since = (now - dt.timedelta(days=2)).strftime("%Y-%m-%d")
waiting = waiting_jobs(all_jobs(args.repo, since), now, args.hours)
issue = open_issue(args.repo)
action, text = plan(waiting, issue, now)
print(f"{len(waiting)} job(s) waiting over {args.hours:g} h for a self-hosted runner; issue: {issue}; "
f"action: {action}")
if args.dry_run or action is None:
print(text)
return 0
if action == "open":
gh("issue", "create", "--repo", args.repo, "--title", TITLE, "--body", text)
elif action == "update":
gh("issue", "edit", str(issue), "--repo", args.repo, "--body", text)
else:
gh("issue", "close", str(issue), "--repo", args.repo, "--comment", text)
return 0
if __name__ == "__main__":
sys.exit(main())
+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()),
+3 -3
View File
@@ -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):
@@ -251,14 +251,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')