Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 92dab6cafc Pages v2: text fields, and a page in the title screen's Friends icon slot
horizon_hooks::pages gains Row::Field (Row::field(label, text).max(n)),
vanilla's EditBox on a page drawn as its own screen, with Act::Edit (each
change, before any other act or redraw reaches the page) and Act::Submit
(Enter); Place::Title, the title screen's Friends icon slot, unchanged in
place and look; Page::back, the label of the button that leaves such a
page; and pages::connect, for a Title page to join a server.

The icon opens the first Title page as multiplayer::Screen::Page, a form
laid out as Join a Friend (multiplayer/page.rs); its fields go through the
title screen's caret like every field of its own (focus, typing, caret
keys, selection, copy, paste, the max length), Tab walks fields then
buttons. Without a Title page the icon opens Join a Friend while a
resolver is registered, else it stays inactive. The options shell skips
fields. title.rs loses lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 23:11:27 +01:00
Horizon agentandClaude Opus 5.5 e11233f2dc The title screen's Friends icon is inactive without an invite-code resolver (spec #14167 step 4)
Join a Friend opens from vanilla's Friends icon slot, unchanged in place
and size at every GUI scale; it is now drawn inactive, and takes no click,
key or controller press, when no Hooks::tunnel resolver is registered
(net::joins_friends), as the pause menu's icon is without a sharing page.
horizon-official registers horizon-friends' resolver, so players of the
official game see no change; the plain executable, which refuses every
friend: address, no longer opens a screen that cannot join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 22:45:31 +01:00
180 changed files with 1471 additions and 2053 deletions

No files matched your search

+1 -1
View File
@@ -121,7 +121,7 @@ jobs:
cargo +stable fmt --all -- --check
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client -p horizon-game --all-targets --no-default-features -- -D warnings
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
+1 -1
View File
@@ -336,7 +336,7 @@ jobs:
set -euo pipefail
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
cargo build --release --locked -p horizon-game --no-default-features --bin horizon-client
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
folder="dist/${{ matrix.server_dir }}"
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
+6 -5
View File
@@ -53,7 +53,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| Crate | Owns |
|---|---|
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`), also the dedicated server (`--dedicated`); `horizon-game` builds its executable. Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client` | Gameplay library (`src/lib.rs`: `game.rs` + `game/`, `entry.rs`) and thin executable (`src/main.rs`). Also the dedicated server (`--dedicated`). Re-exports `horizon-client-lib` for existing mod paths. |
| `horizon-client-lib` | Reusable client systems, `horizon-ctl`, probes and their tests. Its manifest points to the existing sources under `crates/horizon-client/`; the library root is `src/client_lib.rs`. It never depends on gameplay. |
| `horizon-items` | Blocks and items below gameplay: `blocks.rs` (block facts), `items.rs`, `item_data.rs` (components), `enchant.rs`, `cooldowns.rs`, `bundle.rs`, `item_nbt.rs`. Re-exported as `horizon_client::items`... |
| `horizon-command` | Command parsing (Brigadier): the vanilla tree, argument types, selectors, suggestions, the tree's network form. Re-exported as `horizon_client::command` |
@@ -107,7 +107,7 @@ python3 tools/pr_ready.py # the self-check: fmt, clippy -D warn
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client -p horizon-client-lib --lib --bins --tests # skips the examples (pr_ready does this); `--doc` separately
cargo test -p horizon-client-lib --lib mod_content::tests:: -- --ignored # the slow content-root tests, #[ignore]d; pr_ready runs them when mod content, assets, blocks or items change
cargo build --profile fast -p horizon-game # the game (bin horizon-client), optimized, quick to link: to play or run lan_e2e
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
@@ -159,8 +159,9 @@ python3 tools/lang_coverage.py --summary # the keys each language still misses
`L:` = lib (`crates/horizon-client/src/`, or `crates/horizon-client-render/src/` for the modules that crate holds, under
the same `horizon_client::<module>` paths), `G:` = gameplay (`crates/horizon-client/src/game/`), also in the lib in normal builds.
Gameplay keeps its `horizon_client::<module>` imports through a self-crate alias. The executable
(`crates/horizon-game/src/main.rs`) contains only the allocator, mod API marker and a call to
`horizon_game::main()`, which runs `entry::main` with the platform's parts registered.
contains only the allocator, mod API marker and a call to `entry::main`. For unit tests, the lib
excludes `entry` and `game`, and the executable test target owns `game` so its process-wide test
state stays separate from the library tests. This compiles gameplay twice for executable tests.
| Subsystem | Files |
|---|---|
@@ -217,7 +218,7 @@ Single player, LAN guests and dedicated servers all run the server code, so a fi
Test the server thread when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-game -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
python3 tools/lan_e2e.py --check <name> # host on its server thread, guest is a server client
python3 tools/parity.py dedicated_server server_entities server_player_damage
```
+1 -1
View File
@@ -31,7 +31,7 @@ here.
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-game
cargo run --release -p horizon-client
```
- **Launcher:** its own Cargo workspace in `launcher/` (`cd launcher && cargo run`; on NixOS, `nix-shell` first).
Generated
-13
View File
@@ -1650,15 +1650,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-discord"
version = "0.1.0"
dependencies = [
"horizon-platform",
"log",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
@@ -1690,11 +1681,8 @@ name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-core",
"horizon-mods",
"horizon-threads",
"serde_json",
"winresource",
]
[[package]]
@@ -1805,7 +1793,6 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-discord",
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
-1
View File
@@ -34,7 +34,6 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-discord",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
+1 -1
View File
@@ -119,7 +119,7 @@ git clone https://github.com/horizon-rust/horizon.git
cd horizon
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar # needs JDK 25
cargo run --release -p horizon-prepare -- sounds # optional: sounds from your launcher install
cargo run --release -p horizon-game
cargo run --release -p horizon-client
```
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
+2 -4
View File
@@ -9,13 +9,11 @@ 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 = []
+10 -32
View File
@@ -638,30 +638,10 @@ pub fn fullscreen_request(
}
}
/// 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.
/// 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.
pub fn icon() -> Option<winit::window::Icon> {
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -674,15 +654,14 @@ fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// 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"))]
/// 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 {
let icon = icon();
#[cfg(target_os = "windows")]
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -760,8 +739,7 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
assert!(icon().is_some());
}
#[test]
+6 -3
View File
@@ -70,6 +70,7 @@ 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" }
@@ -108,12 +109,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers"]
default = ["audio", "controllers", "horizon-extras"]
# 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.
@@ -125,8 +130,6 @@ 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).
+1 -2
View File
@@ -29,6 +29,7 @@ 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"
@@ -84,7 +85,5 @@ 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"] }
+4 -6
View File
@@ -1001,14 +1001,12 @@ impl Hdr {
}
}
/// 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.
/// 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.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
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 (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
@@ -2,21 +2,16 @@
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! 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};
//! 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.
use crate::{
hud::{HudAtlas, HudBatch},
text::Text,
ui,
};
/// The settings pages' colours (`menu_style`, given at startup).
fn b() -> &'static SettingsColours {
&menu_style::get().settings
}
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -41,91 +36,54 @@ 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,
}
}
}
use menu_style::hud_colour as hex;
/// `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)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
fn glass() -> u32 {
hex(b().page, 118)
}
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
pub fn edge() -> u32 {
hex(b().ink, 26)
}
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
fn group_fill() -> u32 {
hex(b().ink, 11)
}
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub fn hover() -> u32 {
hex(b().ink, 18)
}
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub fn open() -> u32 {
hex(b().ink, 26)
}
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub fn head_rule() -> u32 {
hex(b().ink, 31)
}
pub fn row_rule() -> u32 {
hex(b().ink, 15)
}
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
fn well_fill() -> u32 {
hex(b().outline, 102)
}
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
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)
}
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);
/// The one main button.
pub fn face() -> u32 {
hex(b().face, 255)
}
fn face_hover() -> u32 {
hex(b().face_hover, 255)
}
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
fn box_edge() -> u32 {
hex(b().ink_soft, 89)
}
fn outline() -> u32 {
hex(b().outline, 255)
}
pub fn accent() -> u32 {
hex(b().accent, 255)
}
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const 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 fn muted() -> u32 {
hex(b().muted, 255)
}
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub fn soft() -> u32 {
hex(b().ink_soft, 255)
}
pub const 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 fn veil() -> u32 {
hex(b().page, 77)
}
pub const 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.
@@ -162,21 +120,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());
fill(batch, atlas, rect, GROUP);
}
/// 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());
fill(batch, atlas, rect, WELL);
}
/// 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());
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
/// kit's `fit::ellipsize`.
@@ -184,11 +142,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());
fill(batch, atlas, rect, WELL);
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 {
@@ -211,7 +169,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);
}
}
@@ -240,17 +198,17 @@ pub fn button_with_room(
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => well_fill(),
(true, true, false) => face(),
(true, true, true) => face_hover(),
(true, false, false) => button_fill(),
(true, false, true) => button_hover(),
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(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(
@@ -294,7 +252,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,
@@ -305,22 +263,17 @@ 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());
fill(batch, atlas, [x, line, w, 2.0], TRACK);
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_fill() } else { muted() },
);
fill(batch, atlas, handle, if live { HANDLE } 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.
@@ -337,13 +290,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,9 +3,8 @@
//! 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.
//!
//! 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 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.
//!
//! 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
@@ -17,7 +16,7 @@
//! use std::time::{Duration, Instant};
//!
//! let opened = Instant::now();
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
//! // 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);
@@ -27,6 +26,7 @@
//! 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,20 +50,28 @@ impl Clock {
}
}
/// A duration and an easing curve.
/// A duration and one of the design system's easing curves.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Tween {
/// How long it runs.
pub duration: Duration,
/// The CSS `cubic-bezier` control points of its curve.
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
pub curve: [f32; 4],
}
impl Tween {
/// `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 }
/// `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)
}
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
@@ -76,7 +84,7 @@ impl Tween {
/// The eased progress, 0 to 1, on the tween's curve.
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
bezier(self.curve, self.linear(clock, since))
horizon_theme::bezier(self.curve, self.linear(clock, since))
}
/// Whether it has finished (always, on a still clock).
@@ -90,29 +98,6 @@ 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)
@@ -160,7 +145,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.curve);
let tween = Tween::new(MS(80), horizon_theme::MOVE);
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);
@@ -175,7 +160,8 @@ mod tests {
fn a_still_clock_shows_every_motion_settled() {
let t0 = Instant::now();
let still = Clock::new(t0, true);
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
let tween = Tween::of(horizon_theme::QUICK);
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
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);
@@ -187,7 +173,7 @@ mod tests {
#[test]
fn the_helpers_start_where_they_say() {
let t0 = Instant::now();
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
let tween = Tween::new(MS(100), horizon_theme::QUICK);
let c = at(t0, 0);
assert_eq!(fade(tween, c, t0), 0.0);
assert_eq!(fade_out(tween, c, t0), 1.0);
@@ -198,25 +184,10 @@ 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.curve);
let tween = Tween::new(MS(80), horizon_theme::MOVE);
// 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,12 +38,6 @@
//! ```
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];
@@ -51,12 +45,8 @@ 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 fn arrow_colour() -> u32 {
colour(overlay().backdrop_muted, 255)
}
pub fn arrow_off_colour() -> u32 {
colour(overlay().backdrop_muted, 77)
}
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
pub const ARROW_OFF: u32 = colour(horizon_theme::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);
@@ -75,15 +65,9 @@ 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 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)
}
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);
/// The line of text's height in GUI px (vanilla's font, without its shadow).
const LINE: f32 = 8.0;
@@ -160,11 +144,7 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
arrow,
ax,
ay,
if on {
arrow_colour()
} else {
arrow_off_colour()
},
if on { ARROW } else { ARROW_OFF },
true,
);
}
@@ -302,9 +282,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.
@@ -400,16 +380,15 @@ mod tests {
let [ax, _] = strip.arrows[i];
(batch.vertices.iter())
.filter(|v| {
(v.pos[0] - ax).abs() < 4.0
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 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_colour() >> 24);
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
assert_eq!(alpha(&first, 1), ARROW >> 24);
}
#[test]
-1
View File
@@ -74,7 +74,6 @@ 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;
@@ -1,145 +0,0 @@
//! 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(_) => continue,
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Button {
label,
active,
+32 -152
View File
@@ -7,10 +7,7 @@
//! 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::{Chunk, Section},
fast_hash::FxHashMap,
};
use crate::{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`)
@@ -21,16 +18,13 @@ struct Cached<T> {
section: Weak<Section>,
pois: SectionPois<T>,
}
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
/// index: a search through a column looks it up once.
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
pub struct PoiSections<T> {
sections: Mutex<Lists<T>>,
}
struct Lists<T> {
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,
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
/// Size above which lists of sections no longer resident are dropped.
prune_at: usize,
}
const MIN_PRUNE: usize = 4096;
@@ -38,61 +32,27 @@ impl<T> Default for PoiSections<T> {
fn default() -> Self {
Self {
sections: Mutex::new(Lists {
by_column: FxHashMap::default(),
len: 0,
by_section: FxHashMap::default(),
prune_at: MIN_PRUNE,
}),
}
}
}
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();
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();
}
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);
@@ -104,56 +64,18 @@ fn list<'a, T: Copy>(
} else {
Arc::from([])
};
*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()));
}
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);
}
lists.by_section.insert(
key,
Cached {
section: Arc::downgrade(section),
pois: pois.clone(),
},
);
pois
}
}
@@ -221,48 +143,6 @@ 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();
@@ -273,7 +153,7 @@ mod tests {
index.get((1, i, 0), &Arc::new(Section::default()), kind);
}
let lists = index.sections.lock().unwrap();
assert!(lists.len <= 2);
assert!(lists.by_column[&(0, 0)][0].is_some());
assert!(lists.by_section.len() <= 2);
assert!(lists.by_section.contains_key(&(0, 0, 0)));
}
}
@@ -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,14 +2134,7 @@ 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));
@@ -2247,7 +2240,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);
+16 -5
View File
@@ -1,5 +1,6 @@
[package]
name = "horizon-client"
default-run = "horizon-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
@@ -118,11 +119,14 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages 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, 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
# 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-render/horizon-extras", "friends"]
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"]
# 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"]
@@ -139,10 +143,18 @@ windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "datapack_biome_sync"
path = "tests/datapack_biome_sync.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[[test]]
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
@@ -157,8 +169,7 @@ path = "examples/wind_burst_hook_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
# The build script runs on the host, but embeds resources based on the target OS (for the
# launcher's native-mod game package, which uses this build script).
# The build script runs on the host, but embeds resources based on the target OS.
winresource = "0.1"
[lints]
+3 -7
View File
@@ -1,7 +1,5 @@
//! Generates the native mod API. The launcher's native-mod game package (`horizon-native-game`,
//! which owns the `horizon-client` executable of a build with native mods) runs this build script
//! too: there it also gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`). horizon-client itself has no executable (horizon-game's).
//! Generates the native mod API, and gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`, shared with the official executable's build script).
#[path = "../../packaging/windows/exe.rs"]
mod windows_exe;
@@ -23,7 +21,5 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
if std::env::var("CARGO_PKG_NAME").as_deref() != Ok("horizon-client") {
windows_exe::resources("horizon-client");
}
windows_exe::resources("horizon-client");
}
+4 -2
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, menu_style, 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, 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,6 +58,8 @@ 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,15 +1,11 @@
//! 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 says what the player does on each change ([`set`]), which wakes the thread,
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change 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`.
//!
//! 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)]
//! 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.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
@@ -19,7 +15,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use horizon_platform::hooks::{self, Activity, Dimension, GameMode, Hooks, Play};
use horizon_command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -44,38 +40,24 @@ const CLOSE: u32 = 2;
const PING: u32 = 3;
const PONG: u32 = 4;
/// The presence's hooks, for `horizon_platform::hooks::register`.
pub static HOOKS: Presence = Presence;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dimension {
Overworld,
Nether,
End,
}
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)]
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,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -132,8 +114,7 @@ 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.
/// The status [`set`] gave before, the switch included, is the first one sent.
pub fn start(automated: bool) {
pub fn start(enabled: bool, 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")
@@ -141,6 +122,9 @@ pub fn start(automated: bool) {
{
return;
}
if !enabled {
lock().wanted.status = Status::Off;
}
match std::thread::Builder::new()
.name("Discord presence".into())
.spawn(run)
@@ -152,10 +136,12 @@ pub fn start(automated: bool) {
}
}
/// What the player does now; only a change wakes the thread. Kept for [`start`] before it.
/// What the player does now; only a change wakes the thread. Free without [`start`].
pub fn set(status: Status) {
let changed = lock().wanted.update(status, unix_now());
if let Some(thread) = THREAD.get().filter(|_| changed) {
let Some(thread) = THREAD.get() else {
return;
};
if lock().wanted.update(status, unix_now()) {
thread.unpark();
}
}
-1
View File
@@ -13,7 +13,6 @@ 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.
+14 -5
View File
@@ -49,7 +49,6 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
}
}
mod activity;
mod advancements;
mod advancements_screen;
mod agent;
@@ -129,6 +128,8 @@ mod decorated_pot;
mod dedicated;
mod deflect;
mod dialogs;
#[cfg(feature = "discord")]
mod discord;
mod dispense_fixture;
mod dispenser;
mod dragon;
@@ -1498,7 +1499,7 @@ impl LevelAccess for GameLevel<'_> {
};
}
if let Some(signal) = books::lectern_signal(self.world, pos) {
return signal * i32::from(books::has_book_state(self.registry, state));
return signal;
}
if let Some(signal) =
machines::shelf_signal(self.world, self.registry, state, pos, direction)
@@ -11252,7 +11253,8 @@ 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());
self.report_activity();
#[cfg(feature = "discord")]
self.update_discord_presence();
let steps = self.clock.advance(elapsed);
for _ in 0..steps {
let started = Instant::now();
@@ -12527,8 +12529,15 @@ pub fn run_with() -> Result<()> {
if options.execution_role.is_client() {
narrator::start(&early_settings);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options, &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);
}
// 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"));
@@ -1,76 +0,0 @@
//! 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,16 +1608,6 @@ 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)) {
@@ -1731,7 +1721,6 @@ 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 {
@@ -1749,55 +1738,6 @@ 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
+122 -286
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, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Mark, Mesh, NameDecoration,
PlayerDraw, PlayerModel, PlayerModels, Quad, SpriteStore, Sprites,
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
PlayerModels, 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: a companion or an aura.
poses: RefCell<Poses>,
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Companions, trails and auras.
@@ -463,45 +463,14 @@ struct Seat {
models: Vec<PlayerModel>,
/// The world is the window's own single-player one (`Frame::solo`).
solo: bool,
/// The items the hooks show, by the item's address.
items: FxHashMap<usize, Kept>,
/// 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>)>,
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)]
@@ -570,10 +539,6 @@ 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);
@@ -699,7 +664,14 @@ impl Seat {
if !(styles || said.marks) {
return None;
}
let uuid = self.uuid_of(m)?;
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 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 };
@@ -715,135 +687,42 @@ impl Seat {
Some(NameDecoration::new(mark, badge, top))
}
/// 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 {
/// 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;
let next = self.last_sprite + 1;
let kept = self
.items
let (id, _) = *self
.sprites
.entry(Arc::as_ptr(item) as usize)
.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 {
.or_insert_with(|| (next, item.clone()));
self.last_sprite = self.last_sprite.max(id);
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
if !self.wanted.contains(&id) {
self.wanted.push(id);
}
return None;
};
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])
// 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]),
))
}
/// 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.
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
for id in self.wanted.drain(..) {
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;
}
}
});
let item = self.sprites.values().find(|(i, _)| *i == id);
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
store.put(id, slot_image(item));
}
}
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
@@ -1043,47 +922,104 @@ impl Game<'_> {
}
}
/// 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`.
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
/// go to `out`. `camera`: where the world view's camera is; None in a picture of the player
/// (the inventory), which shows no companion, trail or aura.
#[allow(clippy::too_many_arguments)]
pub(super) fn cosmetics_fx_of(
pub(super) fn cosmetics_geometry(
&self,
m: &Mob,
own: bool,
base: glam::Mat4,
camera: glam::DVec3,
camera: Option<glam::DVec3>,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
// "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() {
use horizon_client::mobs::{anim, render};
let worn = self.worn(m, own);
// An emote's props: its time and weight while it plays.
let ticks = m.age as f32 + alpha;
let emote = emotes::playing(m).and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then_some((&p.item, t, w))
});
if worn.is_none() && emote.is_none() || horizon_client::mobs::trader::is_invisible(m) {
return None;
}
let worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
return None;
}
let mut poses = self.cosmetics.poses.borrow_mut();
// The avatar's body, posed as it is drawn.
let (key, _) = render::body(m);
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
let mut scratch = self.cosmetics.poses.borrow_mut();
let (body_poses, poses) = &mut *scratch;
body_poses.reset(body);
let data = self.res.mob_data();
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
let mut bounds = None;
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,
// 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
}
};
bounds = self.cosmetics_fx(&at, item, gpu, bounds, &mut poses, out);
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
@@ -1462,104 +1398,4 @@ 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,6 +71,11 @@ 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,7 +314,8 @@ impl Game<'_> {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
self.pose_companion(item, m, alpha, &mut poses, &model);
self.cosmetics_model(&model, matrix, &poses, uv, light, false, None, out);
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! 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,6 +34,8 @@ 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);
@@ -96,8 +98,9 @@ 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_icon(Window::default_attributes().with_title("Horizon"));
let attributes = horizon_client::window::with_horizon_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(
@@ -127,25 +130,22 @@ 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")]
// 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
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));
}
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
let boot_step = startup::step("renderer.boot");
match loading_renderer(
@@ -93,9 +93,10 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
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 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 made = event_loop
.create_window(attributes)
.map_err(Into::into)
+17 -4
View File
@@ -801,8 +801,19 @@ impl Game<'_> {
packed,
out,
);
let world = horizon_hooks::players::Pass::World;
self.own_layers_geometry(m, world, camera, alpha as f32, 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,
);
}
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);
@@ -916,8 +927,10 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
let picture = horizon_hooks::players::Pass::Picture;
self.own_layers_geometry(&m, picture, camera, 1.0, 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);
}
// `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).
+51 -147
View File
@@ -738,11 +738,14 @@ impl Game<'_> {
});
self.set_pushed_on_ground(target, limited, resolved);
// shortcut: unoccupied server mobs only, expand when controller/passenger authority is bound.
let mob = if let Target::Entity(EntityHandle::Mob(i)) = target {
// 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 {
let m = &self.mobs[i];
(!self.options.execution_role.is_client()
let eligible = !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()
@@ -754,22 +757,16 @@ impl Game<'_> {
.guests
.iter()
.any(|g| host.hub.here(g) && g.vehicle == Some(m.id))
}))
.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);
});
if eligible {
self.mobs[i].on_ground = limited.y < 0.0 && limited.y != resolved.y;
}
}
// `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 {
@@ -778,12 +775,6 @@ 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);
}
@@ -1062,7 +1053,6 @@ 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;
@@ -1161,9 +1151,8 @@ 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.500000009536743).abs() < 1e-9,
(moved[0] - 0.5).abs() < 1e-9,
"far stone gone first: {}",
moved[0]
);
@@ -1262,7 +1251,7 @@ mod order_tests {
}
/// A primed TNT pushed down by a piston onto a slime block bounces up,
/// matching vanilla's restitution even in a lazy chunk (#12022).
/// matching vanilla's slime restitution (~+0.64) even in a lazy chunk (#12022).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_pushed_tnt_bounces_on_slime() {
@@ -1323,36 +1312,32 @@ mod order_tests {
death_ticks: 0,
id: 1,
};
// 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.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;
}
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
);
}
}
@@ -1617,54 +1602,6 @@ 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);
@@ -1676,16 +1613,6 @@ 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());
@@ -1728,46 +1655,23 @@ mod h255_mob_piston_ground_tests {
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_client_role_ai_and_vertical_ground_state() {
fn h255_added_client_role_ai_and_vertical_paths_keep_native_state() {
use crate::game::execution_role::ExecutionRole as Role;
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,
),
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)),
] {
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_eq!(game.mobs[i].on_ground, on_ground);
assert!(
game.mobs[i].on_ground,
"excluded native path remains unchanged; not Java parity"
);
});
}
}
#[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);
});
}
}
+2 -46
View File
@@ -199,7 +199,7 @@ impl PartPoses for PosesOf<'_> {
}
}
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
/// A player model drawn in the world pass, 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_fx_of(m, false, base, camera, alpha, light, gpu, out))
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
@@ -344,50 +344,6 @@ 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!(
+2 -1
View File
@@ -11463,7 +11463,8 @@ impl Remote {
pub(super) fn listed_player_count(&self) -> usize {
self.listed.len()
}
/// Players in the server's player list, this one included (for the activity hooks).
/// Players in the server's player list, this one included (for Discord).
#[cfg(feature = "discord")]
pub(super) fn player_count(&self) -> usize {
self.players.len()
}
@@ -306,6 +306,8 @@ 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,9 +516,10 @@ 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_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes =
horizon_client::window::with_horizon_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,8 +273,9 @@ 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_icon(Window::default_attributes().with_title("Horizon"));
let attributes = horizon_client::window::with_horizon_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
@@ -1,56 +0,0 @@
//! 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,7 +6,6 @@ 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)]
@@ -1,5 +1,4 @@
//! This repository's plain executable: the platform's game with no Horizon part (docs/layers.md).
//! The official executable is horizon-official's.
//! Horizon: native Rust client for Minecraft Java Edition 26.3 gameplay.
#![forbid(unsafe_code)]
#[cfg(not(any(feature = "system-allocator", feature = "alloc-census")))]
@@ -12,9 +11,9 @@ static ALLOCATOR: horizon_threads::CensusAllocator = horizon_threads::CensusAllo
/// The mod API level this game serves, kept in the program for the launcher
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
/// reads it, so the linker keeps it.
static MOD_API_LEVEL: [u8; horizon_game::MOD_API_LEVEL.len()] = horizon_game::MOD_API_LEVEL;
static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK;
fn main() {
std::hint::black_box(&MOD_API_LEVEL);
horizon_game::main();
horizon_client::entry::main();
}
+23 -32
View File
@@ -849,6 +849,7 @@ 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;
@@ -856,51 +857,42 @@ impl Mob {
// checkFallDamage reads the landing block (getOnPosLegacy).
self.supporting = self.supporting_block(w, moved);
self.species_check_fall_damage(w, moved, events);
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 {
// 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.
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: world.block(on.0, on.1, on.2),
block: w.world.block(on.0, on.1, on.2),
collided_x: x_hit,
collided_z: z_hit,
vertical_collision: vertical,
collision_below: vertical && wanted.y < 0.0,
collision_below: self.vertical_collision_below,
living: true,
resolved_movement: moved,
fall_distance: self.fall_distance,
};
// Unknown block states have no bounce; the provider cannot fail.
// The port's phases cannot fail for a mob: a block outside the
// registry reads as one with no bounce, never as an error.
let _ = restitute_movement(
&mut MobRestitution {
mob: self,
world,
movement,
world: w,
},
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 {
@@ -2111,21 +2103,20 @@ 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> {
struct MobRestitution<'a, 'w> {
mob: &'a mut Mob,
world: &'a crate::world::World,
movement: &'a horizon_world::movement_properties::BoundMovementProperties,
world: &'a MobWorld<'w>,
}
impl<'a> MobRestitution<'a> {
impl<'a, 'w> MobRestitution<'a, 'w> {
/// The block's movement data: its coefficients and its bounce tag.
fn movement(
&self,
state: BlockStateId,
) -> Result<horizon_world::movement_properties::MovementBlock, ()> {
self.movement.get(state).map_err(|_| ())
self.world.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 = ();
@@ -2171,7 +2162,7 @@ impl RestitutionWorld for MobRestitution<'_> {
position.z.floor() as i32,
));
};
let state = self.world.block(support[0], support[1], support[2]);
let state = self.world.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, 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 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 i = i32::from(i);
let y = base + (i >> 8);
let y = min + s * 16 + (i >> 8);
if (y0..=y1).contains(&y) {
visit(poi, [qx * 16 + (i & 15), y, qz * 16 + (i >> 4 & 15)]);
}
+41
View File
@@ -7,6 +7,7 @@ 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},
@@ -138,6 +139,8 @@ 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.
@@ -146,6 +149,21 @@ 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,
@@ -176,6 +194,7 @@ impl Screen {
Self::Direct { .. } => "direct_connect",
Self::ConfirmDelete { .. } => "server_delete",
Self::Friend { .. } => "friends_join",
Self::Page(form) => form.id(),
Self::Disconnected { .. } => "disconnected",
}
}
@@ -744,6 +763,7 @@ impl Screen {
53,
),
],
Self::Page(form) => form.buttons(w, h),
Self::Friend { code, .. } => vec![
b(
half(w) - 100.0,
@@ -864,6 +884,7 @@ impl Screen {
]),
)
}
Self::Page(form) => form.narration(),
Self::Friend { .. } => ScreenInfo::new(
"multiplayer/friend",
get("horizon.multiplayer.join_friend", "Join a Friend"),
@@ -1318,6 +1339,7 @@ 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, ..
} => {
@@ -1383,6 +1405,7 @@ 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),
@@ -1431,6 +1454,9 @@ 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 {
@@ -1823,6 +1849,7 @@ 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])
@@ -1851,6 +1878,13 @@ 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 {
@@ -1910,6 +1944,9 @@ 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,
@@ -1934,6 +1971,9 @@ 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;
};
@@ -1960,6 +2000,7 @@ impl Screen {
pub fn escape(&mut self, saves: &Path) -> bool {
match self {
Self::List { .. }
| Self::Page(_)
| Self::Friend {
from_title: true, ..
} => true,
@@ -0,0 +1,467 @@
//! 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,6 +523,22 @@ 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 {
+27 -43
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,29 +80,21 @@ 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;
@@ -110,20 +102,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,
@@ -131,11 +123,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,
)
@@ -145,11 +137,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,
);
}
@@ -165,22 +157,14 @@ impl NoResources {
}
}
fn title_size() -> f32 {
ui_theme::sizes().title
}
const TITLE_SIZE: f32 = horizon_theme::TITLE.px;
const TITLE_LINE: f32 = 40.0;
fn body() -> f32 {
ui_theme::sizes().body
}
const BODY: f32 = horizon_theme::BODY.px;
const LINE: f32 = 24.0;
const PARAGRAPH_GAP: f32 = 10.0;
fn button_text() -> f32 {
ui_theme::sizes().ui
}
const BUTTON_TEXT: f32 = horizon_theme::UI.px;
const SMALL_LINE: f32 = 18.0;
fn control() -> f32 {
ui_theme::sizes().control_large
}
const CONTROL: f32 = horizon_theme::CONTROL_LARGE;
struct Layout {
x: f32,
@@ -200,7 +184,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
}
}
@@ -248,7 +232,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]),
@@ -271,7 +255,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:?}"
);
});
@@ -282,10 +266,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;
+4 -8
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,11 +339,7 @@ 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() {
@@ -354,7 +350,7 @@ impl TopBarUi {
&shortcut,
x + menu_width - shortcut_width - 8.0,
y + 6.0,
style::soft(),
style::SOFT,
true,
);
}
+7 -10
View File
@@ -1940,8 +1940,8 @@ impl Title {
)
.does(crate::widgets::Action::Exit),
];
// CommonButtons' icon buttons; Horizon's Join a Friend (an
// invite code) sits behind vanilla's Friends button.
// CommonButtons' icon buttons: vanilla's Friends button opens a crate's page or
// Join a Friend, inactive with neither (`multiplayer::title_entry`).
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),
true,
id != 5 || crate::multiplayer::has_title_entry(),
id,
);
button.icon = Some((icon, 15.0));
@@ -3658,12 +3658,7 @@ 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(
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
)
) {
} else if matches!(&self.mode, Mode::Multiplayer(screen) if screen.has_fields()) {
// A press on a text field takes the focus from the button.
self.focused_button = None;
}
@@ -3782,7 +3777,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 = Mode::Multiplayer(crate::multiplayer::Screen::friend(true));
self.mode = crate::multiplayer::title_entry().map_or(Mode::Main, Mode::Multiplayer);
Action::None
}
(Mode::Onboarding(_), Some(onboarding::SOUNDS)) => Action::Sounds,
@@ -5306,6 +5301,8 @@ 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"),
+50 -2
View File
@@ -100,13 +100,61 @@ 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 = Fixture::new();
let mut f = with_resolver();
f.click("Friends");
assert!(matches!(
f.title.mode,
@@ -118,7 +166,7 @@ mod multiplayer_parent_navigation {
#[test]
fn friend_escape_returns_to_title() {
let mut f = Fixture::new();
let mut f = with_resolver();
f.click("Friends");
assert!(matches!(
f.title.mode,
+46 -111
View File
@@ -1,6 +1,6 @@
//! 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
//! 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
//! 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,76 +39,44 @@ pub(crate) fn set_preference(dir: &Path, key: &str, value: Option<&str>) {
let _ = std::fs::write(dir.join("horizon.txt"), out);
}
/// The overlays' palette (`menu_style`) in the HUD's colour encoding, plus the
/// The dark palette (`horizon_theme::DARK`) in the HUD's colour encoding, plus the
/// translucent plates the overlays draw over the game.
pub(crate) mod color {
use horizon_client_render::menu_style::{self, OverlayColours};
pub use menu_style::hud_colour as hex;
fn p() -> &'static OverlayColours {
&menu_style::get().overlay
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)
}
/// The page, under every veil.
pub fn bg() -> u32 {
hex(p().page, 255)
}
pub const 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 fn raised() -> u32 {
hex(p().ink, 20)
}
pub const RAISED: u32 = hex(P.ink, 20);
/// Pressed plates and a switch that is off (`plate`, the same way).
pub fn hover() -> u32 {
hex(p().ink, 36)
}
pub const HOVER: u32 = hex(P.ink, 36);
/// One-pixel rules between rows, slider tracks.
pub fn border() -> u32 {
hex(p().rule, 255)
}
pub const BORDER: u32 = hex(P.rule, 255);
/// Text and selection (`ink`).
pub fn text() -> u32 {
hex(p().ink, 255)
}
pub const TEXT: u32 = hex(P.ink, 255);
/// Values and secondary text (`ink_soft`).
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 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(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 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
}
/// 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;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Face {
@@ -119,34 +87,20 @@ 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 {
/// 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 {
fn load() -> Self {
let f = |b: &'static [u8]| FontRef::try_from_slice(b).expect("bundled font");
Self {
faces: [
f(given.regular),
f(given.medium),
f(given.semibold),
f(given.mono),
f(horizon_theme::fonts::GEIST_REGULAR),
f(horizon_theme::fonts::GEIST_MEDIUM),
f(horizon_theme::fonts::GEIST_SEMIBOLD),
f(horizon_theme::fonts::GEIST_MONO),
],
})
}
}
fn face(&self, f: Face) -> &FontRef<'static> {
&self.faces[f as usize]
@@ -188,8 +142,7 @@ struct Cached {
}
/// Glyphs and corner masks rasterized into the atlas region `horizon/ui-text`.
struct Cache {
/// None: no fonts were given, and text draws nothing.
fonts: Option<Fonts>,
fonts: Fonts,
entries: HashMap<Key, Cached>,
shelf: (u32, u32, u32),
pending: Vec<crate::font::Patch>,
@@ -277,16 +230,9 @@ impl Cache {
if let Some(c) = self.entries.get(&key) {
return *c;
}
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 scale = self.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 fonts.face(face).outline_glyph(glyph) {
let made = match self.fonts.face(face).outline_glyph(glyph) {
Some(outline) => {
let b = outline.px_bounds();
let (w, h) = (b.width() as u32, b.height() as u32);
@@ -513,17 +459,14 @@ impl<'a> Ui<'a> {
let atlas = self.atlas;
let gui = self.scale;
with_cache(atlas, |c| {
let Some(fonts) = &c.fonts else {
return (0.0, 0.0, 0.0, Vec::new());
};
let scale = fonts.scale(face, px);
let scale = c.fonts.scale(face, px);
let (ascent, descent) = {
let f = fonts.face(face).as_scaled(scale);
let f = c.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() && fonts.face(face).glyph_id(ch).0 == 0)
let pack = (!ch.is_whitespace() && c.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);
@@ -548,10 +491,7 @@ impl<'a> Ui<'a> {
continue;
}
let (id, advance, kern) = {
let Some(fonts) = &c.fonts else {
break;
};
let font = fonts.face(face);
let font = c.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)))
@@ -666,8 +606,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");
@@ -689,7 +629,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;
@@ -756,12 +696,7 @@ mod tests {
let atlas = HudAtlas::build(Path::new("/nonexistent/horizon-test-assets")).unwrap();
reset_atlas();
let draw = |cache: &mut Cache| {
let glyph = cache
.fonts
.as_ref()
.unwrap()
.face(Face::Regular)
.glyph_id('A');
let glyph = cache.fonts.face(Face::Regular).glyph_id('A');
assert!(
cache
.glyph(&atlas, Face::Regular, glyph, 16.0)
@@ -847,7 +782,7 @@ mod tests {
let lines = wrapping_lines(
&ui,
Face::Mono,
micro(),
MICRO,
"the quick brown fox jumps over the lazy dog",
90.0,
);
@@ -855,7 +790,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!(
@@ -877,7 +812,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
@@ -1,15 +0,0 @@
[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
+2 -35
View File
@@ -1,48 +1,15 @@
[package]
name = "horizon-game"
default-run = "horizon-client"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "The game as the platform runs it: the engine's entry with the platform's parts registered, for an executable to start, and this repository's plain executable (docs/layers.md)"
# This repository's plain executable: the platform's game with no Horizon part (`cargo run -p
# horizon-game`, the harnesses, horizon-ctl launch, horizon-plain, the server build). The official
# executable is horizon-official's.
[[bin]]
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
[features]
# The same features as horizon-client's, which they turn on (its Cargo.toml says what each does).
default = ["audio", "controllers", "horizon-extras"]
audio = ["horizon-client/audio"]
controllers = ["horizon-client/controllers"]
horizon-extras = ["horizon-client/horizon-extras"]
friends = ["horizon-client/friends"]
rng-trace = ["horizon-client/rng-trace"]
system-allocator = ["horizon-client/system-allocator"]
alloc-census = ["horizon-client/alloc-census", "horizon-threads/alloc-census"]
windows-mimalloc = ["horizon-client/windows-mimalloc"]
windows-segment-heap = ["horizon-client/windows-segment-heap"]
description = "The game as the platform runs it: the engine's entry with the platform's parts registered, for an executable to start (docs/layers.md)"
[dependencies]
horizon-client = { path = "../horizon-client", default-features = false }
horizon-client = { path = "../horizon-client" }
horizon-threads = { path = "../horizon-threads" }
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
[dev-dependencies]
horizon-core = { path = "../horizon-core" }
serde_json = "1"
[build-dependencies]
# The build script runs on the host, but embeds resources based on the target OS.
winresource = "0.1"
[lints]
workspace = true
-8
View File
@@ -1,8 +0,0 @@
//! Gives the Windows executable Horizon's icon and its stack, as horizon-official's
//! (`packaging/windows/exe.rs`).
#[path = "../../packaging/windows/exe.rs"]
mod windows_exe;
fn main() {
windows_exe::resources("horizon-client");
}
-6
View File
@@ -33,9 +33,3 @@ pub const MOD_API_LEVEL: [u8; horizon_mods::api_level::MARK.len()] = horizon_mod
pub fn main() {
horizon_client::entry::main();
}
/// [`main`] with native mods' hooks: the launcher's generated executable for a build with native
/// mods calls it, so that build runs with the platform's parts too.
pub fn main_with(mods: horizon_client::mod_api::Mods) {
horizon_client::entry::main_with(mods);
}
-105
View File
@@ -83,72 +83,6 @@ 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.
@@ -244,32 +178,6 @@ 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() {
@@ -347,9 +255,6 @@ 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()
@@ -510,14 +415,6 @@ 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();
@@ -557,8 +454,6 @@ mod tests {
"started 0.2.31",
"published true",
"published false",
"Menus",
"Hidden",
"frame 4"
]
);
+55
View File
@@ -19,6 +19,11 @@ 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`]);
@@ -40,6 +45,11 @@ 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.
@@ -85,6 +95,16 @@ 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 {
@@ -107,6 +127,23 @@ 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 {
@@ -167,6 +204,19 @@ 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();
@@ -185,4 +235,9 @@ 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()
}
}
+1 -3
View File
@@ -274,10 +274,8 @@ impl Bone {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Pass {
/// In the world, seen by the camera (the window's own model too, in third person).
/// In the world, seen by the camera.
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,7 +16,6 @@ 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]
+2 -4
View File
@@ -1,7 +1,7 @@
//! The official game: the platform's game (`horizon-game`) with Horizon's own in-game parts
//! plugged in through the platform's hooks (docs/layers.md, "How a Horizon crate plugs in").
//! The release builds ship its executable under the game's name, `horizon-client`
//! (`game-build.yml`); horizon-game holds this repository's plain executable.
//! (`game-build.yml`); horizon-client keeps its own plain executable.
//!
//! Each Horizon crate that runs in the game gives a `&'static` value implementing
//! `horizon_platform::hooks::Hooks`, and [`register`] adds it. Nothing here depends on an engine
@@ -11,15 +11,13 @@
use horizon_platform::hooks::{self, Hooks};
/// Every Horizon part in the game, in the order its hooks run.
const PARTS: [&dyn Hooks; 4] = [
const PARTS: [&dyn Hooks; 3] = [
// 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
+2 -5
View File
@@ -54,10 +54,7 @@ pub mod hooks {
Mesh, NameDecoration, PartPoses, Pass, PlayerDraw, PlayerModel, PlayerModels, Quad,
SpriteStore, Sprites,
};
pub use horizon_hooks::{
Activity, Client, Dimension, GameMode, Hooks, Play, Started, TooLate, register, seat,
tunnel,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
@@ -68,7 +65,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, register};
pub use horizon_hooks::pages::{Act, Label, Page, Place, Row, connect, register};
}
/// Drawing on the HUD (docs/layers.md, "Platform registries").
+1 -1
View File
@@ -108,7 +108,7 @@ ones in this version.
flagged, and nothing happens with the anticheat off.
```sh
cargo build --profile fast -p horizon-game --bin horizon-client
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-bots
python3 tools/anticheat_e2e.py --bin-dir target/fast
python3 tools/server_bench.py --servers horizon --bots 60 --behaviour walk --anticheat # its cost
+1 -1
View File
@@ -20,7 +20,7 @@ Checks: a bare name is a harness (`python3 tools/parity.py <name>`, script `tool
For the full set of harnesses a file can affect, ask the runner:
`printf '%s\n' <paths> > /tmp/p.txt && python3 tools/parity.py --changed-files /tmp/p.txt --list`.
Client probes are built with `-p horizon-client-lib`; the game with `-p horizon-game`.
Client probes are built with `-p horizon-client-lib`; the game with `-p horizon-client`.
Because the probe library does not compile gameplay, its dep-info does not include `game.rs`
or `game/`. Exact harness counts still depend on refreshed records and reachability; use the
command above to inspect the selection rather than relying on historical counts.
+1 -1
View File
@@ -46,7 +46,7 @@ The report is the [benchmark report format](benchmark-report.md): startup to the
## Reproduce it
You need Horizon built (`cargo build --release -p horizon-game -p horizon-client-lib --bins`) and its resources prepared from your own copy of 26.3 (which also provides `.local/minecraft/26.3`, the vanilla client the driver starts), Java 25 with `javac`, and Python 3.10 or newer.
You need Horizon built (`cargo build --release -p horizon-client -p horizon-client-lib --bins`) and its resources prepared from your own copy of 26.3 (which also provides `.local/minecraft/26.3`, the vanilla client the driver starts), Java 25 with `javac`, and Python 3.10 or newer.
```sh
python3 tools/perf_bench.py --game horizon --label horizon-mine
+1 -1
View File
@@ -7,7 +7,7 @@ It only measures. It does not change the build.
## What it measures
Every build is the one a developer runs, `cargo build --locked -p horizon-game --profile <profile> --timings`
Every build is the one a developer runs, `cargo build --locked -p horizon-client --profile <profile> --timings`
(the game and `horizon-ctl`), in a target folder of its own.
- **Cold:** a build from an empty target folder, for the `dev` and `fast` profiles. Its timings page gives the
+3 -3
View File
@@ -9,7 +9,7 @@ Optimizations are not extras; they are part of the game in every build.
## Building without Horizon's extras
```sh
cargo build --release -p horizon-game --no-default-features --features audio,controllers
cargo build --release -p horizon-client --no-default-features --features audio,controllers
```
Best effort: CI builds and tests the default only. The server build (`--no-default-features`,
@@ -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 | crate `horizon-discord` | registered only by the official executable (`horizon-official`); horizon-client's own executable has none |
| Discord Rich Presence | `discord_presence.rs`, `game/discord.rs` | the `discord` feature, which `horizon-extras` turns on |
| Play with Friends | hosting: `game/friends.rs`, `game/friends/screen.rs`; joining: crate `horizon-friends` (`game`) | hosting: the `friends` feature (`game/friends_off.rs` stands in); joining: only the official executable registers it ([layers.md](layers.md#how-a-horizon-crate-plugs-in)) |
| 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 `friends` feature above, the gates
`grep -rn horizon-extras crates/` lists every gate. Apart from the three features 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.
+1 -1
View File
@@ -131,7 +131,7 @@ It runs only in frames drawn flat (Metal past `flat::MIN_SECTIONS` sections in v
`tools/graphics_bench.py` opens a fresh creative world (seed 1234) on a virtual screen and flies the camera along four fixed 192-block paths, over a village, a forest, the ocean and a mountain, at render distances 8, 16 and 32, one game per distance. Each flight takes 12 seconds and reports average FPS, 1% lows, frame-time percentiles, CPU time per frame (frame time minus waits for the GPU, the display and the limiter), the split of that CPU time (world update, ticks, frame preparation, renderer) for all frames and for the slowest 1%, and GPU time per frame from timestamp queries.
```sh
cargo build --release -p horizon-game -p horizon-client-lib --bins
cargo build --release -p horizon-client -p horizon-client-lib --bins
python3 tools/graphics_bench.py --label mine # lavapipe on vscreen: CPU-side numbers
python3 tools/graphics_bench.py --label mine-gpu --offscreen # the desktop's RTX 4080
python3 tools/graphics_bench.py --compare baseline-4080 mine-gpu # markdown table
+1 -1
View File
@@ -63,7 +63,7 @@ for Windows includes it), else against the SHA-256 GitHub publishes, and never a
- a `game-files` folder for the files you prepare, kept across updates.
Options: `--version vX.Y.Z`, `--dir FOLDER`, `--bin FOLDER` (Windows: `-Version`, `-Dir`). Other systems,
such as Linux on ARM, build from source: `cargo build --release -p horizon-game --bin horizon-client -p
such as Linux on ARM, build from source: `cargo build --release -p horizon-client --bin horizon-client -p
horizon-server` and keep both programs in one folder.
On Nix and NixOS, the repository's flake builds `horizon-server` from source: [nix.md](nix.md).
+20 -6
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`, `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`.
`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`.
- **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
@@ -224,7 +224,7 @@ that. horizon-plain becomes a plain build of this repository.
the official game build in CI, plugged in through the platform's hooks. Players never compile them: a native
mod is source the launcher compiles per instance, so it is not the way. The official executable is a thin
binary crate in the Horizon repository that links the platform and the Horizon crates; this repository keeps
its own plain executable, `horizon-game`'s `horizon-client`. The game runs the same without them.
its own plain executable. The game runs the same without them.
4. Every Horizon address is in `horizon-addresses`, which moves with the layer.
5. The launcher is Horizon's: it moves too. It installs the game from this repository's releases and adds the
Horizon layer to it.
@@ -257,7 +257,7 @@ The seam's points 1-3 above, as they work today (layer plan M5).
|---|---|---|
| `horizon-hooks` | engine | The `Hooks` trait, `register`, and the engine's dispatch (`horizon_hooks::engine`) |
| `horizon-platform` | platform | `horizon_platform::hooks`: `Hooks`, `Started`, `register`, without the dispatch |
| `horizon-game` | platform | The game for an executable to start: `horizon_game::main()`, the allocator, the mod API mark. The platform's own parts register here as they leave the engine (M6). Its `horizon-client` executable is this repository's plain game, with no Horizon part: `cargo run -p horizon-game`, the harnesses, `horizon-ctl launch`, the server build and horizon-plain run it |
| `horizon-game` | platform | The game for an executable to start: `horizon_game::main()`, the allocator, the mod API mark. The platform's own parts register here as they leave the engine (M6) |
| `horizon-official` | Horizon | The official executable: `register()` adds the Horizon crates' hooks, then it runs `horizon_game::main()` |
A Horizon crate that runs in the game:
@@ -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, Title, Pause }
pub enum Place { HorizonSettings, Pause, Extras, Title }
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,6 +371,7 @@ 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
@@ -382,9 +383,11 @@ 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) }
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
```
- `HorizonSettings`: a row of the Horizon Settings page, after the engine's own pages. `Title` and `Pause`: a
@@ -395,6 +398,17 @@ pub enum Act { Press, PressBack, Slide(f32), Bind(String) }
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
+8 -11
View File
@@ -116,10 +116,10 @@ verified, it is reused and the named files aren't read. Two things no such launc
```sh
cargo run --release -p horizon-prepare -- --prepare --server-jar ~/Downloads/server.jar
cargo run --release -p horizon-prepare -- sounds
cargo run --release -p horizon-game -- --render-distance 12
cargo run --release -p horizon-client -- --render-distance 12
```
Development builds that link quickly: `cargo build --profile fast -p horizon-game`.
Development builds that link quickly: `cargo build --profile fast -p horizon-client`.
`HORIZON_OCCLUSION_PROFILE=1` enables detailed entity-occlusion phase and bounds-scan timings
in `horizon-ctl stats` and `bench`. The normal benchmark keeps these CPU-clock reads disabled;
@@ -174,7 +174,7 @@ The native Rust adapter recognizes 26.2, 26.1.x, 1.21.11, 1.21.9/1.21.10,
the adapter. The client still needs its prepared **26.3** resources.
```sh
HORIZON_OLDER_SERVERS=1 cargo run --profile fast -p horizon-game -- --server 127.0.0.1:25565
HORIZON_OLDER_SERVERS=1 cargo run --profile fast -p horizon-client -- --server 127.0.0.1:25565
```
In PowerShell, set `$env:HORIZON_OLDER_SERVERS = '1'`, then run the same Cargo
@@ -289,7 +289,7 @@ local socket; under control, losing window focus does not pause the game or
release held keys, so a script can play while you use other windows.
```sh
cargo build --release -p horizon-game -p horizon-client-lib --bins
cargo build --release -p horizon-client -p horizon-client-lib --bins
C=target/release/horizon-ctl
$C launch --world Survival --seed 5 --wait # start the game, open or create the world, wait for it
$C state # player, world, screen, crosshair target
@@ -427,12 +427,9 @@ 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.
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
`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
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
@@ -449,7 +446,7 @@ To play on another machine without Rust, JDK or prepared resources,
package a release build where they exist:
```sh
cargo build --release -p horizon-game
cargo build --release -p horizon-client
python3 tools/package_native_client.py --out ~/builds/horizon
```
+1 -1
View File
@@ -76,7 +76,7 @@ On a Linux machine with the vanilla data prepared (`python3 tools/lab.py prepare
# Pumpkin: your own build, run as a black box (GPL-3.0: don't read or copy its source into Horizon)
git clone https://github.com/Pumpkin-MC/Pumpkin && cd Pumpkin && git checkout 55cb804cc && cargo build --release && cd -
# Horizon
cargo build --release -p horizon-game --bin horizon-client
cargo build --release -p horizon-client --bin horizon-client
cargo build --release -p horizon-server -p horizon-bots
# The comparison: 0, 20, 60 and 100 bots, three rounds, the servers alternated
taskset -c 0-3 python3 tools/server_bench.py --servers horizon,pumpkin --pumpkin Pumpkin/target/release/pumpkin \
+2 -1
View File
@@ -53,7 +53,8 @@ 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. Hosting needs no hook: the
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
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.
+3 -3
View File
@@ -427,11 +427,11 @@ 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 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
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
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-game --no-default-features`
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
and copy `target/release/horizon-client` as `horizon-engine`. macOS and Windows have no such libraries to
avoid, so they keep the one archive.
+1 -1
View File
@@ -153,7 +153,7 @@ with. The coin is a fresh random key from the system, never the world's random.
A native mod is built into the game it runs in ([native-mods.md](native-mods.md)). For the server: the source of the
release the server runs, `horizon-mixins apply` with the `smp` bundle, then the server build
(`cargo build --release --no-default-features -p horizon-game`), and `horizon-server`'s `[engine] path` set to
(`cargo build --release --no-default-features -p horizon-client`), and `horizon-server`'s `[engine] path` set to
the result. When to deploy it on the official server is the owners' decision; the mod's README holds the exact
commands.
+4 -4
View File
@@ -18,11 +18,11 @@ mimalloc. No frame-rate improvement is guaranteed.
```sh
# Current mimalloc baseline, then add Segment Heap. Keep the same profile.
cargo build --profile fast -p horizon-game --bin horizon-client
cargo build --profile fast -p horizon-game --bin horizon-client --features windows-segment-heap
cargo build --profile fast -p horizon-client --bin horizon-client
cargo build --profile fast -p horizon-client --bin horizon-client --features windows-segment-heap
# Older commands still build, but these repeat the two variants above.
cargo build --profile fast -p horizon-game --bin horizon-client --features windows-mimalloc
cargo build --profile fast -p horizon-game --bin horizon-client --features windows-segment-heap,windows-mimalloc
cargo build --profile fast -p horizon-client --bin horizon-client --features windows-mimalloc
cargo build --profile fast -p horizon-client --bin horizon-client --features windows-segment-heap,windows-mimalloc
```
Copy each executable to a separate experiment directory before building the next
+1 -1
View File
@@ -17,7 +17,7 @@ the Java exporters used by the game.
```powershell
rustup target add aarch64-pc-windows-msvc
cargo build --release --locked --target aarch64-pc-windows-msvc `
-p horizon-game -p horizon-client-lib --bin horizon-client --bin horizon-ctl `
-p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl `
-p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
```
+7 -58
View File
@@ -83,13 +83,6 @@ pub(super) fn has_official(stage: &Path) -> bool {
stage.join("crates/horizon-official/Cargo.toml").is_file()
}
/// Whether the revision's platform game starts native mods (`horizon_game::main_with`): the
/// generated executable then runs through it, with the platform's parts (pack formats...).
fn has_game_main_with(stage: &Path) -> bool {
fs::read_to_string(stage.join("crates/horizon-game/src/lib.rs"))
.is_ok_and(|lib| lib.contains("pub fn main_with("))
}
/// The package the game build compiles: the generated executable when trait mods need one, else
/// the game the release ships for `target` (the server build is horizon-client's, without
/// Horizon's parts, as `game-build.yml`'s).
@@ -144,7 +137,7 @@ fn names_mods(stage: &Path) -> bool {
/// The generated executable's `main`: the trait mods of bundles `ids`, in that order, named by
/// those ids when `named`; with Horizon's own parts registered when `official` (the revision has
/// `horizon-official`, and the build is the game's, not the server's: its `official` feature).
fn main_rs(ids: &[&str], named: bool, official: bool, platform: bool) -> String {
fn main_rs(ids: &[&str], named: bool, official: bool) -> String {
let entries = (0..ids.len())
.map(|i| format!(" mod_{i}::hooks(),\n"))
.collect::<String>();
@@ -158,11 +151,6 @@ fn main_rs(ids: &[&str], named: bool, official: bool, platform: bool) -> String
} else {
""
};
let run = if platform {
"horizon_game::main_with"
} else {
"horizon_client::entry::main_with"
};
// Mirrors crates/horizon-official/src/main.rs, with trait mods passed to main_with.
format!(
r#"//! Generated by the launcher.
@@ -177,7 +165,7 @@ fn main() {{
std::hint::black_box(&MOD_API_LEVEL);
let mods: horizon_client::mod_api::Mods = vec![
{entries} ].into();
{naming}{official} {run}(mods);
{naming}{official} horizon_client::entry::main_with(mods);
}}
"#
)
@@ -313,15 +301,6 @@ pub(super) fn stage(
{
client.insert("default-features".into(), false.into());
}
// The platform's game, which starts the mods with the platform's parts; its features are
// horizon-client's, chosen through horizon-client above.
let platform = has_game_main_with(stage);
if platform {
let mut dep = toml::Table::new();
dep.insert("path".into(), "../horizon-game".into());
dep.insert("default-features".into(), false.into());
deps.insert("horizon-game".into(), toml::Value::Table(dep));
}
// The official game's parts, for the game build only (the `official` feature, a default one).
let official = has_official(stage);
if official {
@@ -452,7 +431,7 @@ pub(super) fn stage(
let ids: Vec<_> = mods.iter().map(|(_, bundle)| bundle.id.as_str()).collect();
fs::write(
game.join("src/main.rs"),
main_rs(&ids, names_mods(stage), official, platform),
main_rs(&ids, names_mods(stage), official),
)
.map_err(|e| e.to_string())?;
let root = stage.join("Cargo.toml");
@@ -526,12 +505,12 @@ mod tests {
#[test]
fn generated_main_uses_the_games_allocator() {
let game = include_str!("../../../crates/horizon-game/src/main.rs");
let game = include_str!("../../../crates/horizon-client/src/main.rs");
let allocator = game
.lines()
.find(|line| line.starts_with("static ALLOCATOR:"))
.unwrap();
let generated = main_rs(&[], false, false, false);
let generated = main_rs(&[], false, false);
assert!(generated.lines().any(|line| line == allocator));
}
@@ -539,7 +518,7 @@ mod tests {
fn generated_main_keeps_mod_order() {
let ids = ["base", "extra"];
for count in 0..=2 {
let text = main_rs(&ids[..count], false, false, false);
let text = main_rs(&ids[..count], false, false);
assert!(text.contains("entry::main_with(mods)"));
assert_eq!(text.matches("::hooks()").count(), count);
assert!(!text.contains("name_mods"));
@@ -550,7 +529,7 @@ mod tests {
}
}
// A revision that names its mods gets the ids in the same order, before main_with.
let named = main_rs(&ids, true, false, false);
let named = main_rs(&ids, true, false);
let naming = named
.find(r#"mod_api::name_mods(&["base", "extra"]);"#)
.unwrap();
@@ -687,36 +666,6 @@ mod tests {
}
/// A staged workspace with horizon-client, as `stage` expects it.
#[test]
fn a_revision_with_the_platforms_main_with_starts_the_mods_through_it() {
let dir = fixture("[mod]\nid = 'example'\nversion = '1'\n[api]\npath = 'engine'\n");
let stage_dir = workspace(&dir);
let resolution = super::super::dependencies::Resolution { order: vec![0] };
let main = || fs::read_to_string(stage_dir.join(GAME).join("src/main.rs")).unwrap();
let game = || read(&stage_dir.join(GAME).join("Cargo.toml")).unwrap();
// Before horizon-game had it: the engine's entry, as that revision's release ran.
let bundle = super::super::bundle(&dir, None).unwrap();
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
assert!(main().contains(" horizon_client::entry::main_with(mods);"));
assert!(game()["dependencies"].get("horizon-game").is_none());
fs::create_dir_all(stage_dir.join("crates/horizon-game/src")).unwrap();
fs::write(
stage_dir.join("crates/horizon-game/src/lib.rs"),
"pub fn main_with(mods: horizon_client::mod_api::Mods) {}\n",
)
.unwrap();
let bundle = super::super::bundle(&dir, None).unwrap();
stage(&stage_dir, &[bundle], &[vec![]], &resolution).unwrap();
assert!(main().contains(" horizon_game::main_with(mods);"));
assert!(!main().contains("entry::main_with"));
let game = game();
let platform = &game["dependencies"]["horizon-game"];
assert_eq!(platform["path"].as_str(), Some("../horizon-game"));
// A server build's `--no-default-features` still reaches the game through horizon-client.
assert_eq!(platform["default-features"].as_bool(), Some(false));
fs::remove_dir_all(dir).unwrap();
}
fn workspace(dir: &Path) -> std::path::PathBuf {
let stage_dir = dir.join("workspace");
fs::create_dir_all(stage_dir.join("crates/horizon-client")).unwrap();
+1 -1
View File
@@ -16,7 +16,7 @@ FROM rust:1-slim-bookworm AS build
WORKDIR /src
COPY . .
# The server build of the game (no sound, no controllers: it needs neither ALSA nor udev) as horizon-engine.
RUN cargo build --release --locked -p horizon-game --no-default-features --bin horizon-client \
RUN cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client \
&& cargo build --release --locked -p horizon-server --bin horizon-server \
&& mkdir /out && cp target/release/horizon-client /out/horizon-engine && cp target/release/horizon-server /out/
+1 -1
View File
@@ -1,5 +1,5 @@
//! Windows executables' resources, shared by the build scripts of the game's executables
//! (`crates/horizon-game/build.rs`, `crates/horizon-official/build.rs`, which include it with
//! (`crates/horizon-client/build.rs`, `crates/horizon-official/build.rs`, which include it with
//! `#[path]`). Embeds Horizon's icon (`packaging/icons/horizon.ico`) as icon resource 1: Explorer
//! and the task bar show it, and the window takes it as its icon. The optional Segment Heap
//! manifest (`crates/horizon-client/windows-segment-heap.manifest`, the package's
+1 -1
View File
@@ -285,7 +285,7 @@ class RunTests(unittest.TestCase):
self.report("--profiles", "dev,fast", "--crates", "horizon-items", "--jobs", "3")
for call in self.builds():
args = call["args"]
self.assertEqual(args[:5], ["build", "--locked", "-p", "horizon-game", "--profile"])
self.assertEqual(args[:5], ["build", "--locked", "-p", "horizon-client", "--profile"])
self.assertIn(args[5], ["dev", "fast"])
self.assertIn("--timings", args)
self.assertEqual(args[args.index("--jobs") + 1], "3")
+2 -1
View File
@@ -112,7 +112,8 @@ class PrReadyTests(unittest.TestCase):
"examples/probe/main.rs", "tests/lan_bot.rs"]:
self.assertEqual(pr_ready.touched_crates({f"crates/horizon-client/{path}"}, packages),
["horizon-client-lib"])
for path in ["src/lib.rs", "src/entry.rs", "src/game.rs", "src/game/combat.rs", "Cargo.toml", "build.rs"]:
for path in ["src/lib.rs", "src/main.rs", "src/entry.rs", "src/game.rs", "src/game/combat.rs",
"tests/launch_api.rs", "Cargo.toml", "build.rs"]:
self.assertEqual(pr_ready.touched_crates({f"crates/horizon-client/{path}"}, packages),
["horizon-client"])
+1 -1
View File
@@ -1158,7 +1158,7 @@ def main():
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-game', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-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)
+1 -1
View File
@@ -34,7 +34,7 @@ Checks:
Each server runs in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free port,
in a flat world (creative unless the check says otherwise: the bots fly). Build first:
cargo build --profile fast -p horizon-game -p horizon-client-lib --bin horizon-client --bin horizon-ctl
cargo build --profile fast -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl
cargo build --profile fast -p horizon-bots
"""
import argparse
+1 -1
View File
@@ -96,7 +96,7 @@ def main():
client, ctl_path = bindir / 'horizon-client', bindir / 'horizon-ctl'
for binary in (client, ctl_path):
if not binary.exists():
raise Failed(f'{binary} is missing; build it (cargo build --profile fast -p horizon-game '
raise Failed(f'{binary} is missing; build it (cargo build --profile fast -p horizon-client '
'-p horizon-client-lib --bins) or pass --bin-dir')
resources = Path(args.resources).resolve()
if not resources.exists():
+2 -2
View File
@@ -5,7 +5,7 @@
python3 tools/build_times.py run --profiles dev --only incremental --crates horizon-items
python3 tools/build_times.py compare before.json after.json
`run` builds the game (`cargo build --locked -p horizon-game --profile P --timings`) and writes a JSON
`run` builds the game (`cargo build --locked -p horizon-client --profile P --timings`) and writes a JSON
report and a Markdown table under .local/build-times/reports/:
- cold: a build from an empty target folder of its own (never target/), per profile. The timings page of
@@ -51,7 +51,7 @@ ROOT = lab.ROOT
REPORT_FORMAT, REPORT_VERSION = "horizon-build-times", 1
WORK = Path(".local") / "build-times"
MARKER = ".horizon-build-times"
PACKAGE = "horizon-game"
PACKAGE = "horizon-client"
PROFILES = ["dev", "fast"]
# The files an edit goes to by default: large, often changed, and each in a different layer. horizon-client's
# game.rs belongs to the game binary, the others to libraries the binary links. Any other crate in the
-9
View File
@@ -6291,14 +6291,6 @@ 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 = []
@@ -7489,7 +7481,6 @@ 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-official'), '--resources',
self.ctl_run('launch', '--offscreen', '--game', str(bins / 'horizon-client'), '--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-official and horizon-ctl (default: build with the fast profile)')
parser.add_argument('--bin-dir', help='prebuilt horizon-client 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-official', '-p', 'horizon-client-lib', '--bins'],
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-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)
+1 -1
View File
@@ -170,7 +170,7 @@ def main():
return
ctl = Path(args.build).resolve() / "horizon-ctl"
if not ctl.exists():
sys.exit(f"{ctl} missing: cargo build --release -p horizon-game -p horizon-client-lib --bins")
sys.exit(f"{ctl} missing: cargo build --release -p horizon-client -p horizon-client-lib --bins")
cwd = ROOT / ".local/graphics-bench"
shutil.rmtree(cwd / "saves" / WORLD, ignore_errors=True)
cwd.mkdir(parents=True, exist_ok=True)
+1 -1
View File
@@ -7364,7 +7364,7 @@ def main():
if args.bin_dir:
bins = Path(args.bin_dir).resolve()
else:
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-game', '-p', 'horizon-client-lib',
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib',
'--bin', 'horizon-client', '--bin', 'horizon-ctl'],
cwd=ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=ROOT,
+4 -2
View File
@@ -51,7 +51,6 @@
"horizon-addresses": "horizon",
"horizon-bench": "horizon",
"horizon-cosmetics": "horizon",
"horizon-discord": "horizon",
"horizon-friends": "horizon",
"horizon-heartbeat": "horizon",
"horizon-launcher": "horizon",
@@ -96,6 +95,7 @@
"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,11 +106,13 @@
"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-shaders",
"horizon-client-render -> horizon-theme"
]
}
+1 -1
View File
@@ -42,7 +42,7 @@ def close(g):
def build():
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-game', '-p', 'horizon-client-lib',
subprocess.run(['cargo', 'build', '--locked', '--profile', 'fast', '-p', 'horizon-client', '-p', 'horizon-client-lib',
'--bin', 'horizon-client', '--bin', 'horizon-ctl', '--example', 'old_world_fixture'], cwd=lan.ROOT, check=True)
meta = json.loads(subprocess.run(['cargo', 'metadata', '--format-version', '1', '--no-deps'], cwd=lan.ROOT,
capture_output=True, text=True, check=True).stdout)
+2 -2
View File
@@ -3,7 +3,7 @@
tarball for one of your own Linux or macOS machines. The resources are Mojang
assets: keep the archive in ignored .local/ and never publish or commit it.
cargo build --release -p horizon-game -p horizon-client-lib --bins
cargo build --release -p horizon-client -p horizon-client-lib --bins
python3 tools/package_native_client.py
"""
import argparse
@@ -88,7 +88,7 @@ def main():
cwd=ROOT, check=True, capture_output=True, text=True).stdout)
args.binary = Path(metadata['target_directory']) / 'release/horizon-client'
if not args.binary.is_file():
raise SystemExit(f'missing {args.binary}: run cargo build --release -p horizon-game')
raise SystemExit(f'missing {args.binary}: run cargo build --release -p horizon-client')
if not (args.resources / 'horizon-resources.json').is_file():
raise SystemExit(f'missing prepared resources in {args.resources}: run horizon-prepare --prepare (cargo run --release -p horizon-prepare -- --prepare)')
if not (args.resources / 'assets/minecraft/sounds.json').is_file():
+1 -1
View File
@@ -66,7 +66,7 @@ Cases:
it, and Undo shows once the pack is picked.
- extras_shell_edges: on Extras, which has no page list, down from the search field lands on the first row, not Done;
a row the right stick leaves mostly hidden scrolls back into view when A presses it, and A changes it.
Needs built fast binaries (`cargo build --profile fast -p horizon-game -p horizon-client-lib --bins`)
Needs built fast binaries (`cargo build --profile fast -p horizon-client -p horizon-client-lib --bins`)
and prepared resources in .local/horizon-client/26.3. Exit status 0 when every case passes.
"""
import argparse
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff. Show more